ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 38e16eb on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
/* Person › History: what was issued, handed back and handed in, grouped per day, newest first; and
|
||||
* their staff app account, with the admin-only code. */
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { fmtDate, itemMap, label, type IssueRec } from "@/lib/compute";
|
||||
import { MButton, MEmpty, MONO, MPill, MRow, MSection } from "@/components/m";
|
||||
import type { PersonTabProps } from "@/components/m/handback/HandBackTab";
|
||||
|
||||
type Group = { key: string; date: string; kind: number; verb: string; parts: Record<string, { name: string; qty: number }>; signed: boolean; at: string };
|
||||
|
||||
const COND_NOTE: Record<string, string> = { "Returned - Damaged": " (damaged)", "Written Off": " (condemned)", Lost: " (lost)" };
|
||||
/** "Today", "12 Aug", or "12 Aug 2025" for another year (the mockup's short date). */
|
||||
function dayLabel(iso: string, today: string): string {
|
||||
if (!iso || iso.length < 10) return fmtDate(iso);
|
||||
if (iso.slice(0, 10) === today) return "Today";
|
||||
const d = new Date(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10));
|
||||
if (Number.isNaN(d.getTime())) return fmtDate(iso);
|
||||
const mon = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][d.getMonth()];
|
||||
const short = `${d.getDate()} ${mon}`;
|
||||
return iso.slice(0, 4) === (today || "").slice(0, 4) ? short : `${short} ${iso.slice(0, 4)}`;
|
||||
}
|
||||
const lower = (t: string) => t; // catalogue names keep their own casing (acronyms such as RN)
|
||||
|
||||
export default function HistoryTab({ staffId, setBar, onError }: PersonTabProps) {
|
||||
const { s, isAdmin, mutate } = useSnap();
|
||||
const st = s.staff.find((x) => x.id === staffId);
|
||||
// Shown once, then gone: the code is a credential and is never in the snapshot.
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
useEffect(() => { setBar(null); }, [setBar]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const byId = itemMap(s);
|
||||
const m: Record<string, Group> = {};
|
||||
const put = (date: string, kind: number, verb: string, i: IssueRec, note = "") => {
|
||||
const gk = `${date}|${kind}`;
|
||||
const g = (m[gk] ||= { key: gk, date, kind, verb, parts: {}, signed: false, at: "" });
|
||||
const it = byId[i.itemId];
|
||||
const pk = `${i.itemId}:${i.si}${note}`;
|
||||
const p = (g.parts[pk] ||= { name: `${lower(label(it))} ${String(it?.sizes[i.si] ?? i.si)}${note}`, qty: 0 });
|
||||
p.qty += i.qty;
|
||||
if (kind === 0) { if (i.receipt) g.signed = true; if ((i.createdAt || "") > g.at) g.at = i.createdAt || ""; }
|
||||
};
|
||||
for (const i of s.issues) {
|
||||
if (i.staffId !== staffId) continue;
|
||||
put(i.date, 0, "Issued", i);
|
||||
if (i.returned) put(i.returned.date, 1, "Handed back", i, COND_NOTE[i.returned.cond] || "");
|
||||
if (i.handedIn) put(i.handedIn, 2, "Handed in", i);
|
||||
}
|
||||
return Object.values(m)
|
||||
.sort((a, b) => b.date.localeCompare(a.date) || a.kind - b.kind)
|
||||
.slice(0, 25)
|
||||
.map((g) => ({
|
||||
key: g.key, date: g.date, signed: g.signed,
|
||||
title: `${g.verb} ${Object.values(g.parts).map((p) => {
|
||||
// A condition note sits after the count: "fleece L ×1 (damaged)".
|
||||
const at = p.name.indexOf(" (");
|
||||
return at > 0 ? `${p.name.slice(0, at)} ×${p.qty}${p.name.slice(at)}` : `${p.name} ×${p.qty}`;
|
||||
}).join(", ")}`,
|
||||
}));
|
||||
}, [s, staffId]);
|
||||
|
||||
if (!st) return null;
|
||||
const account = st.selfEmail ? `Signed up · ${st.selfEmail}` : st.selfCode ? "Code out, not used" : "No account";
|
||||
|
||||
const generate = async () => {
|
||||
setBusy(true);
|
||||
const r = await mutate<{ code: string }>("staff.selfCode", { id: st.id });
|
||||
setBusy(false);
|
||||
if (!r.ok) { onError(r.error); return; }
|
||||
setCode(r.result.code);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MSection label="History" />
|
||||
{groups.length === 0
|
||||
? <MEmpty title="Nothing recorded yet" />
|
||||
: groups.map((g) => <MRow key={g.key} mark="mute" title={g.title} sub={dayLabel(g.date, s.today)} right={g.signed ? <MPill tone="ok">Signed</MPill> : undefined} />)}
|
||||
|
||||
<MSection label="Staff app" />
|
||||
<MRow title="Staff app" sub={account} />
|
||||
{code ? (
|
||||
<>
|
||||
<div aria-live="polite" style={{ fontFamily: MONO, fontSize: 26, fontWeight: 600, letterSpacing: "0.06em", marginTop: 14 }}>{code}</div>
|
||||
<MButton small label="Done" onClick={() => setCode(null)} />
|
||||
</>
|
||||
) : isAdmin && !st.selfEmail ? (
|
||||
<MButton small tone="ink" label={busy ? "Generating…" : st.selfCode ? "New code" : "Generate a code"} disabled={busy} onClick={generate} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
/* Person › Issue: their kit in their size, a basket with per-line flags and reasons, and the bar to
|
||||
* the Sign step. The flags are issueLineFlags(), the same function issue.create checks inside its
|
||||
* lock, and the cap is capCheck() (the shell draws the meters from the same basket). */
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { OVERRIDE_REASONS, bcParse, ccOf, garmentForGroup, garmentForStyle, isPantItem, isTopItem, issueLineFlags, key, label, money, onhand, sizeIndexOf, splitKey, type Item } from "@/lib/compute";
|
||||
import { scanReject } from "@/lib/feedback";
|
||||
import MScan from "@/components/MScan";
|
||||
import { MBar, MButton, MChipRow, MKitCard, MLine, MONO, MReasonChips, MSection, MStepper, useToast } from "@/components/m";
|
||||
import { useBasket, type IssueLine } from "@/components/MBasket";
|
||||
import type { PersonTabProps } from "@/components/m/handback/HandBackTab";
|
||||
import { plural } from "@/components/m/issue/meta";
|
||||
|
||||
const rank = (it: Item) => (isTopItem(it) ? 0 : isPantItem(it) ? 1 : 2);
|
||||
|
||||
export default function IssueTab({ staffId, setBar }: PersonTabProps) {
|
||||
const { s } = useSnap();
|
||||
const { L, byId } = useDerived();
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const basket = useBasket();
|
||||
const st = s.staff.find((x) => x.id === staffId);
|
||||
const lines = basket.issue(staffId);
|
||||
const [pick, setPick] = useState<Record<string, number>>({});
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const [scan, setScan] = useState(false);
|
||||
|
||||
const shelf = (k: string) => Math.max(0, onhand(s, L, k));
|
||||
const sizeOf = (it: Item | undefined, si: number) => String(it?.sizes[si] ?? si);
|
||||
|
||||
/* Their kit: garments the server would issue them without an override (group and cut) that are a
|
||||
* top, a pair of pants, or something they have held before. Anything else comes in by scanning. */
|
||||
const kit = useMemo(() => {
|
||||
if (!st) return [];
|
||||
const last: Record<string, { si: number; at: string }> = {};
|
||||
for (const i of s.issues) {
|
||||
if (i.staffId !== st.id) continue;
|
||||
const at = i.date + (i.createdAt || "");
|
||||
if (!last[i.itemId] || at > last[i.itemId].at) last[i.itemId] = { si: i.si, at };
|
||||
}
|
||||
return s.catalog
|
||||
.filter((it) => !it.archived && garmentForGroup(it, st.group) && garmentForStyle(it, st.uniformStyle) && (rank(it) < 2 || !!last[it.id]))
|
||||
.sort((a, b) => rank(a) - rank(b) || label(a).localeCompare(label(b)))
|
||||
.map((it) => {
|
||||
const want = isTopItem(it) ? st.top : isPantItem(it) ? st.pants : "";
|
||||
let si = want ? sizeIndexOf(it, want) : -1;
|
||||
if (si < 0 && last[it.id] && last[it.id].si < it.sizes.length) si = last[it.id].si;
|
||||
return { it, si: si < 0 ? null : si };
|
||||
});
|
||||
}, [s, st]);
|
||||
|
||||
const flags = useMemo(() => (st ? issueLineFlags(s, st, lines) : []), [s, st, lines]);
|
||||
|
||||
const add = (itemId: string, si: number) => {
|
||||
const it = byId[itemId];
|
||||
if (!it) return;
|
||||
const k = key(itemId, si);
|
||||
const cur = basket.issue(staffId);
|
||||
const inBasket = cur.find((l) => l.key === k)?.qty || 0;
|
||||
if (shelf(k) - inBasket <= 0) { toast(`None of ${label(it)} ${sizeOf(it, si)} on the shelf`); return; }
|
||||
const next: IssueLine[] = inBasket
|
||||
? cur.map((l) => (l.key === k ? { ...l, qty: l.qty + 1 } : l))
|
||||
: [...cur, { key: k, itemId, si, qty: 1, reason: null }];
|
||||
basket.setIssue(staffId, next);
|
||||
};
|
||||
const setQty = (k: string, v: number) => {
|
||||
const cur = basket.issue(staffId);
|
||||
const oh = shelf(k);
|
||||
let n = v;
|
||||
if (n > oh) { toast(`Only ${oh} on the shelf`); n = oh; }
|
||||
basket.setIssue(staffId, n <= 0 ? cur.filter((l) => l.key !== k) : cur.map((l) => (l.key === k ? { ...l, qty: n } : l)));
|
||||
};
|
||||
const setReason = (k: string, r: string | null) => {
|
||||
basket.setIssue(staffId, basket.issue(staffId).map((l) => (l.key === k ? { ...l, reason: r } : l)));
|
||||
};
|
||||
const onCode = (raw: string) => {
|
||||
const code = raw.trim();
|
||||
const hit = bcParse(s, code);
|
||||
if (!hit) {
|
||||
scanReject();
|
||||
const bound = s.barcodes[code];
|
||||
let it: Item | undefined = bound ? byId[splitKey(bound).itemId] : undefined;
|
||||
if (!it && /^93\d{7}$/.test(code)) it = s.catalog.find((x) => x.sort === Math.floor((+code - 930000000) / 100));
|
||||
toast(it?.archived ? `${label(it)} is discontinued` : `${code} isn’t a garment ThreadCount knows`);
|
||||
return;
|
||||
}
|
||||
add(hit.itemId, hit.si);
|
||||
};
|
||||
// The bar and the scanner are drawn by the shell as direct children of the app column (the native
|
||||
// scanner hides everything else), so the scanner reads the latest handler through a ref.
|
||||
const onCodeRef = useRef(onCode);
|
||||
useEffect(() => { onCodeRef.current = onCode; });
|
||||
|
||||
const n = lines.reduce((t, l) => t + l.qty, 0);
|
||||
const needReason = flags.some((f, i) => !!f && !lines[i]?.reason);
|
||||
const short = lines.find((l) => l.qty > shelf(l.key));
|
||||
const shortText = short ? `Only ${shelf(short.key)} of ${label(byId[short.itemId])} ${sizeOf(byId[short.itemId], short.si)} on the shelf` : "";
|
||||
const inactive = !!st?.inactive;
|
||||
const off = inactive || n === 0 || needReason || !!short;
|
||||
const small = n === 0 ? "nothing yet" : needReason ? "reason each flag" : plural(n, "item");
|
||||
const offReason = inactive ? "Reactivate them in the portal first" : n === 0 ? "Nothing to issue yet" : needReason ? "Pick a reason for each flagged line" : shortText;
|
||||
|
||||
useEffect(() => {
|
||||
setBar(
|
||||
<>
|
||||
<MBar label="Review and sign" small={small} disabled={off} offReason={off ? offReason : undefined}
|
||||
onClick={() => router.push(`/m/person/${encodeURIComponent(staffId)}/sign`)} />
|
||||
{scan && <MScan title="Scan a garment" onHit={(raw) => { setScan(false); onCodeRef.current(raw); }} onClose={() => setScan(false)} />}
|
||||
</>,
|
||||
);
|
||||
}, [setBar, small, off, offReason, scan, router, staffId]);
|
||||
useEffect(() => () => setBar(null), [setBar]);
|
||||
|
||||
if (!st) return null;
|
||||
const cc = ccOf(s, st);
|
||||
const total = lines.reduce((t, l) => t + l.qty * (byId[l.itemId]?.cost || 0), 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MSection label="Their size" />
|
||||
{kit.map(({ it, si: def }) => {
|
||||
const si = pick[it.id] ?? def;
|
||||
const k = si === null ? "" : key(it.id, si);
|
||||
const oh = si === null ? null : shelf(k);
|
||||
const inBasket = si === null ? 0 : lines.find((l) => l.key === k)?.qty || 0;
|
||||
const size = si === null ? null : sizeOf(it, si);
|
||||
return (
|
||||
<MKitCard key={it.id} title={label(it)} size={size} onShelf={oh} sizeOpen={open === it.id}
|
||||
onSize={() => setOpen((o) => (o === it.id ? null : it.id))}
|
||||
onAdd={() => { if (si !== null) add(it.id, si); }}
|
||||
addDisabled={si === null || (oh ?? 0) - inBasket <= 0}
|
||||
addLabel={size === null ? `Pick a size for ${label(it)}` : `Add ${label(it)} ${size}`}>
|
||||
<MChipRow label={`Sizes of ${label(it)}`} value={si === null ? null : String(si)}
|
||||
options={it.sizes.map((sz, i) => ({ value: String(i), label: String(sz), n: shelf(key(it.id, i)) }))}
|
||||
disabled={(v) => shelf(key(it.id, +v)) <= 0}
|
||||
onPick={(v) => { setPick((p) => ({ ...p, [it.id]: +v })); setOpen(null); }} />
|
||||
</MKitCard>
|
||||
);
|
||||
})}
|
||||
<MButton icon="scan" label="Scan a garment" onClick={() => setScan(true)} />
|
||||
|
||||
{lines.length > 0 && (
|
||||
<>
|
||||
<MSection label="Issuing now" right={plural(n, "item")} />
|
||||
{lines.map((l, i) => {
|
||||
const it = byId[l.itemId];
|
||||
const f = flags[i];
|
||||
return (
|
||||
<MLine key={l.key} title={label(it)} size={sizeOf(it, l.si)} flag={f?.label}
|
||||
right={<MStepper label={label(it)} n={l.qty} min={0} max={999} onChange={(v) => setQty(l.key, v)} />}>
|
||||
{f && <MReasonChips reasons={OVERRIDE_REASONS} value={l.reason} label={`Reason for ${label(it)}`} onPick={(r) => setReason(l.key, r)} />}
|
||||
</MLine>
|
||||
);
|
||||
})}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, padding: "10px 0", fontWeight: 800 }}>
|
||||
<span>{cc ? `To cost centre ${cc}` : "To cost centre"}</span>
|
||||
<b style={{ fontFamily: MONO }}>{money(total)}</b>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* The meta line under a person's name on the counter phone: group · number · dept · CC. */
|
||||
import { ccOf, type Snapshot, type StaffRec } from "@/lib/compute";
|
||||
|
||||
export function personMeta(s: Snapshot, st: StaffRec): string {
|
||||
const cc = ccOf(s, st);
|
||||
return [st.group, st.num, st.dept, cc && `CC ${cc}`].map((x) => (x || "").trim()).filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
export const plural = (n: number, one: string, many = one + "s") => `${n} ${n === 1 ? one : many}`;
|
||||
Reference in New Issue
Block a user