Files
threadcount-community/app/m/(app)/receive/page.tsx
T
ThreadCount 822c0b7c0b 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).
2026-09-13 11:09:20 +10:00

122 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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} />}
</>
);
}