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:
ThreadCount
2026-09-16 07:57:54 +10:00
commit 0910bc32c1
457 changed files with 55952 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
"use client";
/* The ink "Now counting" panel (mockup .panel): garment · size, Counted / Expected / Gap, the
* Hands-free switch, and the inline "Type a count" field when it is open. The same pieces are the
* figure and control of the live camera overlay when hands-free falls back to it. */
import { useEffect, useRef, useState } from "react";
import { GROUND, INK, MFigRow, MKick, MSwitchRow } from "@/components/m";
import { signed } from "@/components/m/count/lines";
const OFF_GREY_TEXT = "#b5b1af"; // mockup .panel .kick
export function CountFigures({ name, counted, expected }: { name: string; counted: number; expected: number }) {
const d = counted - expected;
return (
<>
<div style={{ ["--tcx-kick" as string]: OFF_GREY_TEXT } as React.CSSProperties}><MKick>Now counting</MKick></div>
<div aria-live="polite" style={{ fontSize: 19, fontWeight: 800, marginTop: 2, lineHeight: 1.2 }}>{name}</div>
<MFigRow figs={[
{ label: "Counted", n: counted },
{ label: "Expected", n: expected },
{ label: "Gap", n: signed(d), tone: d === 0 ? "ok" : "accent" },
]} />
</>
);
}
export function HandsFree({ on, onToggle, disabled }: { on: boolean; onToggle: () => void; disabled?: boolean }) {
return <MSwitchRow dark tone="accent" title="Hands-free" on={on} onToggle={onToggle} disabled={disabled} />;
}
/** Inline count field: Enter or Set applies it. Keyed by the caller on the line, so it never shows
* the previous line's figure under the new line's name. */
export function TypeCount({ name, value, onSet }: { name: string; value: number; onSet: (n: number) => void }) {
const [v, setV] = useState(String(value));
const ref = useRef<HTMLInputElement | null>(null);
useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []);
const set = () => { const n = parseInt(v, 10); if (Number.isFinite(n) && n >= 0) onSet(n); };
return (
<div style={{ display: "flex", gap: 8, marginTop: 10 }}>
<input ref={ref} type="text" inputMode="numeric" pattern="[0-9]*" aria-label={`Counted for ${name}`} value={v}
onChange={(e) => setV(e.target.value.replace(/[^0-9]/g, "").slice(0, 5))}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); set(); } }}
style={{ flex: 1, minWidth: 0, height: 52, border: "2px solid #57534f", background: "#fff", color: INK, fontFamily: "inherit", fontSize: 16, padding: "0 14px", borderRadius: 0, boxSizing: "border-box" }} />
<button type="button" onClick={set}
style={{ minHeight: 52, padding: "0 18px", border: "2px solid " + GROUND, background: "transparent", color: GROUND, fontFamily: "inherit", fontSize: 14, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer" }}>Set</button>
</div>
);
}
/** The panel as it sits at the top of the counting body (bleeds over MBody pad). */
export function CountPanel({ children }: { children: React.ReactNode }) {
return <section aria-label="Now counting" style={{ background: INK, color: GROUND, margin: "-16px -16px 12px", padding: "14px 16px 16px" }}>{children}</section>;
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
/* The lines a shelf count lists, shared by the counting screen and Check the gaps.
*
* The two screens have to list exactly the same set: anything countable on one and missing on the
* other is counted on the phone and then dropped at commit, with the tally cleared behind it.
*
* A placed size is countable even with no stock history (a shelf being set up). The unplaced bucket
* needs a test or it would be the whole catalogue: stock history, or a bound barcode (somebody
* scanned that label onto that size, so the garment physically exists). /m/count uses the same
* test through `looseVariants` for its "Not on a shelf yet" row. */
import { useMemo } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { UNPLACED, bcBound, label, locMap, locSubtree, locTrail, locUnder, onhand, touched } from "@/lib/compute";
import type { Item, Ledger, Snapshot, Variant } from "@/lib/compute";
export type CountLine = {
key: string; itemId: string; si: number; size: string; item: Item;
expected: number; code: string; where: string;
};
/** Unplaced sizes worth counting: stock history or a bound barcode. */
export function looseVariants(s: Snapshot, L: Ledger, variants: Variant[]): Variant[] {
return variants.filter((v) => !s.placed[v.key] && (touched(s, L, v.key) || !!bcBound(s, v.item, v.si)));
}
export function useCountLines(locationId: string) {
const { s } = useSnap();
const { L, variants } = useDerived();
const locs = useMemo(() => locMap(s), [s]);
const lines = useMemo<CountLine[]>(() => {
const picked = locationId === UNPLACED
? looseVariants(s, L, variants)
: (() => { const sub = locSubtree(s, locationId); return variants.filter((v) => sub.has(s.placed[v.key] || "")); })();
return picked.map((v) => ({
key: v.key, itemId: v.itemId, si: v.si, size: v.size, item: v.item,
expected: onhand(s, L, v.key), code: bcBound(s, v.item, v.si), where: locUnder(locs, s.placed[v.key], locationId),
}));
}, [s, L, variants, locationId, locs]);
const locName = locationId === UNPLACED ? "Not on a shelf" : locTrail(locs, locationId, 0) || locs[locationId]?.name || "Location";
return { lines, locName, locs };
}
/** "Scrub top (W)": the garment as a row title; the size follows it. */
export const lineTitle = (l: Pick<CountLine, "item">) => label(l.item);
/** Gap with its sign, using a true minus: "+3", "12", "0". */
export const signed = (d: number) => (d === 0 ? "0" : d > 0 ? `+${d}` : `${-d}`);
/** The reasons a count gap can carry, stored as the stocktake line's reason. A short count gets the
* first list, a count over what was expected the second. */
export const SHORT_REASONS = ["At laundry", "Condemned", "Missing", "Other"] as const;
export const OVER_REASONS = ["Found extra", "Other"] as const;
+235
View File
@@ -0,0 +1,235 @@
"use client";
/* Person Hand back (HANDBACK workstream). One basket for everything a person brings to the window:
* each line is one garment with its condition, and any line can swap for another size. The whole
* list is recorded in one handback.commit, so a return and its swap never land half way. */
import { useCallback, useEffect, useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { bcParse, key, label, onhand, plOf, staffName } from "@/lib/compute";
import { DIVIDER, INK, MBar, MButton, MChipRow, MEmpty, MONO, MRow, MSection, MUTED, useScanFlash, useToast } from "@/components/m";
import { useHeld, type Held } from "@/components/MPerson";
import { useBasket, type BackLine } from "@/components/MBasket";
import MScan from "@/components/MScan";
import type { DoneProps } from "@/components/SignFlow";
export type PersonTabProps = {
staffId: string;
/** The tab calls it in an effect; the shell renders it docked below MBody (History sets null). */
setBar: (bar: React.ReactNode) => void;
/** The shell swaps the whole screen for <DoneScreen {...d} />. */
onDone: (d: DoneProps) => void;
/** The shell shows MError under the rule. */
onError: (msg: string) => void;
};
type Cond = BackLine["cond"];
const CONDS: { value: Cond; label: string }[] = [
{ value: "Good", label: "Good" }, { value: "Damaged", label: "Damaged" }, { value: "Condemn", label: "Condemn" }, { value: "Lost", label: "Lost" },
];
/** The phone's four words for the record's four return conditions. */
const RECORDED: Record<Cond, "Returned - Good" | "Returned - Damaged" | "Written Off" | "Lost"> = {
Good: "Returned - Good", Damaged: "Returned - Damaged", Condemn: "Written Off", Lost: "Lost",
};
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
let seq = 0;
const newUid = () => `${Date.now().toString(36)}${(seq++).toString(36)}${Math.random().toString(36).slice(2, 6)}`;
// The mockup's .chip and .x; m.tsx keeps its chip style private.
const chipBtn: React.CSSProperties = {
minHeight: 44, minWidth: 48, padding: "0 12px", border: "2px solid " + INK, background: "transparent", color: INK,
fontFamily: "inherit", fontSize: 14, fontWeight: 700, borderRadius: 0, display: "inline-flex", alignItems: "center",
justifyContent: "center", cursor: "pointer", flex: "none",
};
const xBtn: React.CSSProperties = {
border: 0, background: "none", width: 44, height: 44, fontSize: 22, color: MUTED, padding: 0, cursor: "pointer", flex: "none", fontFamily: "inherit",
};
export default function HandBackTab({ staffId, setBar, onDone, onError }: PersonTabProps) {
const { s, mutate } = useSnap();
const { L, byId } = useDerived();
const toast = useToast();
const flash = useScanFlash();
const basket = useBasket();
const { setBack, clear } = basket;
const st = s.staff.find((x) => x.id === staffId);
const held = useHeld(s, staffId);
const lines = basket.back(staffId);
const [scanning, setScanning] = useState(false);
const [swapOpen, setSwapOpen] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const heldBy = useMemo(() => {
const m: Record<string, Held> = {};
for (const h of held) m[h.key] = h;
return m;
}, [held]);
const usedOf = (k: string) => lines.filter((l) => key(l.itemId, l.si) === k).length;
// The record moved under the basket (a garment taken back elsewhere): drop lines past what they hold.
useEffect(() => {
if (!lines.length) return;
const seen: Record<string, number> = {};
const kept = lines.filter((l) => {
const k = key(l.itemId, l.si);
seen[k] = (seen[k] || 0) + 1;
return seen[k] <= (heldBy[k]?.qty || 0);
});
if (kept.length !== lines.length) setBack(staffId, kept);
}, [heldBy, lines, setBack, staffId]);
/* The server takes new garments back before pre-loved ones, so the first N lines of a variant are
* new (a swap comes off the shelf, a Good one goes back on it) and the rest pre-loved (the pool). */
const preloved = useMemo(() => {
const out: Record<string, boolean> = {};
const seen: Record<string, number> = {};
for (const l of lines) {
const k = key(l.itemId, l.si);
const newUnits = (heldBy[k]?.issues || []).filter((i) => !i.preloved).reduce((t, i) => t + i.qty, 0);
out[l.uid] = (seen[k] || 0) >= newUnits;
seen[k] = (seen[k] || 0) + 1;
}
return out;
}, [lines, heldBy]);
/** What a swap to size `si` can still draw on for this line, after the other lines' swaps and
* whatever the other lines put back in good condition. */
const swapStock = (line: BackLine, si: number) => {
const k = key(line.itemId, si);
const pl = preloved[line.uid];
let n = pl ? plOf(s, k) : onhand(s, L, k);
for (const o of lines) {
if (o.uid === line.uid || preloved[o.uid] !== pl || o.itemId !== line.itemId) continue;
if (o.swapSi === si) n--;
if (o.cond === "Good" && o.si === si) n++;
}
return Math.max(0, n);
};
const add = (h: Held) => {
setBack(staffId, [...lines, { uid: newUid(), itemId: h.itemId, si: h.si, cond: "Good", swapSi: null }]);
};
const tapHeld = (h: Held) => {
if (usedOf(h.key) >= h.qty) { toast(`They only hold ${h.qty}`); return; }
add(h);
};
const onScan = (raw: string) => {
setScanning(false);
const code = raw.trim();
const v = bcParse(s, code);
const h = v ? heldBy[key(v.itemId, v.si)] : undefined;
if (!h) { toast(`${code} isnt something ${st?.first || "they"} holds`); return; }
if (held.every((x) => usedOf(x.key) >= x.qty)) { toast("Everything they hold is already on the list"); return; }
if (usedOf(h.key) >= h.qty) { toast(`They only hold ${h.qty}`); return; }
flash("Garment", `${label(byId[h.itemId])} ${h.size}`, () => add(h));
};
const update = (uid: string, patch: Partial<BackLine>) =>
setBack(staffId, lines.map((l) => (l.uid === uid ? { ...l, ...patch } : l)));
const remove = (uid: string) => {
setBack(staffId, lines.filter((l) => l.uid !== uid));
setSwapOpen(null);
};
const commit = useCallback(async () => {
if (!lines.length || saving) return;
setSaving(true);
onError("");
const sent = lines;
const r = await mutate<{ back: number; swaps: number }>("handback.commit", {
staffId,
lines: sent.map((l) => ({ itemId: l.itemId, si: l.si, cond: RECORDED[l.cond], swapSi: l.swapSi })),
});
setSaving(false);
if (!r.ok) { onError(r.error); return; }
const shelfKeys: string[] = [];
for (const l of sent) {
if (l.cond === "Good" && !preloved[l.uid]) shelfKeys.push(key(l.itemId, l.si));
if (l.swapSi !== null) shelfKeys.push(key(l.itemId, l.swapSi));
}
const n = r.result?.back ?? sent.length;
const swaps = r.result?.swaps ?? sent.filter((l) => l.swapSi !== null).length;
clear("back", staffId);
onDone({
head: `${plural(n, "item")} handed back`,
sub: staffName(st) + (swaps ? ` · ${plural(swaps, "size swap")} issued` : ""),
shelfKeys: [...new Set(shelfKeys)],
next: "scan",
});
}, [lines, saving, onError, mutate, staffId, preloved, clear, onDone, st]);
useEffect(() => {
setBar(saving
? <MBar label="Recording…" disabled small={plural(lines.length, "item")} />
: lines.length
? <MBar label="Record hand back" small={plural(lines.length, "item")} onClick={commit} />
: <MBar label="Record hand back" small="nothing yet" disabled offReason="Scan or tap what they hand back" />);
}, [setBar, saving, lines.length, commit]);
return (
<>
<MButton tone="ink" icon="scan" label="Scan what they hand back" onClick={() => setScanning(true)} />
{lines.length > 0 && (
<>
<MSection label="Handing back" right={plural(lines.length, "item")} />
{lines.map((l) => {
const it = byId[l.itemId];
const name = label(it);
const size = String(it?.sizes[l.si] ?? l.si);
const swapSize = l.swapSi === null ? null : String(it?.sizes[l.swapSi] ?? l.swapSi);
const open = swapOpen === l.uid;
const others = (it?.sizes || []).map((sz, i) => ({ sz: String(sz), i })).filter((x) => x.i !== l.si);
const counts: Record<string, number> = {};
for (const x of others) counts[String(x.i)] = swapStock(l, x.i);
const cur = l.swapSi === null ? null : String(l.swapSi);
return (
// The mockup's .cl: MLine's layout, with the swap note in ink rather than the flag's accent.
<div key={l.uid} style={{ borderBottom: "1px solid " + DIVIDER, padding: "10px 0" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<b style={{ fontWeight: 700 }}>{name}</b> <span style={{ fontFamily: MONO }}>{size}</span>
{swapSize && <div style={{ fontSize: 13, fontWeight: 800, color: INK, marginTop: 2 }}>Swap for {swapSize}</div>}
</div>
<button type="button" style={{ ...chipBtn, opacity: l.cond === "Good" ? 1 : 0.4, cursor: l.cond === "Good" ? "pointer" : "not-allowed" }}
disabled={l.cond !== "Good"} aria-expanded={open} aria-controls={`swap-${l.uid}`}
aria-label={swapSize ? `Size ${swapSize}: change the swap for ${name} ${size}` : `Swap size for ${name} ${size}`}
onClick={() => setSwapOpen(open ? null : l.uid)}>
{swapSize ? `Size ${swapSize}` : "Swap size"}
</button>
<button type="button" style={xBtn} aria-label={`Remove ${name} ${size}`} onClick={() => remove(l.uid)}>×</button>
</div>
<MChipRow grid={4} label={`Condition of ${name} ${size}`} options={CONDS} value={l.cond}
onPick={(c) => {
// Only a garment going back on the shelf swaps; anything else is replaced by an issue.
if (c === "Good") { update(l.uid, { cond: c }); return; }
update(l.uid, { cond: c, swapSi: null });
if (open) setSwapOpen(null);
}} />
{open && l.cond === "Good" && (
<div id={`swap-${l.uid}`}>
<MChipRow label={`Swap ${name} ${size} for`} value={cur}
options={others.map((x) => ({ value: String(x.i), label: x.sz, n: counts[String(x.i)] }))}
disabled={(v) => counts[v] <= 0 && cur !== v}
onPick={(v) => { update(l.uid, { swapSi: cur === v ? null : Number(v) }); setSwapOpen(null); }} />
</div>
)}
</div>
);
})}
</>
)}
<MSection label="Holding now" />
{held.length === 0
? <MEmpty title="Nothing out" />
: held.map((h) => (
<MRow key={h.key} mark="ink" title={`${label(byId[h.itemId])} ${h.size}`} sub="Tap to hand one back" right={`×${h.qty}`}
onClick={() => tapHeld(h)} />
))}
{scanning && <MScan title="Scan what they hand back" onHit={onScan} onClose={() => setScanning(false)} />}
</>
);
}
+94
View File
@@ -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}
</>
);
}
+165
View File
@@ -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} isnt 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>
</>
)}
</>
);
}
+9
View File
@@ -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}`;
+65
View File
@@ -0,0 +1,65 @@
"use client";
/* Count a shelf: every location holding garments, plus the unplaced bucket, each shelf with a
shelf-label print chip. Mounted by app/m/(app)/count/page.tsx (a Stock detail, no tab bar). */
import { useMemo } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { UNPLACED, daysBetween } from "@/lib/compute";
import { printShelfLabel } from "@/lib/nativeprint";
import { isNative } from "@/lib/nativescan";
import { INK, MBody, MEmpty, MRow, MRule, MSection, MTop, MTopCount, useToast } from "@/components/m";
import { countRows } from "@/components/m/stock/stockdata";
function lastCounted(last: string | undefined, today: string): string {
if (!last) return "Never counted";
const n = daysBetween(last, today);
if (n <= 0) return "Counted today";
if (n === 1) return "Counted yesterday";
return `Counted ${n} days ago`;
}
export default function CountList() {
const { s } = useSnap();
const { L, variants } = useDerived();
const toast = useToast();
const rows = useMemo(() => countRows(s, L, variants), [s, L, variants]);
const print = async (id: string, name: string) => {
const r = await printShelfLabel({ locationId: id, copies: 1 });
if (!r.ok) toast(r.error);
else if (isNative()) toast(`${name} label sent to the shelf printer`);
};
const chip: React.CSSProperties = {
minHeight: 44, minWidth: 64, padding: "0 12px", border: "2px solid " + INK, background: "transparent", color: INK,
fontFamily: "inherit", fontSize: 13, fontWeight: 800, letterSpacing: "0.05em", textTransform: "uppercase", cursor: "pointer", flex: "none",
};
return (
<>
<MTop title="Count a shelf" back right={<MTopCount>{rows.length}</MTopCount>} />
<MRule />
<MBody pad>
{rows.length === 0 ? (
<MEmpty title="Nothing to count yet" sub="Place garments on a shelf in the portal." />
) : (
<>
<MSection label="Shelves" right="lines · units" />
{rows.map((r) => (
<div key={r.id} style={{ display: "flex", alignItems: "center", gap: 10, borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ flex: 1, minWidth: 0, marginBottom: -1 }}>
<MRow href={`/m/count/${r.id}`} mark={r.id === UNPLACED ? "mute" : "ink"}
title={<span style={{ paddingLeft: r.depth * 14 }}>{r.name}</span>}
sub={<span style={{ paddingLeft: r.depth * 14 }}>{lastCounted(r.last, s.today)}</span>}
right={`${r.lines} · ${r.units}`} />
</div>
{r.id !== UNPLACED && (
<button type="button" style={chip} onClick={() => print(r.id, r.name)} aria-label={`Print a shelf label for ${r.name}`}>Label</button>
)}
</div>
))}
</>
)}
</MBody>
</>
);
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
/* The print sheet over a stock line: copies, why, and the bar that sends it. A native shell hands the
label page to Android printing; a browser opens the printable label page. */
import { useEffect, useState } from "react";
import { printLabels, printState, type PrintState } from "@/lib/nativeprint";
import { MBar, MChipRow, MKick, MRow, MSheet, MStepper, useToast } from "@/components/m";
const REASONS = ["Torn", "Faded", "New shelf"] as const;
type Reason = (typeof REASONS)[number];
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
export default function PrintSheet({ open, onClose, code, title }: { open: boolean; onClose: () => void; code: string; title: string }) {
const toast = useToast();
const [copies, setCopies] = useState(1);
const [reason, setReason] = useState<Reason>("Torn");
const [state, setState] = useState<PrintState | null>(null);
const [sending, setSending] = useState(false);
useEffect(() => {
if (!open) return;
setCopies(1); setReason("Torn");
let live = true;
printState().then((p) => { if (live) setState(p); });
return () => { live = false; };
}, [open]);
const go = async () => {
if (sending) return;
setSending(true);
const r = await printLabels({ code, copies, reason });
setSending(false);
if (!r.ok) { toast(r.error); return; }
onClose();
toast(state?.state === "browser" ? `${plural(copies, "label")} opened to print` : `${plural(copies, "label")} sent to the shelf printer`);
};
return (
<MSheet open={open} onClose={onClose} labelId="tc-print-title"
bar={<MBar label={sending ? "Sending…" : `Print ${plural(copies, "label")}`} onClick={go} disabled={sending} />}>
<MKick>Shelf printer{state ? ` · ${state.label}` : ""}</MKick>
<h2 id="tc-print-title" style={{ fontSize: 20, fontWeight: 900, margin: "2px 0 0", lineHeight: 1.15 }}>{title}</h2>
<MRow title="Copies" sub="Barcode, garment, size, shelf"
right={<MStepper n={copies} onChange={setCopies} min={1} max={20} label="copies" />} />
<MChipRow label="Why it is being reprinted" value={reason} onPick={setReason}
options={REASONS.map((r) => ({ value: r, label: r }))} />
</MSheet>
);
}
+72
View File
@@ -0,0 +1,72 @@
/* Figures the Stock tab and a stock line's page both read, so the two never disagree. Pure. */
import { UNPLACED, bcBound, daysBetween, isOpen, key, locSubtree, locTree, onhand, touched, type Item, type Ledger, type Snapshot, type Variant } from "@/lib/compute";
export type CountRow = { id: string; name: string; depth: number; lines: number; units: number; last: string | undefined };
/** The shelves Count a shelf lists: every location holding placed garments, then the unplaced bucket
* (the same test the counting and variance screens use for it). */
export function countRows(s: Snapshot, L: Ledger, variants: Variant[]): CountRow[] {
const lastAt: Record<string, string> = {};
for (const t of s.stocktakes) if (t.mode !== "preloved" && t.locationId && (!lastAt[t.locationId] || t.date > lastAt[t.locationId])) lastAt[t.locationId] = t.date;
const out: CountRow[] = locTree(s).map(({ loc, depth }) => {
const sub = locSubtree(s, loc.id);
const mine = variants.filter((v) => sub.has(s.placed[v.key] || ""));
return { id: loc.id, name: loc.name, depth, lines: mine.length, units: mine.reduce((t, v) => t + onhand(s, L, v.key), 0), last: lastAt[loc.id] };
}).filter((r) => r.lines > 0);
const loose = variants.filter((v) => !s.placed[v.key] && (touched(s, L, v.key) || !!bcBound(s, v.item, v.si)));
if (loose.length) out.push({ id: UNPLACED, name: "Not on a shelf yet", depth: 0, lines: loose.length, units: loose.reduce((t, v) => t + onhand(s, L, v.key), 0), last: undefined });
return out;
}
/** Outstanding units per variant key on open orders placed with a supplier (drafts excluded) —
* the same set onOrderText() reads for one size. */
export function placedOnOrder(s: Snapshot, byId: Record<string, Item>): Record<string, number> {
const out: Record<string, number> = {};
for (const o of s.orders) {
if (!isOpen(o) || o.status === "Draft") continue;
const got: Record<string, number> = {};
for (const rc of o.receipts) for (const l of rc.lines) got[l.itemId + "|" + l.size] = (got[l.itemId + "|" + l.size] || 0) + l.qty;
for (const l of o.lines) {
const g = l.itemId + "|" + l.size;
const done = Math.min(l.qty, got[g] || 0);
got[g] = (got[g] || 0) - done;
if (l.qty <= done) continue;
const it = byId[l.itemId];
if (!it) continue;
const si = it.sizes.findIndex((z) => String(z) === l.size);
if (si < 0) continue;
const k = key(it.id, si);
out[k] = (out[k] || 0) + l.qty - done;
}
}
return out;
}
/** "Today", "Yesterday", "n days ago" or "Never", from the newest shelf count holding this line. */
export function lastCountedText(s: Snapshot, itemId: string, si: number): string {
let last = "";
for (const t of s.stocktakes) {
if (t.mode === "preloved" || (last && t.date <= last)) continue;
if (t.lines.some((l) => l.itemId === itemId && l.si === si)) last = t.date;
}
if (!last) return "Never";
const n = daysBetween(last, s.today);
return n <= 0 ? "Today" : n === 1 ? "Yesterday" : `${n} days ago`;
}
/** Units of one size out in staff hands now: issued, not returned, not handed in. */
export function heldByStaff(s: Snapshot, itemId: string, si: number): number {
let n = 0;
for (const i of s.issues) if (i.itemId === itemId && i.si === si && !i.returned && !i.handedIn) n += i.qty;
return n;
}
type Ranked = { oh: number; par: number; name: string };
/** Worst first: on hand as a share of par, par 0 last. */
export function byShortfall(a: Ranked, b: Ranked): number {
const ra = a.par > 0 ? a.oh / a.par : Infinity, rb = b.par > 0 ? b.oh / b.par : Infinity;
if (ra !== rb) return ra < rb ? -1 : 1;
return a.oh - b.oh || a.name.localeCompare(b.name);
}
/** Rows drawn per page on the long lists; the rest arrive with "Show more", never silently cut. */
export const PAGE = 200;
+35
View File
@@ -0,0 +1,35 @@
"use client";
/* The green line at the top of Today after something finished elsewhere: a shelf count committed
* (?flash=counted&loc=<name>&gaps=<n>, or the older ?counted=1) or a facility just created
* (?flash=created). Dismissing it drops the query so a reload does not bring it back. */
import { useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { OK } from "@/components/m";
export function bannerText(q: URLSearchParams): string {
const flash = q.get("flash");
if (flash === "created") return "Facility created";
if (flash === "counted" || q.get("counted")) {
const loc = (q.get("loc") || "").slice(0, 80).trim();
const gaps = Math.max(0, parseInt(q.get("gaps") || "0", 10) || 0);
return `${loc || "Shelf"} counted${gaps > 0 ? ` · ${gaps} ${gaps === 1 ? "gap" : "gaps"}` : ""}`;
}
return "";
}
export default function TodayBanner() {
const sp = useSearchParams();
const router = useRouter();
const [gone, setGone] = useState(false);
const text = bannerText(new URLSearchParams(sp.toString()));
if (!text || gone) return null;
return (
<div role="status" style={{ background: OK, color: "#ffffff", padding: "12px 14px", margin: "-16px -16px 12px", fontWeight: 700, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8 }}>
<span>{text}</span>
<button type="button" aria-label="Dismiss" onClick={() => { setGone(true); router.replace("/m"); }}
style={{ background: "none", border: 0, color: "#ffffff", fontSize: 22, width: 44, height: 44, flex: "none", cursor: "pointer", padding: 0, fontFamily: "inherit" }}>
×
</button>
</div>
);
}
+161
View File
@@ -0,0 +1,161 @@
"use client";
/* Today's figures, worked out once from the snapshot: the To do rows (each only when there is
* something to do), Your day, and Recent. Membership comes from the shared selectors
* (lib/portalcounts.ts through lib/today.ts and lib/workcount.ts), so Today, the Work badge and the
* Work segments cannot disagree about what is waiting. */
import { useMemo } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { formatInZone, onhand, reorderAt, staffName, variantName } from "@/lib/compute";
import { PICKUP_LATE_DAYS, atReorderVariants } from "@/lib/portalcounts";
import { collectRows, countRows, dayMonth, plural, receiveRows } from "@/lib/today";
import { PICK_STATUSES } from "@/lib/workcount";
import { useRequests } from "@/components/requests/RequestList";
export type TodoRow = { key: string; n: string; accent: boolean; title: string; sub: string; href: string };
export type RecentRow = { key: string; title: string; sub: string; right: string; href?: string };
export type TodayView = {
kicker: string;
todo: TodoRow[];
day: { issued: number; back: number; counted: number };
recent: RecentRow[];
};
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** "Wed 16 Sep" from a facility date, without the locale's comma or four-letter "Sept". */
export function shortDate(iso: string): string {
const y = +iso.slice(0, 4), m = +iso.slice(5, 7) - 1, d = +iso.slice(8, 10);
if (!y || m < 0 || !d) return "";
return `${WEEKDAYS[new Date(Date.UTC(y, m, d)).getUTCDay()]} ${d} ${MONTHS[m]}`;
}
/** Names, at most four, then "+n". */
function names(list: string[]): string {
const uniq = [...new Set(list.filter(Boolean))];
return uniq.slice(0, 4).join(", ") + (uniq.length > 4 ? ` +${uniq.length - 4}` : "");
}
const first = (full: string) => full.trim().split(/\s+/)[0] || "";
export function useToday(): TodayView {
const { s } = useSnap();
const { L, byId, staffById } = useDerived();
const { data } = useRequests();
return useMemo(() => {
const today = s.today;
const todo: TodoRow[] = [];
// 1. Requests to pick.
if (data) {
const picks = data.requests.filter((r) => PICK_STATUSES.has(r.status));
if (picks.length) {
todo.push({
key: "picks", n: String(picks.length), accent: false, title: "Requests to pick",
sub: names(picks.map((r) => staffById[r.staffId]?.first || first(r.staffName))), href: "/m/work?seg=picks",
});
}
}
// 2. The most overdue shelf count (countsDue sorts never-counted first, then oldest).
const due = countRows(s, byId)[0];
if (due) {
const never = due.age === "—";
const days = never ? 0 : parseInt(due.age, 10) || 0;
const what = due.garments.join(", ");
todo.push({
key: "count", n: never ? "New" : `${days}d`, accent: true, title: `Count ${due.trail || due.loc.name}`,
sub: [what, never ? "never counted" : `not counted for ${plural(days, "day")}`].filter(Boolean).join(" · "),
href: `/m/count/${encodeURIComponent(due.loc.id)}`,
});
}
// 3. Deliveries to receive.
const inn = receiveRows(s, staffById);
if (inn.length) {
const o = inn[0].o;
todo.push({
key: "in", n: String(inn.length), accent: false, title: inn.length === 1 ? "Delivery to receive" : "Deliveries to receive",
sub: [o.code, o.supplier].filter(Boolean).join(" · "), href: "/m/work?seg=in",
});
}
// 4. Pickups waiting past the late threshold.
const late = collectRows(s, byId, staffById, { includeRound: true }).filter((r) => r.late);
if (late.length) {
todo.push({
key: "pickups", n: String(late.length), accent: true,
title: `${late.length === 1 ? "Pickup" : "Pickups"} waiting ${PICKUP_LATE_DAYS}+ days`,
sub: names(late.map((r) => r.name)), href: "/m/work?seg=pickups",
});
}
// 5. Lines at or below par, the worst one named (lowest on hand against par).
const low = atReorderVariants(s, L);
if (low.length) {
let worst = low[0], worstRatio = Infinity, worstOh = 0;
for (const v of low) {
const oh = onhand(s, L, v.key), par = reorderAt(s, v.key);
const ratio = par > 0 ? oh / par : oh;
if (ratio < worstRatio) { worst = v; worstRatio = ratio; worstOh = oh; }
}
todo.push({
key: "below", n: String(low.length), accent: false, title: "Lines below par",
sub: `Worst: ${variantName(worst.item, worst.size)}, ${worstOh <= 0 ? "none on the shelf" : `${worstOh} on the shelf`}`,
href: "/m/stock?seg=below",
});
}
// Your day.
let issued = 0, back = 0;
for (const i of s.issues) {
if (i.date === today) issued += i.qty;
if (i.returned?.date === today || (!i.returned && i.handedIn === today)) back += i.qty;
}
const countsToday = s.stocktakes.filter((t) => t.date === today && t.mode !== "preloved");
// Recent: one row per person per kind per day, plus today's counts and deliveries. Newest first.
type R = RecentRow & { sort: string };
const groups: Record<string, { staffId: string; kind: "Issued" | "Handed back"; at: string; qty: number; last: string }> = {};
for (const i of s.issues.slice(-200)) {
const add = (kind: "Issued" | "Handed back", at: string, stamp: string) => {
const g = (groups[`${i.staffId}|${kind}|${at}`] ||= { staffId: i.staffId, kind, at, qty: 0, last: "" });
g.qty += i.qty;
if (stamp > g.last) g.last = stamp;
};
add("Issued", i.date, i.createdAt);
if (i.returned) add("Handed back", i.returned.date, "");
else if (i.handedIn) add("Handed back", i.handedIn, "");
}
const time = (iso: string) => formatInZone(iso, s.tz, { hour: "numeric", minute: "2-digit", hourCycle: "h23" }).replace(/^0(\d)/, "$1");
const rows: R[] = Object.entries(groups).map(([k, g]) => ({
key: k, title: staffName(staffById[g.staffId], "Staff"), sub: `${g.kind} ${plural(g.qty, "item")}`,
right: g.at === today ? (g.last ? time(g.last) : "Today") : dayMonth(g.at, s.tz),
href: `/m/person/${g.staffId}`,
// A record with a time today sorts by it; a date with no time sorts to the end of that day.
sort: g.last && g.at === today ? g.last : `${g.at}T23:59:59`,
}));
const locName = (id: string | null) => (id ? s.locations.find((l) => l.id === id)?.name || "Shelf" : "Whole room");
for (const t of countsToday) {
rows.push({
key: `st|${t.id}`, title: locName(t.locationId),
sub: t.variances > 0 ? `Counted, ${plural(t.variances, "gap")}` : "Counted, all lines match",
right: "Today", sort: `${today}T23:59:59`,
});
}
for (const o of s.orders) {
for (const r of o.receipts) {
if (r.date !== today) continue;
const n = r.lines.reduce((a, l) => a + l.qty, 0);
rows.push({ key: `rc|${r.id}`, title: `Delivery ${o.code}`, sub: `Received ${plural(n, "item")}`, right: "Today", sort: `${today}T23:59:59` });
}
}
const recent = rows
.sort((a, b) => (a.sort < b.sort ? 1 : a.sort > b.sort ? -1 : 0))
.slice(0, 4)
.map(({ key, title, sub, right, href }) => ({ key, title, sub, right, href }));
const kicker = [shortDate(today), s.settings.facility, s.settings.location].filter(Boolean).join(" · ");
return { kicker, todo, day: { issued, back, counted: countsToday.length }, recent };
}, [s, L, byId, staffById, data]);
}
+72
View File
@@ -0,0 +1,72 @@
/* Shared shaping for the counter phone's Work tab and the screens it opens (pick a request, receive a
* delivery, sign a ward round). No rules of its own: membership comes from lib/today.ts and
* lib/portalcounts.ts, so the Work badge, the segments and Today agree. */
import {
ccOf, daysBetween, isPlacedOpen, label, locTrail, sizeIndexOf,
type Item, type LocationRec, type OrderRec, type Snapshot, type StaffRec,
} from "@/lib/compute";
import { weekdayDayMonth, type RoundSheetRow } from "@/lib/today";
/** "Scrub top, navy M": the garment and its size as the Work rows print them. */
export const garmentSize = (it: Item | undefined, fallbackName: string, size: string | number) =>
`${it ? label(it) : fallbackName} ${size}`;
/** "Theatres · 2291 · Theatres · CC 4200", blank parts dropped. */
export function personMeta(s: Snapshot, st: StaffRec | undefined, ward?: string): string {
if (!st) return ward || "";
const cc = ccOf(s, st);
return [st.group, st.num, st.dept, cc ? `CC ${cc}` : ""].filter((x) => x && String(x).trim()).join(" · ");
}
export const locMap = (s: Snapshot): Record<string, LocationRec> => Object.fromEntries(s.locations.map((l) => [l.id, l]));
/** Where a variant lives on the shelves, or "". */
export const shelfOf = (s: Snapshot, locs: Record<string, LocationRec>, itemId: string, si: number) =>
locTrail(locs, s.placed[`${itemId}:${si}`], 0);
/** Orders a delivery can be received against: placed with the supplier and not closed. */
export const RECEIVABLE = ["Ordered", "Shipped", "Back Order"];
export type OutLine = { id: string; itemId: string; size: string; si: number; ordered: number; outstanding: number };
/** Each line still owed on an order after earlier part deliveries. */
export function outstandingLines(o: OrderRec, byId: Record<string, Item>): OutLine[] {
return o.lines.map((l) => {
const already = o.receipts.reduce((t, r) => t + r.lines.filter((x) => x.itemId === l.itemId && x.size === l.size).reduce((a, x) => a + x.qty, 0), 0);
return { id: l.id, itemId: l.itemId, size: l.size, si: sizeIndexOf(byId[l.itemId], l.size), ordered: l.qty, outstanding: Math.max(0, l.qty - already) };
}).filter((l) => l.outstanding > 0);
}
export const outstandingTotal = (o: OrderRec, byId: Record<string, Item>) =>
outstandingLines(o, byId).reduce((t, l) => t + l.outstanding, 0);
/** Every open placed order that still has something to come. */
export function openReceivable(s: Snapshot, byId: Record<string, Item>): OrderRec[] {
return s.orders.filter((o) => isPlacedOpen(o) && RECEIVABLE.includes(o.status) && outstandingLines(o, byId).length > 0);
}
/** "Due today", "3d overdue", "Due Thu 18 Sep", or the order's status when nobody gave a date. */
export function orderWhen(s: Snapshot, o: OrderRec): string {
if (!o.expected) return o.status;
if (o.expected < s.today) return `${daysBetween(o.expected, s.today)}d overdue`;
if (o.expected === s.today) return "Due today";
return `Due ${weekdayDayMonth(o.expected, s.tz)}`;
}
export type RoundLine = { key: string; itemId: string; size: string; name: string; qty: number };
/** A ward's waiting bags summed by garment and size, for the round's Handing over list. */
export function roundLines(rows: RoundSheetRow[], byId: Record<string, Item>): RoundLine[] {
const m = new Map<string, RoundLine>();
for (const r of rows) for (const l of r.p.lines) {
const k = `${l.itemId}|${l.size}`;
const cur = m.get(k);
if (cur) cur.qty += l.qty;
else m.set(k, { key: k, itemId: l.itemId, size: l.size, name: garmentSize(byId[l.itemId], "Garment", l.size), qty: l.qty });
}
return [...m.values()];
}
/** A route segment, decoded once whether or not the router already decoded it. */
export function segment(v: string | string[] | undefined): string {
const raw = Array.isArray(v) ? v[0] : v || "";
try { return decodeURIComponent(raw); } catch { return raw; }
}