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 49da3a4 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -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. It’s 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 they’re 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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user