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 a113353 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { Icon, Panel, SizeStrip, Tag, type SizeCell } from "@/components/portal";
|
||||
import { ErrorLine } from "@/components/ui";
|
||||
import { ALL_GROUPS, bcParse, garmentForStyle, groupBucket, inBucket, key, label, onhand, plOf, reorderAt, setHalf, type IssueRec, type Item, type StaffRec } from "@/lib/compute";
|
||||
import { dayMonth, pronoun, sizeOf, type HoldGroup } from "./lib";
|
||||
import styles from "./counter.module.css";
|
||||
|
||||
const HOLD_SHOWN = 8;
|
||||
|
||||
export default function AddGarments({ st, isAdmin, onAdd, onBind, onCamera, onReturn, onRepeat, repeatDate, groups, owed, inputRef, listOpenRef }: {
|
||||
st: StaffRec;
|
||||
isAdmin: boolean;
|
||||
onAdd: (itemId: string, si: number) => void;
|
||||
onBind: (code: string) => void;
|
||||
onCamera: () => void;
|
||||
onReturn: (issue: IssueRec) => void;
|
||||
onRepeat: () => void;
|
||||
repeatDate: string | null;
|
||||
groups: HoldGroup[];
|
||||
owed: number;
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
listOpenRef: React.RefObject<boolean>;
|
||||
}) {
|
||||
const { s } = useSnap();
|
||||
const { L, byId } = useDerived();
|
||||
const [q, setQ] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const popId = useId();
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const pr = pronoun(st);
|
||||
|
||||
// For this person's group and cut: the same test the old quick-add list used.
|
||||
const bucket = groupBucket(st.group) || ALL_GROUPS;
|
||||
const forThem = (it: Item) => inBucket(it, bucket) && garmentForStyle(it, st.uniformStyle);
|
||||
|
||||
const qq = q.trim().toLowerCase();
|
||||
const found = useMemo(() => {
|
||||
if (qq.length < 2) return { rel: [] as Item[], other: [] as Item[] };
|
||||
const all = s.catalog
|
||||
.filter((it) => !it.archived && (it.item.toLowerCase().includes(qq) || it.sku.toLowerCase().includes(qq) || label(it).toLowerCase().includes(qq)))
|
||||
.sort((a, b) => a.sort - b.sort);
|
||||
const rel = all.filter(forThem).slice(0, 8);
|
||||
const other = all.filter((it) => !rel.includes(it) && !forThem(it)).slice(0, 8 - rel.length);
|
||||
return { rel, other };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [s.catalog, qq, bucket, st.uniformStyle]);
|
||||
const listShown = open && qq.length >= 2;
|
||||
useEffect(() => { listOpenRef.current = listShown; }, [listShown, listOpenRef]);
|
||||
|
||||
// Usual chips: set garments at the recorded size, then anything outside a set they have had before,
|
||||
// at the size of their latest issue of it. Most issued to them first.
|
||||
const usual = useMemo(() => {
|
||||
const issued: Record<string, number> = {};
|
||||
const latest: Record<string, IssueRec> = {};
|
||||
for (const i of s.issues) {
|
||||
if (i.staffId !== st.id) continue;
|
||||
issued[i.itemId] = (issued[i.itemId] || 0) + i.qty;
|
||||
const l = latest[i.itemId];
|
||||
if (!l || i.date > l.date || (i.date === l.date && i.createdAt > l.createdAt)) latest[i.itemId] = i;
|
||||
}
|
||||
const rank = (a: { it: Item }, b: { it: Item }) => (issued[b.it.id] || 0) - (issued[a.it.id] || 0) || a.it.sort - b.it.sort;
|
||||
const sets: { it: Item; si: number }[] = [], others: { it: Item; si: number }[] = [];
|
||||
for (const it of s.catalog) {
|
||||
if (it.archived) continue;
|
||||
const half = setHalf(it);
|
||||
if (half) {
|
||||
const want = half === "top" ? st.top : st.pants;
|
||||
if (!want || !forThem(it)) continue;
|
||||
const si = it.sizes.map(String).indexOf(String(want));
|
||||
if (si >= 0) sets.push({ it, si });
|
||||
} else if (latest[it.id] && latest[it.id].si < it.sizes.length) {
|
||||
others.push({ it, si: latest[it.id].si });
|
||||
}
|
||||
}
|
||||
return [...sets.sort(rank), ...others.sort(rank)].slice(0, 6);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [s.catalog, s.issues, st.id, st.top, st.pants, bucket, st.uniformStyle]);
|
||||
|
||||
const cells = (it: Item): SizeCell[] => it.sizes.map((sz, si) => {
|
||||
const k = key(it.id, si), oh = onhand(s, L, k), pl = plOf(s, k), ro = reorderAt(s, k);
|
||||
return { si, size: String(sz), count: oh, state: oh <= 0 ? "out" : ro > 0 && oh <= ro ? "low" : "ok", title: `${oh} on shelf · ${pl} pre-loved` };
|
||||
});
|
||||
|
||||
function add(itemId: string, si: number) {
|
||||
onAdd(itemId, si);
|
||||
setErr("");
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
function onKey(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Escape" && listShown) { e.preventDefault(); setOpen(false); return; }
|
||||
if (e.key === "ArrowDown" && listShown) { e.preventDefault(); popRef.current?.querySelector<HTMLButtonElement>("button")?.focus(); return; }
|
||||
if (e.key !== "Enter" || e.ctrlKey || e.metaKey) return;
|
||||
const raw = q.trim();
|
||||
if (!raw) return;
|
||||
e.preventDefault();
|
||||
const hit = bcParse(s, raw);
|
||||
if (hit) { setQ(""); add(hit.itemId, hit.si); return; }
|
||||
// A name with no size can't be added; a code nobody knows gets bound (admins) or reported.
|
||||
if (/\d/.test(raw) && !/\s/.test(raw)) {
|
||||
setQ("");
|
||||
if (isAdmin) onBind(raw);
|
||||
else setErr(`No garment has ${raw}.`);
|
||||
}
|
||||
}
|
||||
|
||||
const option = (it: Item) => (
|
||||
<div key={it.id} className={styles.opt}>
|
||||
<div className={styles.optHead}><span>{label(it)}</span>{it.sku && <span className="tc-mono" style={{ fontWeight: 400, fontSize: 12, color: "#57534f" }}>{it.sku}</span>}</div>
|
||||
<SizeStrip itemLabel={label(it)} cells={cells(it)} action="Add" onCell={(si) => add(it.id, si)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const shownGroups = showAll ? groups : groups.slice(0, HOLD_SHOWN);
|
||||
const holdingQty = groups.reduce((t, g) => t + g.qty, 0);
|
||||
const recordHref = `/app/staff/${st.id}?tab=details${isAdmin ? "&edit=1" : ""}`;
|
||||
|
||||
return (
|
||||
<Panel title="Add garments" aside={repeatDate ? <button type="button" className={`btn btn-ghost ${styles.rowGhost}`} onClick={onRepeat}>Repeat last · {dayMonth(repeatDate)}</button> : undefined}>
|
||||
<div className={styles.scanWrap} ref={wrapRef}
|
||||
onBlur={(e) => { if (!wrapRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false); }}>
|
||||
<div className={styles.scanBox}>
|
||||
<Icon name="scan" size={16} />
|
||||
<input ref={inputRef} className={styles.scanInput} role="combobox" aria-expanded={listShown} aria-controls={popId} aria-autocomplete="list" aria-haspopup="dialog"
|
||||
aria-label="Scan a garment or type a garment name" placeholder="Scan a garment, or type a name" autoFocus autoComplete="off"
|
||||
value={q} onChange={(e) => { setQ(e.target.value); setOpen(true); setErr(""); }} onFocus={() => setOpen(true)} onKeyDown={onKey} />
|
||||
<button type="button" className={`btn btn-ghost ${styles.camBtn}`} onClick={onCamera} aria-label="Scan with the camera"><Icon name="camera" size={16} /></button>
|
||||
</div>
|
||||
{listShown && (
|
||||
<div id={popId} ref={popRef} className={styles.pop} role="dialog" aria-label="Matching garments"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); setOpen(false); inputRef.current?.focus(); } }}>
|
||||
{found.rel.length + found.other.length === 0 && <div className={styles.popEmpty}>No garment matches “{q.trim()}”.</div>}
|
||||
{found.rel.map(option)}
|
||||
{found.other.length > 0 && <div className={`tc-lbl ${styles.optDivider}`}>Other garments</div>}
|
||||
{found.other.map(option)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{err && <div className={styles.scanErr}><ErrorLine msg={err} /></div>}
|
||||
|
||||
<div className={`tc-lbl ${styles.usualLbl}`}>{pr.poss} usual · one tap adds it</div>
|
||||
<div className={styles.chips}>
|
||||
{usual.map(({ it, si }) => (
|
||||
<button type="button" key={it.id} className={`btn btn-secondary ${styles.chip}`} onClick={() => add(it.id, si)} aria-label={`Add ${label(it)} size ${sizeOf(it, si)}`}>
|
||||
{label(it)} <Tag tone="ink" mono>{sizeOf(it, si)}</Tag>
|
||||
</button>
|
||||
))}
|
||||
{!st.top && !st.pants && (
|
||||
<span className={styles.meta}>No usual sizes recorded · <Link href={recordHref} className="btn btn-ghost" style={{ minHeight: 0, padding: 0, fontSize: 12 }}>Record them</Link></span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`tc-lbl ${styles.holdLbl}`}>Holding now · {holdingQty} {holdingQty === 1 ? "garment" : "garments"}</div>
|
||||
{groups.length === 0 ? (
|
||||
<div className={styles.empty} style={{ paddingTop: 0 }}>Nothing out.</div>
|
||||
) : (
|
||||
<div className={styles.tableWrap}>
|
||||
<table className="tc-table">
|
||||
<tbody>
|
||||
{shownGroups.map((g) => {
|
||||
const it = byId[g.itemId];
|
||||
return (
|
||||
<tr key={g.itemId + ":" + g.si}>
|
||||
<td>{label(it)} · <span className="tc-mono">{sizeOf(it, g.si)}</span></td>
|
||||
<td className="num">×{g.qty}</td>
|
||||
<td className={styles.meta}>last {dayMonth(g.last)}</td>
|
||||
<td style={{ textAlign: "right" }}>
|
||||
<button type="button" className={`btn btn-ghost ${styles.rowGhost}`} aria-label={`Return ${label(it)} size ${sizeOf(it, g.si)}`} onClick={() => onReturn(g.latest)}>Return</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{(groups.length > HOLD_SHOWN || owed > 0) && (
|
||||
<div className={styles.holdFoot}>
|
||||
{groups.length > HOLD_SHOWN && <button type="button" className={`btn btn-ghost ${styles.rowGhost}`} aria-expanded={showAll} onClick={() => setShowAll(!showAll)}>{showAll ? "Show fewer" : "Show all"}</button>}
|
||||
{owed > 0 && <span className={styles.meta}>+{owed} on order or waiting</span>}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
/* The Counter (/app/counter?staff=&mode=): person first, then Issue, Return, Hand in or Swap a size. */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { ErrorLine, LiveRegion, PageHead } from "@/components/ui";
|
||||
import { Kbd, Seg } from "@/components/portal";
|
||||
import { AdjustDialog, BindDialog, HandInDialog, ReturnDialog } from "@/components/dialogs";
|
||||
import Camera from "@/components/Camera";
|
||||
import { bcParse, capCheck, label, type IssueRec } from "@/lib/compute";
|
||||
import { MODES, MODE_LABELS, holdingGroups, openIssuesOf, overlayOpen, sizeOf, type Mode } from "./lib";
|
||||
import { useCounterCart } from "./useCounterCart";
|
||||
import PersonPicker from "./PersonPicker";
|
||||
import PersonPanel from "./PersonPanel";
|
||||
import AddGarments from "./AddGarments";
|
||||
import PickupCart from "./PickupCart";
|
||||
import { HandInPanel, HoldingPanel, ReturnedToday, SwapPanel, SwappedToday } from "./Modes";
|
||||
import styles from "./counter.module.css";
|
||||
|
||||
export default function Counter() {
|
||||
const { s, isAdmin } = useSnap();
|
||||
const { byId, staffById } = useDerived();
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
|
||||
const staffParam = sp.get("staff") || "";
|
||||
const sel = staffParam ? staffById[staffParam] || s.staff.find((x) => x.num === staffParam) : undefined;
|
||||
const modeParam = sp.get("mode") as Mode | null;
|
||||
const mode: Mode = modeParam && MODES.includes(modeParam) ? modeParam : "issue";
|
||||
|
||||
const setUrl = useCallback((staff: string | null, m: Mode) => {
|
||||
const q = new URLSearchParams();
|
||||
if (staff) q.set("staff", staff);
|
||||
if (staff && m !== "issue") q.set("mode", m);
|
||||
const qs = q.toString();
|
||||
router.replace(`/app/counter${qs ? `?${qs}` : ""}`, { scroll: false });
|
||||
}, [router]);
|
||||
|
||||
const cart = useCounterCart(sel);
|
||||
const [msg, setMsg] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [cam, setCam] = useState(false);
|
||||
const [camMsg, setCamMsg] = useState("");
|
||||
const [bind, setBind] = useState("");
|
||||
const [ret, setRet] = useState<IssueRec | null>(null);
|
||||
const [handin, setHandin] = useState(false);
|
||||
const [adjust, setAdjust] = useState(false);
|
||||
const [mac, setMac] = useState(false);
|
||||
useEffect(() => { setMac(/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)); }, []);
|
||||
|
||||
// Messages belong to one person.
|
||||
const [msgFor, setMsgFor] = useState(sel?.id || "");
|
||||
if (msgFor !== (sel?.id || "")) { setMsgFor(sel?.id || ""); setMsg(""); setErr(""); }
|
||||
|
||||
const scanRef = useRef<HTMLInputElement>(null);
|
||||
const listOpenRef = useRef(false);
|
||||
|
||||
const held = useMemo(() => (sel ? capCheck(s, sel, []) : null), [s, sel]);
|
||||
const open = useMemo(() => (sel ? openIssuesOf(s, sel.id) : []), [s, sel]);
|
||||
const groups = useMemo(() => holdingGroups(open), [open]);
|
||||
const owed = held ? held.owed.tops + held.owed.pants + held.owed.other : 0;
|
||||
|
||||
// Repeat last issue: every unreturned line from their most recent issue date.
|
||||
const lastSet = useMemo(() => {
|
||||
if (!open.length) return [];
|
||||
const latest = open.reduce((d, i) => (i.date > d ? i.date : d), "");
|
||||
return open.filter((i) => i.date === latest && byId[i.itemId] && !byId[i.itemId].archived);
|
||||
}, [open, byId]);
|
||||
|
||||
const pick = (id: string) => { setMsg(""); setErr(""); setUrl(id, mode); };
|
||||
const changePerson = () => { setMsg(""); setErr(""); setCam(false); setUrl(null, "issue"); };
|
||||
const setMode = (m: Mode) => { if (sel) setUrl(staffParam, m); };
|
||||
const addGarment = (itemId: string, si: number) => { cart.add(itemId, si); setMsg(""); };
|
||||
|
||||
async function doIssue() {
|
||||
setErr("");
|
||||
const r = await cart.record();
|
||||
if (!r) return;
|
||||
if (r.ok) setMsg(r.msg); else setErr(r.error);
|
||||
scanRef.current?.focus();
|
||||
}
|
||||
|
||||
function camHit(raw: string) {
|
||||
const code = raw.trim();
|
||||
if (!sel) {
|
||||
const st = s.staff.find((x) => x.num === code);
|
||||
setCam(false);
|
||||
if (st) pick(st.id); else setErr(`No one on the register has ${code}.`);
|
||||
return;
|
||||
}
|
||||
const p = bcParse(s, code);
|
||||
if (!p) {
|
||||
setCam(false);
|
||||
if (isAdmin) setBind(code); else setErr(`No garment has ${code}.`);
|
||||
return;
|
||||
}
|
||||
if (mode !== "issue") setMode("issue");
|
||||
addGarment(p.itemId, p.si);
|
||||
setCamMsg(`Added ${label(byId[p.itemId])} · ${sizeOf(byId[p.itemId], p.si)}`);
|
||||
}
|
||||
|
||||
// Window events and keys read the latest render through a ref.
|
||||
const cartLines = cart.cart.length;
|
||||
const latest = useRef({ sel, mode, cam, cartLines, setMode, addGarment, changePerson, doIssue });
|
||||
useEffect(() => { latest.current = { sel, mode, cam, cartLines, setMode, addGarment, changePerson, doIssue }; });
|
||||
useEffect(() => {
|
||||
const onScan = () => { setCamMsg(""); setCam(true); };
|
||||
const onGarment = (e: Event) => {
|
||||
const d = (e as CustomEvent<{ itemId: string; si: number }>).detail;
|
||||
const cur = latest.current;
|
||||
if (!d || !cur.sel) return;
|
||||
if (cur.mode !== "issue") cur.setMode("issue");
|
||||
cur.addGarment(d.itemId, d.si);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const cur = latest.current;
|
||||
if (e.defaultPrevented || !cur.sel || cur.cam || overlayOpen()) return;
|
||||
if (e.key === "Escape") {
|
||||
// A pickup being built is never thrown away by a stray Escape: change person from the panel.
|
||||
if (listOpenRef.current || cur.cartLines > 0) return;
|
||||
e.preventDefault();
|
||||
cur.changePerson();
|
||||
} else if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && cur.mode === "issue") {
|
||||
e.preventDefault();
|
||||
void cur.doIssue();
|
||||
}
|
||||
};
|
||||
window.addEventListener("tc-scan", onScan);
|
||||
window.addEventListener("tc-scan-garment", onGarment);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.removeEventListener("tc-scan", onScan);
|
||||
window.removeEventListener("tc-scan-garment", onGarment);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<PageHead title="Counter">
|
||||
<button type="button" className="btn btn-onink" onClick={() => setAdjust(true)}>Hand-in without a person</button>
|
||||
</PageHead>
|
||||
|
||||
{!sel ? (
|
||||
<div className={styles.stack}>
|
||||
{staffParam && <ErrorLine msg="No one on the register matches that link or badge." />}
|
||||
<PersonPicker onPick={pick} />
|
||||
<ErrorLine msg={err} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.stack}>
|
||||
{held && <PersonPanel st={sel} held={held} onChange={changePerson} />}
|
||||
|
||||
<div className={styles.modeRow}>
|
||||
<div className={styles.modeSeg}>
|
||||
<Seg<Mode> label="Counter mode" opts={MODES} value={mode} labels={MODE_LABELS} onChange={setMode} />
|
||||
</div>
|
||||
{!cart.cart.length && <div className={styles.kbdHint}><Kbd>Esc</Kbd> change person</div>}
|
||||
</div>
|
||||
|
||||
{mode === "issue" && (
|
||||
<div className={`${styles.grid}${cart.cart.length ? " " + styles.cartFirst : ""}`}>
|
||||
<div>
|
||||
<AddGarments st={sel} isAdmin={isAdmin} onAdd={addGarment} onBind={setBind} onCamera={() => { setCamMsg(""); setCam(true); }}
|
||||
onReturn={setRet} onRepeat={() => { cart.replace(lastSet.map((i) => ({ itemId: i.itemId, si: i.si, qty: i.qty, src: "stock" as const }))); setMsg(""); }}
|
||||
repeatDate={lastSet.length ? lastSet[0].date : null} groups={groups} owed={owed} inputRef={scanRef} listOpenRef={listOpenRef} />
|
||||
</div>
|
||||
<div className={styles.pickup}>
|
||||
<PickupCart st={sel} cart={cart} mac={mac} onRecord={doIssue} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{mode === "return" && (
|
||||
<div className={styles.grid}>
|
||||
<HoldingPanel st={sel} onReturn={setRet} onError={setErr} />
|
||||
<ReturnedToday st={sel} />
|
||||
</div>
|
||||
)}
|
||||
{mode === "handin" && (
|
||||
<div className={styles.grid}>
|
||||
<HoldingPanel st={sel} onError={setErr} />
|
||||
<HandInPanel st={sel} onRecord={() => setHandin(true)} />
|
||||
</div>
|
||||
)}
|
||||
{mode === "swap" && (
|
||||
<div className={styles.grid}>
|
||||
<SwapPanel st={sel} onDone={(m) => { setErr(""); setMsg(m); }} onError={(e) => { setMsg(""); setErr(e); }} />
|
||||
<SwappedToday st={sel} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LiveRegion msg={msg} className={styles.msg} />
|
||||
<ErrorLine msg={err} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cam && <Camera onHit={camHit} message={camMsg} onClose={() => { setCam(false); scanRef.current?.focus(); }} />}
|
||||
{bind && <BindDialog code={bind} onClose={() => setBind("")} onBound={(itemId, si) => { if (mode !== "issue") setMode("issue"); addGarment(itemId, si); }} />}
|
||||
{ret && <ReturnDialog issue={ret} onClose={() => setRet(null)} />}
|
||||
{handin && sel && <HandInDialog staff={sel} onClose={() => setHandin(false)} onDone={(m) => { setErr(""); setMsg(m); }} />}
|
||||
{adjust && <AdjustDialog init={{ itemId: "", si: 0, preloved: true }} onClose={() => setAdjust(false)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
/* Return, Hand in and Swap a size: the counter's other three modes. */
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { Panel, QtyStepper, Tag } from "@/components/portal";
|
||||
import { printHandInReceipt } from "@/components/dialogs";
|
||||
import { key, label, onhand, plOf, type IssueRec, type StaffRec } from "@/lib/compute";
|
||||
import { dayMonth, openIssuesOf, owedLinesOf, plural, sizeOf } from "./lib";
|
||||
import styles from "./counter.module.css";
|
||||
|
||||
/** Every open issue line, ungrouped, with the signed toggle; Return when `onReturn` is given. */
|
||||
export function HoldingPanel({ st, onReturn, onError }: { st: StaffRec; onReturn?: (i: IssueRec) => void; onError: (e: string) => void }) {
|
||||
const { s, mutate } = useSnap();
|
||||
const { byId } = useDerived();
|
||||
const open = useMemo(() => openIssuesOf(s, st.id), [s, st.id]);
|
||||
const owed = useMemo(() => owedLinesOf(s, st.id), [s, st.id]);
|
||||
const qty = open.reduce((t, i) => t + i.qty, 0);
|
||||
async function sign(i: IssueRec) {
|
||||
const r = await mutate("issue.receipt", { id: i.id, receipt: !i.receipt });
|
||||
if (!r.ok) onError(r.error);
|
||||
}
|
||||
return (
|
||||
<Panel title="Holding" aside={plural(qty, "garment", "garments")}>
|
||||
{open.length === 0 ? <div className={styles.empty}>Nothing out.</div> : (
|
||||
<div className={styles.tableWrap}>
|
||||
<table className="tc-table">
|
||||
<thead><tr><th>Garment</th><th>Size</th><th className="num">Qty</th><th>Issued</th><th>Signed</th>{onReturn && <th><span className="sr-only">Action</span></th>}</tr></thead>
|
||||
<tbody>
|
||||
{open.map((i) => {
|
||||
const it = byId[i.itemId];
|
||||
return (
|
||||
<tr key={i.id}>
|
||||
<td>{label(it)}{i.preloved ? <span className={styles.meta}> · pre-loved</span> : null}</td>
|
||||
<td className="tc-mono">{sizeOf(it, i.si)}</td>
|
||||
<td className="num">{i.qty}</td>
|
||||
<td className="tc-mono" style={{ fontSize: 12 }}>{dayMonth(i.date)}</td>
|
||||
<td>
|
||||
<button type="button" className={`btn ${i.receipt ? "btn-secondary" : "btn-ghost"} ${styles.rowGhost}`} aria-pressed={i.receipt}
|
||||
aria-label={`${label(it)} size ${sizeOf(it, i.si)} signed for`} onClick={() => sign(i)}>{i.receipt ? "Signed" : "Mark signed"}</button>
|
||||
</td>
|
||||
{onReturn && (
|
||||
<td style={{ textAlign: "right" }}>
|
||||
<button type="button" className={`btn btn-ghost ${styles.rowGhost}`} aria-label={`Return ${label(it)} size ${sizeOf(it, i.si)}`} onClick={() => onReturn(i)}>Return</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{owed.length > 0 && (
|
||||
<>
|
||||
<div className={`tc-lbl ${styles.subLbl}`}>On order or waiting</div>
|
||||
<div className={styles.tableWrap}>
|
||||
<table className="tc-table">
|
||||
<tbody>
|
||||
{owed.map((o) => (
|
||||
<tr key={o.key}>
|
||||
<td>{label(byId[o.itemId])}</td>
|
||||
<td className="tc-mono">{o.size || "–"}</td>
|
||||
<td className="num">{o.qty}</td>
|
||||
<td className={styles.meta}>{o.where}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReturnedToday({ st }: { st: StaffRec }) {
|
||||
const { s } = useSnap();
|
||||
const { byId } = useDerived();
|
||||
const rows = s.issues.filter((i) => i.staffId === st.id && i.returned?.date === s.today);
|
||||
return (
|
||||
<Panel title="Returned today" aside={rows.length ? plural(rows.reduce((t, i) => t + i.qty, 0), "garment", "garments") : undefined}>
|
||||
{rows.length === 0 ? <div className={styles.empty}>Nothing returned today.</div> : rows.map((i) => {
|
||||
const it = byId[i.itemId];
|
||||
const cond = i.returned!.cond;
|
||||
return (
|
||||
<div key={i.id} className={styles.listRow}>
|
||||
<div className={styles.listMain}>{label(it)} · <span className="tc-mono">{sizeOf(it, i.si)}</span> <span className="tc-mono">×{i.qty}</span></div>
|
||||
<Tag tone={cond === "Returned - Good" ? "outline" : "low"}>{cond.replace("Returned - ", "")}</Tag>
|
||||
{i.returned!.photoId && <a className={`btn btn-ghost ${styles.rowGhost}`} href={`/api/photo/${i.returned!.photoId}`} target="_blank" rel="noopener">Photo</a>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export function HandInPanel({ st, onRecord }: { st: StaffRec; onRecord: () => void }) {
|
||||
const { s } = useSnap();
|
||||
const { byId } = useDerived();
|
||||
const list = s.handins.filter((h) => h.staffId === st.id).sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
|
||||
return (
|
||||
<Panel title="Hand in">
|
||||
<div className={styles.panelPad}>
|
||||
<button type="button" className="btn btn-primary" onClick={onRecord}>Record a hand-in</button>
|
||||
</div>
|
||||
<div className={`tc-lbl ${styles.subLbl}`}>Hand-ins</div>
|
||||
{list.length === 0 ? <div className={styles.empty}>No hand-ins on file.</div> : list.map((h) => {
|
||||
const good = h.lines.filter((l) => l.cond === "Good").reduce((t, l) => t + l.qty, 0);
|
||||
const rag = h.lines.filter((l) => l.cond === "Rag").reduce((t, l) => t + l.qty, 0);
|
||||
return (
|
||||
<div key={h.id} className={styles.listRow}>
|
||||
<span className="tc-mono" style={{ fontSize: 12, width: 52 }}>{dayMonth(h.date)}</span>
|
||||
<div className={styles.listMain}>{good} to pre-loved · {rag} rag</div>
|
||||
{h.credit && <Tag>Credited</Tag>}
|
||||
<button type="button" className={`btn btn-ghost ${styles.rowGhost}`} onClick={() => printHandInReceipt(s, st, h, byId)} aria-label={`Receipt for the hand-in on ${dayMonth(h.date)}`}>Receipt</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function SwapRow({ issue, onDone, onError }: { issue: IssueRec; onDone: (msg: string) => void; onError: (e: string) => void }) {
|
||||
const { s, mutate } = useSnap();
|
||||
const { L, byId } = useDerived();
|
||||
const it = byId[issue.itemId];
|
||||
const [si, setSi] = useState(-1);
|
||||
const [qty, setQty] = useState(1);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const n = Math.min(Math.max(1, qty), issue.qty);
|
||||
if (!it) return null;
|
||||
async function swap() {
|
||||
if (si < 0 || busy) return;
|
||||
setBusy(true);
|
||||
const r = await mutate<{ size: string; qty: number }>("issue.exchange", { id: issue.id, si, qty: n });
|
||||
setBusy(false);
|
||||
if (!r.ok) { onError(r.error); return; }
|
||||
setSi(-1); setQty(1);
|
||||
onDone(`Swapped ${label(it)} ${sizeOf(it, issue.si)} for ${r.result.size} ×${r.result.qty}.`);
|
||||
}
|
||||
return (
|
||||
<div className={styles.listRow}>
|
||||
<div className={styles.listMain}>{label(it)} · <span className="tc-mono">{sizeOf(it, issue.si)}</span> <span className="tc-mono">×{issue.qty}</span></div>
|
||||
<select className={`input ${styles.swapSelect}`} aria-label={`New size for ${label(it)}`} value={si} onChange={(e) => setSi(+e.target.value)}>
|
||||
<option value={-1}>New size</option>
|
||||
{it.sizes.map((sz, j) => {
|
||||
if (j === issue.si) return null;
|
||||
const k = key(it.id, j);
|
||||
return <option key={j} value={j}>{String(sz)} ({issue.preloved ? `${plOf(s, k)} pre-loved` : `${onhand(s, L, k)} on shelf`})</option>;
|
||||
})}
|
||||
</select>
|
||||
{issue.qty > 1 && <QtyStepper size="sm" value={n} min={1} max={issue.qty} label={`${label(it)} to swap`} onChange={setQty} />}
|
||||
<button type="button" className="btn btn-secondary" disabled={si < 0 || busy} onClick={swap}>Swap</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SwapPanel({ st, onDone, onError }: { st: StaffRec; onDone: (msg: string) => void; onError: (e: string) => void }) {
|
||||
const { s } = useSnap();
|
||||
const open = useMemo(() => openIssuesOf(s, st.id), [s, st.id]);
|
||||
return (
|
||||
<Panel title="Holding" aside={plural(open.reduce((t, i) => t + i.qty, 0), "garment", "garments")}>
|
||||
{open.length === 0 ? <div className={styles.empty}>Nothing out.</div> : open.map((i) => <SwapRow key={i.id} issue={i} onDone={onDone} onError={onError} />)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
/** Best effort: today's good returns with a new issue of the same garment, another size, today. */
|
||||
export function SwappedToday({ st }: { st: StaffRec }) {
|
||||
const { s } = useSnap();
|
||||
const { byId } = useDerived();
|
||||
const mine = s.issues.filter((i) => i.staffId === st.id);
|
||||
const rows = mine
|
||||
.filter((i) => i.returned?.date === s.today && i.returned.cond === "Returned - Good")
|
||||
.flatMap((old) => {
|
||||
const nu = mine.find((n) => n.date === s.today && n.itemId === old.itemId && n.si !== old.si && n.qty === old.qty);
|
||||
return nu ? [{ old, nu }] : [];
|
||||
});
|
||||
return (
|
||||
<Panel title="Swapped today">
|
||||
{rows.length === 0 ? <div className={styles.empty}>Nothing swapped today.</div> : rows.map(({ old, nu }) => {
|
||||
const it = byId[old.itemId];
|
||||
return (
|
||||
<div key={old.id} className={styles.listRow}>
|
||||
<div className={styles.listMain}>{label(it)} · <span className="tc-mono">{sizeOf(it, old.si)}</span> → <span className="tc-mono">{sizeOf(it, nu.si)}</span></div>
|
||||
<span className="tc-mono">×{old.qty}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { Meter, Tag } from "@/components/portal";
|
||||
import { printCreditSlip } from "@/components/dialogs";
|
||||
import { allowanceRouteOf, approvalRemaining, ccOf, initialRemaining, initialSets, openApproval, setsForFte, type CapCheck, type StaffRec } from "@/lib/compute";
|
||||
import { dayMonth } from "./lib";
|
||||
import styles from "./counter.module.css";
|
||||
|
||||
const Mono = ({ children }: { children: React.ReactNode }) => <span className="tc-mono">{children}</span>;
|
||||
|
||||
/* Who is at the counter: details, their route and signed form, and the six-set meters. */
|
||||
export default function PersonPanel({ st, held, onChange }: { st: StaffRec; held: CapCheck; onChange: () => void }) {
|
||||
const { s } = useSnap();
|
||||
const cc = ccOf(s, st);
|
||||
const cut = st.uniformStyle === "Women's" ? "Women’s cut" : st.uniformStyle === "Men's" ? "Men’s cut" : "";
|
||||
|
||||
const details: React.ReactNode[] = [];
|
||||
if (st.group) details.push(st.group);
|
||||
if (st.dept) details.push(st.dept);
|
||||
if (cc) details.push(<Mono key="cc">{cc}</Mono>);
|
||||
if (cut) details.push(cut);
|
||||
if (st.top || st.pants) details.push(<span key="usual">usual <Mono>{st.top || "–"}</Mono> / <Mono>{st.pants || "–"}</Mono></span>);
|
||||
|
||||
const route = allowanceRouteOf(s, st);
|
||||
const ap = openApproval(s, st.id);
|
||||
const apRem = approvalRemaining(s, st.id);
|
||||
const signed = ap ? ` · manager signed ${ap.sets} on ${dayMonth(ap.date)}, ${apRem} left` : " · nothing signed";
|
||||
let sentence: React.ReactNode;
|
||||
if (route === "fte") {
|
||||
const n = setsForFte(st.fte);
|
||||
sentence = <>FTE table{st.fte ? <> · <Mono>{st.fte}</Mono>{n !== null ? ` proposes ${n} sets` : ""}</> : " · no FTE recorded"}{signed}</>;
|
||||
} else if (route === "kit") {
|
||||
const sets = initialSets(s, st);
|
||||
const left = initialRemaining(s, st) ?? 0;
|
||||
sentence = <>Starting kit{sets !== null ? ` · ${sets} sets` : ""}{left > 0 ? ` · ${left} garments still to issue` : ""}{ap ? signed : ""}</>;
|
||||
} else {
|
||||
sentence = <>Manager approval{signed}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.person}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className={styles.line1}>
|
||||
<span className={styles.name}>{st.first} {st.last}</span>
|
||||
<span className={`tc-mono ${styles.meta}`}>{st.num}</span>
|
||||
{st.selfEmail && <Tag>Staff app</Tag>}
|
||||
{st.inactive && <Tag tone="accent">Inactive</Tag>}
|
||||
</div>
|
||||
{details.length > 0 && (
|
||||
<div className={styles.details}>{details.map((d, i) => <span key={i}>{i > 0 ? " · " : ""}{d}</span>)}</div>
|
||||
)}
|
||||
<div className={styles.route}>
|
||||
<span>{sentence}</span>
|
||||
<button type="button" className={`btn btn-ghost ${styles.inlineGhost}`} onClick={() => window.open(`/print/order-form?staff=${encodeURIComponent(st.id)}`, "_blank")}>Order form</button>
|
||||
{ap && <button type="button" className={`btn btn-ghost ${styles.inlineGhost}`} onClick={() => printCreditSlip(s, st, ap)}>Credit slip</button>}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.meters}>
|
||||
<Meter label="Tops" value={held.tops} of={held.cap} />
|
||||
<Meter label="Pants" value={held.pants} of={held.cap} />
|
||||
{held.other > 0 && <span className={styles.meta}>+{held.other} outside a set</span>}
|
||||
</div>
|
||||
<div className={styles.personActions}>
|
||||
<Link href={`/app/staff/${st.id}`} className="btn btn-ghost">Open record</Link>
|
||||
<button type="button" className="btn btn-ghost" onClick={onChange}>Change person</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { Meter, Panel } from "@/components/portal";
|
||||
import { heldByStaff, setsCap } from "@/lib/compute";
|
||||
import styles from "./counter.module.css";
|
||||
|
||||
/* No person chosen yet: find one by name or staff number (a badge scan types the number). */
|
||||
export default function PersonPicker({ onPick }: { onPick: (id: string) => void }) {
|
||||
const { s } = useSnap();
|
||||
const [q, setQ] = useState("");
|
||||
// One walk of the issues for the whole register, not one per row.
|
||||
const held = useMemo(() => heldByStaff(s), [s]);
|
||||
const cap = setsCap(s.settings.capSets);
|
||||
const qq = q.trim().toLowerCase();
|
||||
const active = s.staff.filter((st) => !st.inactive);
|
||||
const matches = active
|
||||
.filter((st) => !qq || `${st.first} ${st.last}`.toLowerCase().includes(qq) || `${st.last} ${st.first}`.toLowerCase().includes(qq) || st.num.toLowerCase().includes(qq))
|
||||
.slice(0, 8);
|
||||
|
||||
function onKey(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key !== "Enter") return;
|
||||
const exact = qq ? s.staff.find((st) => st.num.toLowerCase() === qq) : undefined;
|
||||
const first = exact || matches[0];
|
||||
if (first) { e.preventDefault(); onPick(first.id); }
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title="Find a person" aside={s.staff.length ? `${active.length} on the register` : undefined}>
|
||||
<div className={styles.search}>
|
||||
<input className={`input ${styles.searchInput}`} aria-label="Search the register by name or staff number" placeholder="Name or staff number, or scan a badge"
|
||||
value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={onKey} autoFocus />
|
||||
</div>
|
||||
{s.staff.length === 0 ? (
|
||||
<div className={styles.empty}>No staff on the register yet. <Link href="/app/staff">Add staff</Link></div>
|
||||
) : matches.length === 0 ? (
|
||||
<div className={styles.empty}>Nobody matches “{q.trim()}”.</div>
|
||||
) : (
|
||||
<div>
|
||||
{matches.map((st) => {
|
||||
const h = held[st.id] || { tops: 0, pants: 0, other: 0, sets: 0 };
|
||||
return (
|
||||
<button type="button" key={st.id} className={styles.pickRow} onClick={() => onPick(st.id)}>
|
||||
<span className={styles.pickMain}>
|
||||
<span style={{ display: "block", fontWeight: 700 }}>{st.first} {st.last} <span className="tc-mono" style={{ fontWeight: 400, color: "#57534f", fontSize: 12 }}>{st.num}</span></span>
|
||||
<span className={styles.meta} style={{ display: "block" }}>{[st.group, st.dept].filter(Boolean).join(" · ") || "—"}</span>
|
||||
</span>
|
||||
<span className={styles.pickMeters}>
|
||||
<Meter label="Tops" value={h.tops} of={cap} size="sm" />
|
||||
<Meter label="Pants" value={h.pants} of={cap} size="sm" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
import { useId } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { Icon, Kbd, Panel, QtyStepper, Seg, Tag } from "@/components/portal";
|
||||
import { ccOf, genderLabel, groupsLabel, key, label, money, onhand, plOf, setHalf, type StaffRec } from "@/lib/compute";
|
||||
import { plural, pronoun, sizeOf, type Source } from "./lib";
|
||||
import type { CounterCart } from "./useCounterCart";
|
||||
import styles from "./counter.module.css";
|
||||
|
||||
export default function PickupCart({ st, cart: c, mac, onRecord }: { st: StaffRec; cart: CounterCart; mac: boolean; onRecord: () => void }) {
|
||||
const { s } = useSnap();
|
||||
const { L, byId } = useDerived();
|
||||
const reasonsId = useId();
|
||||
const pr = pronoun(st);
|
||||
const cap = c.cap;
|
||||
const costCentre = ccOf(s, st);
|
||||
|
||||
const apClause = c.apN > 0 ? (c.apN === c.apRem ? ` · uses the last ${c.apN} signed` : ` · uses ${c.apN} signed`) : "";
|
||||
|
||||
return (
|
||||
<Panel title="This pickup" aside={c.cart.length ? `${plural(c.cart.length, "line", "lines")} · ${plural(c.garments, "garment", "garments")}` : undefined}>
|
||||
{c.cart.length === 0 && <div className={styles.empty}>Scan, type or tap a usual to add garments.</div>}
|
||||
{c.cart.length > 0 && (
|
||||
<div>
|
||||
{c.cart.map((line, i) => {
|
||||
const it = byId[line.itemId];
|
||||
const size = sizeOf(it, line.si);
|
||||
const k = key(line.itemId, line.si);
|
||||
const oh = onhand(s, L, k), pl = plOf(s, k);
|
||||
const opts: Source[] = pl > 0 || line.src === "preloved" ? ["stock", "preloved", "order"] : ["stock", "order"];
|
||||
const half = setHalf(it);
|
||||
const want = half === "top" ? st.top : half === "pants" ? st.pants : "";
|
||||
const offUsual = !!want && !!it && it.sizes.map(String).includes(String(want)) && size !== String(want);
|
||||
const shortShelf = line.src === "stock" && line.qty > oh;
|
||||
const shortPool = line.src === "preloved" && line.qty > pl;
|
||||
return (
|
||||
<div key={line.itemId + ":" + line.si} className={styles.cartLine}>
|
||||
<div className={styles.lineMain}>
|
||||
<div className={styles.lineTitle}>{label(it)} · <span className="tc-mono">{size}</span></div>
|
||||
<div className={styles.lineSeg}>
|
||||
<Seg<Source> size="sm" label={`Where ${label(it)} ${size} comes from`} opts={opts} value={(line.src ?? "") as Source}
|
||||
onChange={(v) => c.setLine(i, { src: v })}
|
||||
labels={{
|
||||
stock: <>Shelf <b className="tc-mono" style={{ fontWeight: 500 }}>{oh}</b></>,
|
||||
preloved: <>Pre-loved <b className="tc-mono" style={{ fontWeight: 500 }}>{pl}</b></>,
|
||||
order: "Order in",
|
||||
}} />
|
||||
{line.src === null && <span className={styles.hint}>Pick a source</span>}
|
||||
{shortShelf && <Tag tone="low">Not enough on the shelf</Tag>}
|
||||
{shortPool && <Tag tone="low">Not enough pre-loved</Tag>}
|
||||
</div>
|
||||
{offUsual && <div className={styles.hint} style={{ marginTop: 4 }}>Usual size {want}</div>}
|
||||
</div>
|
||||
<QtyStepper value={line.qty} min={0} label={`${label(it)} ${size}`} onChange={(n) => c.setQty(i, n)} />
|
||||
<div className={`tc-mono ${styles.cost}`}>{money(line.src === "preloved" ? 0 : line.qty * (it?.cost || 0))}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{c.cart.length > 0 && cap && (
|
||||
<div className={styles.after}>
|
||||
<div className={styles.afterRow}>
|
||||
{c.needsTick ? <span className="tc-mark" aria-hidden="true" style={{ marginRight: 6 }} /> : <Icon name="check" size={16} />}
|
||||
<span>After this {pr.subj} {pr.holds} <b className="tc-mono">{cap.afterSets} of {cap.cap}</b> sets{apClause}</span>
|
||||
</div>
|
||||
{c.needsTick && (
|
||||
<>
|
||||
<div id={reasonsId}>
|
||||
{c.overCap && (
|
||||
<div className={styles.reason}>
|
||||
{cap.breach === "other" ? `Past ${cap.otherCap} garments outside a set — holds ${cap.afterOther}` : `Past ${cap.cap} sets — holds ${cap.afterTops} tops and ${cap.afterPants} pairs`}
|
||||
</div>
|
||||
)}
|
||||
{c.offItems.map((it) => <div key={"g" + it.id} className={styles.reason}>{it.item} is for {groupsLabel(it.groups)}</div>)}
|
||||
{c.offStyleItems.map((it) => <div key={"c" + it.id} className={styles.reason}>{it.item} is the {genderLabel(it.gender)} cut</div>)}
|
||||
</div>
|
||||
<label className={styles.tick}>
|
||||
<input type="checkbox" checked={c.override} onChange={() => c.setOverride(!c.override)} aria-describedby={reasonsId} />
|
||||
Record as an override
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{c.ap && (
|
||||
<div className={styles.afterRow}>
|
||||
<span>Sets off the signed form</span>
|
||||
<QtyStepper size="sm" value={c.apN} min={0} max={c.apRem} label="set off the signed form" onChange={(n) => c.setApDeduct(n)} />
|
||||
<span className={styles.meta}>{c.apRem} left</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.charge}>
|
||||
<div>
|
||||
<div className="tc-lbl">Charged to {costCentre || "no cost centre"}</div>
|
||||
<div className={`tc-mono ${styles.total}`}>{money(c.cartVal)}</div>
|
||||
</div>
|
||||
<div className={styles.chargeBtns}>
|
||||
<button type="button" className="btn btn-secondary" onClick={c.printSlip} disabled={c.cannot || c.handed.length === 0}><Icon name="print" size={16} /> Collection slip</button>
|
||||
<button type="button" className="btn btn-primary" onClick={onRecord} disabled={c.cannot} aria-keyshortcuts="Control+Enter Meta+Enter">
|
||||
Record issue <span className={styles.kbdHint}><Kbd onAccent>{mac ? "⌘↵" : "Ctrl↵"}</Kbd></span>
|
||||
</button>
|
||||
</div>
|
||||
{c.inactive && <div className={styles.hint} style={{ width: "100%" }}>Inactive on the register: reactivate to issue</div>}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/* Counter (/app/counter). Layout only; the shared parts (Panel, Seg, Meter, QtyStepper, Tag) keep their own styles. */
|
||||
.stack { display: flex; flex-direction: column; gap: 18px; }
|
||||
|
||||
/* Person panel */
|
||||
.person { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr) auto; gap: 24px; align-items: center; padding: 16px 20px; border: 2px solid var(--color-text); background: var(--color-bg); }
|
||||
.line1 { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
||||
.name { font-family: var(--font-heading); font-weight: 800; font-size: 24px; letter-spacing: -0.01em; line-height: 1.15; }
|
||||
.meta { font-size: 12px; color: #57534f; }
|
||||
.details { margin-top: 4px; font-size: 13px; color: #57534f; }
|
||||
.route { margin-top: 6px; font-size: 12px; color: #57534f; display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 14px; }
|
||||
.inlineGhost { min-height: 0 !important; padding: 0 !important; font-size: 12px; }
|
||||
.meters { display: flex; flex-direction: column; gap: 8px; }
|
||||
.personActions { display: flex; flex-direction: column; gap: 8px; align-items: flex-end; }
|
||||
|
||||
/* Mode row and grid */
|
||||
.modeRow { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
.modeSeg { max-width: 100%; overflow-x: auto; }
|
||||
.kbdHint { font-size: 12px; color: #57534f; white-space: nowrap; }
|
||||
.grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 24px; align-items: start; }
|
||||
.grid > * { min-width: 0; }
|
||||
|
||||
/* Person search */
|
||||
.search { padding: 14px 16px; border-bottom: 1px solid #cfcccb; }
|
||||
.searchInput { width: 100%; }
|
||||
.pickRow { display: flex; align-items: center; gap: 14px; width: 100%; padding: 11px 16px; border: none; border-top: 1px solid #cfcccb; background: transparent; color: inherit; text-align: left; cursor: pointer; font: inherit; }
|
||||
.pickRow:first-child { border-top: 0; }
|
||||
.pickRow:hover { background: var(--color-neutral-200); }
|
||||
.pickRow:focus-visible { outline: 2px solid var(--color-accent); outline-offset: -3px; }
|
||||
.pickMain { flex: 1; min-width: 0; }
|
||||
.pickMeters { flex: none; display: flex; flex-direction: column; gap: 4px; }
|
||||
|
||||
/* Add garments */
|
||||
.scanWrap { position: relative; padding: 14px 16px; border-bottom: 1px solid #cfcccb; }
|
||||
.scanBox { display: flex; align-items: center; gap: 8px; min-height: 36px; padding: 0 4px 0 10px; background: #fff; border: 2px solid var(--color-text); color: #57534f; }
|
||||
.scanBox:focus-within { border-color: var(--color-accent); box-shadow: inset 0 0 0 1px var(--color-accent); }
|
||||
.scanInput { flex: 1; min-width: 0; border: none; outline: none; background: transparent; padding: 6px 0; font-family: var(--font-body); font-size: 14px; color: var(--color-text); }
|
||||
.scanInput::placeholder { color: #928d8a; }
|
||||
.camBtn { min-height: 28px !important; padding: 0 8px !important; }
|
||||
.pop { position: absolute; left: 16px; right: 16px; top: calc(100% - 12px); z-index: 30; max-height: 440px; overflow: auto; background: var(--color-bg); border: 2px solid var(--color-text); }
|
||||
.opt { padding: 10px 14px; border-top: 1px solid #cfcccb; }
|
||||
.opt:first-child { border-top: 0; }
|
||||
.optHead { display: flex; gap: 8px; align-items: baseline; margin-bottom: 6px; font-weight: 700; }
|
||||
.optDivider { padding: 8px 14px 4px; border-top: 2px solid var(--color-text); }
|
||||
.popEmpty { padding: 12px 14px; font-size: 13px; color: #57534f; }
|
||||
.scanErr { padding: 0 16px 12px; }
|
||||
.usualLbl { padding: 12px 16px 4px; }
|
||||
.chips { padding: 6px 16px 14px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.chip { gap: 10px !important; }
|
||||
.holdLbl { padding: 10px 16px; border-top: 2px solid var(--color-text); }
|
||||
.holdFoot { padding: 8px 16px 12px; display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
|
||||
.tableWrap { overflow-x: auto; }
|
||||
.rowGhost { min-height: 0 !important; padding-top: 0 !important; padding-bottom: 0 !important; }
|
||||
|
||||
/* This pickup */
|
||||
.cartLine { display: flex; align-items: center; gap: 14px; padding: 11px 16px; border-top: 1px solid #cfcccb; }
|
||||
.cartLine:first-child { border-top: 0; }
|
||||
.lineMain { flex: 1; min-width: 0; }
|
||||
.lineTitle { font-weight: 700; }
|
||||
.lineSeg { margin-top: 6px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.hint { font-size: 12px; font-weight: 600; color: var(--color-accent-700); }
|
||||
.cost { width: 70px; flex: none; text-align: right; }
|
||||
.empty { padding: 14px 16px; font-size: 13px; color: #57534f; }
|
||||
.after { padding: 12px 16px; border-top: 1px solid #cfcccb; display: flex; flex-direction: column; gap: 8px; font-size: 14px; }
|
||||
.afterRow { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.reason { font-size: 13px; font-weight: 600; color: var(--color-accent-700); padding-left: 26px; }
|
||||
.tick { display: flex; align-items: center; gap: 10px; padding-left: 26px; font-weight: 600; cursor: pointer; }
|
||||
.tick input { width: 16px; height: 16px; accent-color: var(--color-accent); }
|
||||
.charge { padding: 14px 16px; border-top: 2px solid var(--color-text); display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.total { font-size: 24px; font-weight: 600; line-height: 1.2; }
|
||||
.chargeBtns { margin-left: auto; display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||||
.msg { font-size: 13px; font-weight: 600; border-top: 2px solid var(--color-text); padding-top: 8px; }
|
||||
|
||||
/* Return / Hand in / Swap */
|
||||
.panelPad { padding: 14px 16px; }
|
||||
.listRow { display: flex; align-items: center; gap: 12px; padding: 11px 16px; border-top: 1px solid #cfcccb; flex-wrap: wrap; }
|
||||
.listRow:first-child { border-top: 0; }
|
||||
.listMain { flex: 1; min-width: 0; }
|
||||
.subLbl { padding: 10px 16px; border-top: 2px solid var(--color-text); }
|
||||
.swapSelect { min-height: 32px; max-width: 220px; }
|
||||
|
||||
@media screen and (max-width: 780px) {
|
||||
.person { grid-template-columns: minmax(0, 1fr); gap: 14px; padding: 14px 16px; }
|
||||
.personActions { flex-direction: row; align-items: center; gap: 18px; flex-wrap: wrap; }
|
||||
.modeSeg :global(.tc-seg) { flex-wrap: nowrap; }
|
||||
.kbdHint { display: none; }
|
||||
.grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.cartFirst > .pickup { order: -1; }
|
||||
.cartLine { flex-wrap: wrap; }
|
||||
.chargeBtns { margin-left: 0; width: 100%; flex-direction: column; align-items: stretch; }
|
||||
.chargeBtns > :global(.btn) { width: 100%; justify-content: center; }
|
||||
.scanInput { font-size: 16px; }
|
||||
.route :global(.btn), .personActions :global(.btn), .rowGhost, .camBtn { min-height: 44px !important; }
|
||||
.swapSelect { max-width: 100%; }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/* Small shared pieces for the person-first counter (/app/counter). */
|
||||
import { isOpen, type IssueRec, type Item, type Snapshot, type StaffRec } from "@/lib/compute";
|
||||
|
||||
export type Source = "stock" | "preloved" | "order";
|
||||
/** src null = both shelf and pre-loved stock exist, so the coordinator must pick one. */
|
||||
export type CartLine = { itemId: string; si: number; qty: number; src: Source | null };
|
||||
|
||||
export type Mode = "issue" | "return" | "handin" | "swap";
|
||||
export const MODES: readonly Mode[] = ["issue", "return", "handin", "swap"];
|
||||
export const MODE_LABELS: Record<Mode, string> = { issue: "Issue", return: "Return", handin: "Hand in", swap: "Swap a size" };
|
||||
|
||||
export const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
|
||||
|
||||
/** "2 Sep": the counter's short date. */
|
||||
export function dayMonth(iso: string): string {
|
||||
if (!iso || iso.length < 10) return "—";
|
||||
const d = new Date(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10));
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleDateString("en-AU", { day: "numeric", month: "short" }).replace("Sept", "Sep");
|
||||
}
|
||||
|
||||
/** Words for the person, from the cut their record is set to. */
|
||||
export function pronoun(st: StaffRec | undefined) {
|
||||
if (st?.uniformStyle === "Women's") return { poss: "Her", subj: "she", holds: "holds" } as const;
|
||||
if (st?.uniformStyle === "Men's") return { poss: "His", subj: "he", holds: "holds" } as const;
|
||||
return { poss: "Their", subj: "they", holds: "hold" } as const;
|
||||
}
|
||||
|
||||
/** Garments out with this person now: issued, not returned, not handed in. Newest first. */
|
||||
export function openIssuesOf(s: Snapshot, staffId: string): IssueRec[] {
|
||||
return s.issues
|
||||
.filter((i) => i.staffId === staffId && !i.returned && !i.handedIn)
|
||||
.sort((a, b) => (a.date === b.date ? (a.createdAt < b.createdAt ? 1 : -1) : a.date < b.date ? 1 : -1));
|
||||
}
|
||||
|
||||
export type HoldGroup = { itemId: string; si: number; qty: number; last: string; latest: IssueRec };
|
||||
/** Open issues grouped by garment and size, quantities summed, most recent first. */
|
||||
export function holdingGroups(open: IssueRec[]): HoldGroup[] {
|
||||
const m = new Map<string, HoldGroup>();
|
||||
for (const i of open) {
|
||||
const k = i.itemId + ":" + i.si;
|
||||
const g = m.get(k);
|
||||
if (!g) m.set(k, { itemId: i.itemId, si: i.si, qty: i.qty, last: i.date, latest: i });
|
||||
else { g.qty += i.qty; if (i.date > g.last) { g.last = i.date; g.latest = i; } }
|
||||
}
|
||||
return [...m.values()].sort((a, b) => (a.last < b.last ? 1 : a.last > b.last ? -1 : 0));
|
||||
}
|
||||
|
||||
export type OwedLine = { key: string; itemId: string; size: string; qty: number; where: string };
|
||||
/** What is committed to this person and not handed over yet, line by line: open orders (less what
|
||||
* has arrived), pickups waiting, approved request lines. The same three things capCheck counts. */
|
||||
export function owedLinesOf(s: Snapshot, staffId: string): OwedLine[] {
|
||||
const out: OwedLine[] = [];
|
||||
for (const o of s.orders) {
|
||||
if (o.staffId !== staffId || !isOpen(o)) 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 k = l.itemId + "|" + l.size, done = Math.min(l.qty, got[k] || 0);
|
||||
got[k] = (got[k] || 0) - done;
|
||||
if (l.qty > done) out.push({ key: "o" + l.id, itemId: l.itemId, size: l.size, qty: l.qty - done, where: `On order · ${o.code}` });
|
||||
}
|
||||
}
|
||||
for (const p of s.pickups) if (p.staffId === staffId && !p.pickedUp) p.lines.forEach((l, i) => out.push({ key: "p" + p.id + i, itemId: l.itemId, size: l.size, qty: l.qty, where: "Waiting to collect" }));
|
||||
(s.owedRequestLines || []).forEach((r, i) => { if (r.staffId === staffId) out.push({ key: "r" + i, itemId: r.itemId, size: "", qty: r.qty, where: "Approved request" }); });
|
||||
return out;
|
||||
}
|
||||
|
||||
export const sizeOf = (it: Item | undefined, si: number) => (it ? String(it.sizes[si] ?? "?") : "?");
|
||||
|
||||
/** A real dialog, the command panel or any overlay is up, so page shortcuts stand down. */
|
||||
export function overlayOpen(): boolean {
|
||||
return typeof document !== "undefined" && !!document.querySelector('[role="dialog"][aria-modal="true"], .overlay, .tc-cmd-overlay');
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
/* The pickup being put together at the counter, and every rule it is checked against before it can
|
||||
* be recorded. The rules are asked of lib/compute (capCheck, garmentForGroup, garmentForStyle) so the
|
||||
* screen and the server's refusal can never disagree. */
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { openSlip } from "@/components/dialogs";
|
||||
import { approvalRemaining, capCheck, garmentForGroup, garmentForStyle, isPantItem, isTopItem, key, longLabel, onhand, openApproval, plOf, staffName, type StaffRec } from "@/lib/compute";
|
||||
import { plural, type CartLine } from "./lib";
|
||||
|
||||
export function useCounterCart(sel: StaffRec | undefined) {
|
||||
const { s, mutate } = useSnap();
|
||||
const { L, byId } = useDerived();
|
||||
const selId = sel?.id || "";
|
||||
const [cart, setCart] = useState<CartLine[]>([]);
|
||||
const [override, setOverride] = useState(false);
|
||||
const [apDeduct, setApDeduct] = useState<number | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// A new person starts with an empty bag and no deduction.
|
||||
const [owner, setOwner] = useState(selId);
|
||||
if (owner !== selId) { setOwner(selId); setCart([]); setApDeduct(null); setOverride(false); }
|
||||
|
||||
// An override is about one person and one bag: the tick goes the moment either changes, cleared
|
||||
// as the page draws so the new bag is never on screen with the old tick behind it.
|
||||
const bagKey = selId ? `${selId}|${cart.map((c) => `${c.itemId}:${c.si}:${c.qty}:${c.src}`).join(",")}` : "";
|
||||
const [tickedFor, setTickedFor] = useState(bagKey);
|
||||
if (tickedFor !== bagKey) { setTickedFor(bagKey); setOverride(false); }
|
||||
|
||||
function add(itemId: string, si: number) {
|
||||
setCart((c) => {
|
||||
const f = c.find((x) => x.itemId === itemId && x.si === si);
|
||||
if (f) return c.map((x) => (x === f ? { ...x, qty: x.qty + 1 } : x));
|
||||
const oh = onhand(s, L, key(itemId, si)), pl = plOf(s, key(itemId, si));
|
||||
return [...c, { itemId, si, qty: 1, src: pl > 0 && oh >= 1 ? null : pl > 0 ? "preloved" : oh >= 1 ? "stock" : "order" }];
|
||||
});
|
||||
}
|
||||
const setLine = (i: number, p: Partial<CartLine>) => setCart((c) => c.map((x, j) => (j === i ? { ...x, ...p } : x)));
|
||||
const setQty = (i: number, n: number) => setCart((c) => (n < 1 ? c.filter((_, j) => j !== i) : c.map((x, j) => (j === i ? { ...x, qty: n } : x))));
|
||||
|
||||
// The whole cart goes to the ceiling, pre-loved and ordered-in lines included.
|
||||
const cap = useMemo(() => (sel ? capCheck(s, sel, cart.map((c) => ({ itemId: c.itemId, qty: c.qty }))) : null), [s, sel, cart]);
|
||||
const cartItems = useMemo(() => [...new Set(cart.map((c) => c.itemId))].map((id) => byId[id]).filter((it): it is NonNullable<typeof it> => !!it), [cart, byId]);
|
||||
const offItems = sel ? cartItems.filter((it) => !garmentForGroup(it, sel.group)) : [];
|
||||
const offStyleItems = sel ? cartItems.filter((it) => !garmentForStyle(it, sel.uniformStyle)) : [];
|
||||
const overCap = !!cap && cap.over;
|
||||
const needsTick = overCap || offItems.length > 0 || offStyleItems.length > 0;
|
||||
|
||||
const anyShort = cart.some((c) => (c.src === "stock" && c.qty > onhand(s, L, key(c.itemId, c.si))) || (c.src === "preloved" && c.qty > plOf(s, key(c.itemId, c.si))));
|
||||
const anyUnpicked = cart.some((c) => c.src === null);
|
||||
const inactive = !!sel?.inactive;
|
||||
|
||||
// Pre-loved is free: out of the charge and out of what a signed form pays for.
|
||||
const cartVal = cart.filter((c) => c.src !== "preloved").reduce((t, c) => t + c.qty * (byId[c.itemId]?.cost || 0), 0);
|
||||
const garments = cart.reduce((t, c) => t + c.qty, 0);
|
||||
|
||||
const ap = sel ? openApproval(s, sel.id) : undefined;
|
||||
const apRem = sel ? approvalRemaining(s, sel.id) : 0;
|
||||
const cartTops = cart.reduce((t, c) => t + (isTopItem(byId[c.itemId]) && c.src !== "preloved" ? c.qty : 0), 0);
|
||||
const cartPants = cart.reduce((t, c) => t + (isPantItem(byId[c.itemId]) && c.src !== "preloved" ? c.qty : 0), 0);
|
||||
const apDefault = ap ? Math.min(apRem, Math.max(cartTops, cartPants)) : 0;
|
||||
const apN = ap ? (apDeduct === null ? apDefault : Math.min(apDeduct, apRem)) : 0;
|
||||
|
||||
const cannot = !sel || inactive || cart.length === 0 || anyShort || anyUnpicked || (needsTick && !override) || busy;
|
||||
|
||||
async function record(): Promise<{ ok: true; msg: string } | { ok: false; error: string } | null> {
|
||||
if (cannot || !sel) return null;
|
||||
setBusy(true);
|
||||
// Only the ticked box, and only while the box is on the screen.
|
||||
const r = await mutate<{ stock: number; ordered: number; preloved: number; apDeducted: number; apRemaining: number }>("issue.create", { staffId: sel.id, override: needsTick && override, apDeduct: ap ? apN : 0, lines: cart });
|
||||
setBusy(false);
|
||||
if (!r.ok) return { ok: false, error: r.error };
|
||||
const parts = [
|
||||
r.result.stock ? `${r.result.stock} from stock` : "",
|
||||
r.result.ordered ? `${r.result.ordered} ordered in` : "",
|
||||
r.result.preloved ? `${r.result.preloved} pre-loved` : "",
|
||||
r.result.apDeducted ? `${r.result.apDeducted} off the signed form` : "",
|
||||
].filter(Boolean);
|
||||
setCart([]); setOverride(false); setApDeduct(null);
|
||||
return { ok: true, msg: `Recorded for ${staffName(sel)}: ${parts.length ? parts.join(" · ") : plural(garments, "garment", "garments")}.` };
|
||||
}
|
||||
|
||||
// The collection slip covers what crosses the counter today: shelf and pre-loved lines.
|
||||
const handed = cart.filter((c) => c.src === "stock" || c.src === "preloved");
|
||||
function printSlip() {
|
||||
if (!sel) return;
|
||||
openSlip("collection", {
|
||||
staffName: staffName(sel), dept: sel.dept, sets: handed.reduce((t, c) => t + c.qty, 0), po: "",
|
||||
lines: handed.map((c) => `${c.qty} × ${longLabel(byId[c.itemId])} — ${byId[c.itemId]?.sizes[c.si] ?? "?"}${c.src === "preloved" ? " (pre-loved)" : ""}`).join("\n"),
|
||||
dateReceived: s.today, requestedBy: sel.num, deliveredBy: s.settings.coordinator, dateTime: s.today,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
cart, add, setLine, setQty, replace: setCart,
|
||||
cap, overCap, offItems, offStyleItems, needsTick, override, setOverride,
|
||||
anyShort, anyUnpicked, inactive, cartVal, garments,
|
||||
ap, apRem, apN, setApDeduct,
|
||||
busy, cannot, record, handed, printSlip,
|
||||
};
|
||||
}
|
||||
export type CounterCart = ReturnType<typeof useCounterCart>;
|
||||
Reference in New Issue
Block a user