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 8685140 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-13 11:09:20 +10:00
commit 822c0b7c0b
406 changed files with 47742 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
"use client";
/* Receive a delivery — tick lines against the invoice as you unpack. Receiving closes the order:
anything short is raised as its own back order, so the shortfall is chased on a live order
rather than left sitting on a closed one. */
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useSnap } from "@/lib/client";
import { fmtDate, itemMap, label, OPEN_STATUSES, sizeIndexOf, staffMap, staffName, variantName } from "@/lib/compute";
import { INK, MBar, MBody, MEmpty, MError, MRow, MRule, MSection, MStepper, MTop } from "@/components/m";
export default function MReceive() {
const { s, mutate, busy } = useSnap();
const router = useRouter();
const [pick, setPick] = useState<string | null>(null);
const [got, setGot] = useState<Record<string, number>>({});
const [invoice, setInvoice] = useState("");
const [err, setErr] = useState("");
/* The shortfall is banked here at the moment of receipt, not read off the order afterwards:
receiving closes the order, so the refresh that follows drops it out of the open list and both
`lines` and `short` fall empty. Reading them on this screen told a storeperson who had just
stepped two tunics down to zero that everything on the order arrived, and the back order sat
unchased on Ordering. */
const [done, setDone] = useState<{ code: string; short: number } | null>(null);
const byId = useMemo(() => itemMap(s), [s]);
const staffById = useMemo(() => staffMap(s), [s]);
const open = useMemo(() => s.orders.filter((o) => OPEN_STATUSES.includes(o.status) && o.status !== "Draft"), [s]);
const order = open.find((o) => o.id === pick);
// What's still outstanding on each line after any earlier partial receipt.
const lines = useMemo(() => {
if (!order) return [];
return order.lines.map((l) => {
const already = order.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 { ...l, already, outstanding: Math.max(0, l.qty - already), name: `${variantName(byId[l.itemId], l.size)}`, ok: sizeIndexOf(byId[l.itemId], l.size) >= 0 };
}).filter((l) => l.outstanding > 0);
}, [order, byId]);
const q = (id: string, fallback: number) => got[id] ?? fallback;
const arriving = lines.reduce((t, l) => t + q(l.id, l.outstanding), 0);
const short = lines.filter((l) => q(l.id, l.outstanding) < l.outstanding);
const receive = async () => {
if (!order) return;
const r = await mutate("order.receive", {
id: order.id, invoice: invoice.trim(),
lines: lines.map((l) => ({ lineId: l.id, itemId: l.itemId, size: l.size, arrived: q(l.id, l.outstanding), dest: order.staffId ? "pickup" : "shelf" })),
});
if (!r.ok) { setErr(r.error); return; }
setDone({ code: order.code, short: short.length });
};
if (done) return (
<>
<MTop title="Received" />
<MRule />
<MBody><MEmpty title={`${done.code} received`} sub={done.short ? `${done.code} is closed as received, and a back order for the ${done.short} short line${done.short === 1 ? "" : "s"} has been raised automatically. Its waiting on Ordering on the desktop.` : "Everything on the order arrived. Stock is updated."} /></MBody>
<MBar label="Back" onClick={() => router.push("/m/more")} />
</>
);
return (
<>
<MTop title="Receive" back right={order ? order.code : `${open.length} on order`} />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody>
{!order ? (
<>
<MSection label="On their way" />
{open.length === 0
? <MEmpty title="Nothing on order" sub="Orders show up here once theyre marked as ordered on the desktop." />
: open.map((o) => (
<MRow key={o.id} onClick={() => { setPick(o.id); setGot({}); setInvoice(o.invoice || ""); }} mark="ink"
title={`${o.supplier || "Supplier"} · ${o.code}`}
sub={[o.staffId ? `For ${staffName(staffById[o.staffId])}` : "For stock", o.expected ? `expected ${fmtDate(o.expected)}` : o.status].filter(Boolean).join(" · ")}
right={<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 17, fontVariantNumeric: "tabular-nums" }}>{o.lines.reduce((t, l) => t + l.qty, 0)}</span>} />
))}
</>
) : (
<>
<div style={{ padding: "20px 16px", background: "#fff", borderBottom: "2px solid " + INK }}>
<div style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-accent-700)" }}>{order.supplier || "Supplier"}</div>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em", marginTop: 6 }}>{order.code}</div>
<div style={{ fontSize: 13.5, color: "var(--color-neutral-600)", marginTop: 6 }}>
Ordered {fmtDate(order.date)}{order.expected ? ` · expected ${fmtDate(order.expected)}` : ""}
</div>
<input value={invoice} onChange={(e) => setInvoice(e.target.value)} placeholder="Invoice number (optional)" aria-label="Invoice number"
style={{ width: "100%", minHeight: 48, padding: "10px 12px", border: "2px solid " + INK, background: "var(--color-bg)", fontSize: 16, fontWeight: 600, marginTop: 14 }} />
<button onClick={() => setPick(null)} style={{ marginTop: 12, background: "none", border: 0, padding: 0, color: "var(--color-accent-700)", fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Choose a different order</button>
</div>
<MSection label="Tick each line as you unpack" right="Arrived / ordered" />
{lines.length === 0
? <MEmpty title="Nothing outstanding" sub="Every line on this order has already been receipted." />
: lines.map((l) => (
<div key={l.id} style={{ display: "flex", alignItems: "center", gap: 12, padding: 16, borderBottom: "1px solid var(--color-divider)", background: q(l.id, l.outstanding) < l.outstanding ? "#fff" : "var(--color-bg)" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 700 }}>{l.name}</div>
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 3 }}>
{l.outstanding} outstanding{l.already ? ` · ${l.already} already received` : ""}
{q(l.id, l.outstanding) < l.outstanding ? ` · ${l.outstanding - q(l.id, l.outstanding)} short` : ""}
</div>
</div>
<MStepper n={q(l.id, l.outstanding)} onChange={(n) => setGot((x) => ({ ...x, [l.id]: n }))} max={l.outstanding} />
</div>
))}
{lines.length > 0 && (
<p style={{ padding: "18px 16px 26px", fontSize: 14, color: "var(--color-neutral-700)", lineHeight: 1.6 }}>
{order.staffId ? "Goes onto the pickup list for the staff member it was ordered for." : "Goes onto the shelf."}
{short.length > 0 && ` ${short.length} line${short.length === 1 ? "" : "s"} short — ${order.code} closes as received and the shortfall goes onto a new back order.`}
</p>
)}
</>
)}
</MBody>
{order && lines.length > 0 && <MBar label={busy ? "Receiving…" : `Receive ${arriving} item${arriving === 1 ? "" : "s"}`} glyph="check" onClick={receive} disabled={busy || arriving === 0} />}
</>
);
}