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
+135
View File
@@ -0,0 +1,135 @@
"use client";
/* Pick an approved staff request: tick or scan each garment in the bag, then hand it over. The first
pick moves the request to "picking" through request.pick, so the desktop queue shows it as being
picked. Ticks live in the basket (this phone only) until the hand-over is signed. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { wasSpent } from "@/components/SignFlow";
import { useDerived, useSnap } from "@/lib/client";
import { bcParse, key, onhand } from "@/lib/compute";
import { scanReject } from "@/lib/feedback";
import { relativeDay } from "@/lib/today";
import { PICK_STATUSES } from "@/lib/workcount";
import { useRequests } from "@/components/requests/RequestList";
import { useBasket } from "@/components/MBasket";
import MScan from "@/components/MScan";
import {
MBar, MBody, MButton, MEmpty, MError, MHead, MKick, MPickRow, MRow, MRule, MSection, MTop, useScanFlash, useToast,
} from "@/components/m";
import { garmentSize, locMap, personMeta, segment, shelfOf } from "@/components/m/work/util";
export default function PickRequest() {
const id = segment(useParams<{ id: string }>().id);
const { s, mutate } = useSnap();
const { L, byId, staffById } = useDerived();
const { data, error, reload } = useRequests();
const basket = useBasket();
const toast = useToast();
const flash = useScanFlash();
const [err, setErr] = useState("");
const [scanning, setScanning] = useState(false);
const pickFired = useRef(false);
const locs = useMemo(() => locMap(s), [s]);
const r = data?.requests.find((x) => x.id === id);
const lines = useMemo(() => r?.bag ?? [], [r]);
const stored = basket.picked(id);
const router = useRouter();
const gone = !!data && (!r || !PICK_STATUSES.has(r.status) || !lines.length);
const spent = gone && wasSpent(`/m/request/${id}`);
// Back from this request's own Done screen: the list is spent, so carry on to the queue.
useEffect(() => { if (spent) router.replace("/m/work?seg=picks"); }, [spent, router]);
// A ready bag was packed at the counter already: every line starts ticked.
const got = useCallback((lineId: string, q: number) => {
const v = stored[lineId];
if (v === undefined) return r?.status === "ready" ? q : 0;
return Math.max(0, Math.min(q, v));
}, [stored, r]);
const total = lines.reduce((t, l) => t + l.qty, 0);
const picked = lines.reduce((t, l) => t + got(l.id, l.qty), 0);
const setLine = (lineId: string, n: number) => {
const prev = stored;
basket.setPicked(id, { ...prev, [lineId]: n });
return () => basket.setPicked(id, prev);
};
/** The first garment picked off an approved request tells the queue it is being picked. */
const firstPick = async (undo: () => void) => {
if (!r || r.status !== "accepted" || pickFired.current) return;
pickFired.current = true;
const res = await mutate("request.pick", { id: r.id });
if (!res.ok) { pickFired.current = false; undo(); setErr(res.error); void reload(); return; }
void reload();
};
const toggle = (lineId: string, q: number) => {
const cur = got(lineId, q);
const undo = setLine(lineId, cur >= q ? 0 : q);
if (cur < q) void firstPick(undo);
};
const onScan = (raw: string) => {
setScanning(false);
const hit = bcParse(s, raw);
if (!hit) { scanReject(); toast("That code isnt a garment"); return; }
const it = byId[hit.itemId];
const name = garmentSize(it, "Garment", it?.sizes[hit.si] ?? hit.si);
const match = lines.filter((l) => l.itemId === hit.itemId && l.si === hit.si);
if (!match.length) { scanReject(); toast(`${name} isnt on this request`); return; }
const next = match.find((l) => got(l.id, l.qty) < l.qty);
if (!next) { toast("Everything is picked"); return; }
const n = got(next.id, next.qty) + 1;
flash("Garment", name, () => { const undo = setLine(next.id, n); void firstPick(undo); });
};
const shell = (body: React.ReactNode, bar?: React.ReactNode) => (
<>
<MTop title="Pick request" back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>{body}</MBody>
{bar}
</>
);
if (!data) {
return shell(error
? <><MError msg="Requests couldnt be loaded." /><MButton small label="Try again" onClick={() => void reload()} /></>
: <MKick>Loading</MKick>);
}
if (!r || !PICK_STATUSES.has(r.status) || !lines.length) {
if (spent) return shell(<MKick>Loading</MKick>);
return shell(<><MEmpty title="That request has moved on" /><MButton label="Back to Work" href="/m/work?seg=picks" /></>);
}
const st = staffById[r.staffId];
const when = relativeDay(r.decidedAt, s);
return (
<>
{shell(
<>
<MHead name={r.staffName} meta={personMeta(s, st, r.ward)} />
<MButton tone="ink" icon="scan" label="Scan to pick"
onClick={() => (picked >= total ? toast("Everything is picked") : setScanning(true))} />
<MSection label="Pick list" right={`${picked} of ${total}`} />
{lines.map((l, i) => {
const n = got(l.id, l.qty);
const onShelf = Math.max(0, onhand(s, L, key(l.itemId, l.si)));
const sub = [shelfOf(s, locs, l.itemId, l.si), `${onShelf} on shelf`, r.status === "ready" && i === 0 && r.collectCode ? `code ${r.collectCode}` : ""]
.filter(Boolean).join(" · ");
return (
<MPickRow key={l.id} done={n >= l.qty} onToggle={() => toggle(l.id, l.qty)}
title={garmentSize(byId[l.itemId], l.item, l.size)} sub={sub} right={`${n}/${l.qty}`} />
);
})}
<MSection label="Approved" />
<MRow mark="ok" title={r.managerName || "Manager"} sub={when ? `Approved ${when}` : "Approved"} />
</>,
<MBar label="Hand over" small={`${picked} of ${total} picked`} disabled={picked < total}
offReason="Pick every line first" href={`/m/request/${r.id}/sign`} />,
)}
{scanning && <MScan title="Scan to pick" onHit={onScan} onClose={() => setScanning(false)} />}
</>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
/* Sign for a picked request, then Done. A request still "accepted" is moved to "picking" first
(the queue only lets a picked request be collected); request.collected writes the issue rows, the
signature and, when asked, the slip to the wearer's staff app. */
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { key } from "@/lib/compute";
import { plural } from "@/lib/today";
import { PICK_STATUSES } from "@/lib/workcount";
import { useRequests, type RequestRow } from "@/components/requests/RequestList";
import { useBasket } from "@/components/MBasket";
import SignFlow from "@/components/SignFlow";
import { MBody, MButton, MEmpty, MError, MHead, MKick, MRule, MTop } from "@/components/m";
import { garmentSize, personMeta, segment } from "@/components/m/work/util";
export default function RequestSign() {
const id = segment(useParams<{ id: string }>().id);
const { s, mutate } = useSnap();
const { byId, staffById } = useDerived();
const { data, error, reload } = useRequests();
const basket = useBasket();
// Held once loaded: the hand-over refreshes the queue, and the Done screen must not turn into
// "moved on" when this request leaves it.
const [held, setHeld] = useState<{ r: RequestRow; ready: boolean } | null>(null);
const [seen, setSeen] = useState(false);
const stored = basket.picked(id);
useEffect(() => {
if (held || !data) return;
const r = data.requests.find((x) => x.id === id);
if (r && PICK_STATUSES.has(r.status) && r.bag.length) {
const ready = r.status === "ready" || r.bag.every((l) => (stored[l.id] ?? 0) >= l.qty);
setHeld({ r, ready });
}
setSeen(true);
}, [data, id, held, stored]);
const shell = (body: React.ReactNode) => (
<>
<MTop title="Sign" back />
<MRule />
<MBody pad>{body}</MBody>
</>
);
if (!held) {
if (!data && error) return shell(<><MError msg="Requests couldnt be loaded." /><MButton small label="Try again" onClick={() => void reload()} /></>);
if (!data || !seen) return shell(<MKick>Loading</MKick>);
return shell(<><MEmpty title="That request has moved on" /><MButton label="Back to Work" href="/m/work?seg=picks" /></>);
}
const { r } = held;
if (!held.ready) {
return shell(<><MEmpty title="Pick every line first" /><MButton label="Back to the pick list" href={`/m/request/${r.id}`} /></>);
}
const st = staffById[r.staffId];
const total = r.bag.reduce((t, l) => t + l.qty, 0);
return (
<SignFlow
kind="request"
head={<MHead name={r.staffName} meta={personMeta(s, st, r.ward)} />}
lines={r.bag.map((l) => ({ key: l.id, name: garmentSize(byId[l.itemId], l.item, l.size), qty: l.qty }))}
signerName={r.staffName}
slip={{ available: !!st?.selfEmail }}
barLabel={`Hand over ${plural(total, "item")}`}
spentHref={`/m/request/${r.id}`}
commit={async ({ sigId, slip }) => {
// Accepted cannot go straight to collected. If the pick screen already moved it, this
// refusal is expected and collected below answers for the real state.
if (r.status === "accepted") await mutate("request.pick", { id: r.id });
const c = await mutate("request.collected", { id: r.id, sigId, slip });
if (!c.ok) { void reload(); return { ok: false, error: c.error }; }
basket.clear("picked", r.id);
void reload();
return {
ok: true,
done: {
head: "Request handed over",
sub: `${r.staffName} · ${plural(total, "item")}${slip ? " · slip sent" : ""}`,
shelfKeys: r.bag.map((l) => key(l.itemId, l.si)),
next: "work",
},
};
}}
/>
);
}