7d650e4c10
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from d947f89 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
194 lines
14 KiB
TypeScript
194 lines
14 KiB
TypeScript
"use client";
|
|
import Link from "next/link";
|
|
import { useMemo, useState } from "react";
|
|
import { useDerived, useSnap } from "@/lib/client";
|
|
import { PageHead, Empty, ErrorLine, Field } from "@/components/ui";
|
|
import { csvOf, flaggedNeeds, forecastFor, key, label, money, staffName, supplierCodeOf, supplierInfo, type NeedLine } from "@/lib/compute";
|
|
import { downloadCsv } from "@/lib/print";
|
|
|
|
/* The order list: what to order, by supplier, ready to key into the supplier's site.
|
|
*
|
|
* Two kinds of group. A STOCK group is the shelf's needs for one supplier — every size at or below
|
|
* its reorder level, topped up to twice the level and netted off what is already on order — with
|
|
* the quantities editable and a picker for anything else. A DRAFT group is an order that already
|
|
* exists as a draft: a person's "order in" from the counter (one per supplier per person, and it
|
|
* stays that way, because an order placed under someone's account at the supplier is its own
|
|
* order), or a replenishment draft. Raising turns each group into one placed order with the
|
|
* supplier's order number as its ref, then offers the sheet, the CSV and the email for each. */
|
|
type EditLine = { itemId: string; si: number; size: string; qty: number; code: string; oh: number; ro: number; onOrder: number; flag: boolean };
|
|
type Raised = { id: string; code: string; supplier: string; ref: string; lines: { itemId: string; size: string; qty: number }[] };
|
|
|
|
export default function OrderList() {
|
|
const { s, mutate, isAdmin } = useSnap();
|
|
const { L, byId, staffById } = useDerived();
|
|
const needs = useMemo(() => flaggedNeeds(s, L, byId), [s, L, byId]);
|
|
const [edit, setEdit] = useState<Record<string, EditLine[]> | null>(null);
|
|
const [refs, setRefs] = useState<Record<string, string>>({});
|
|
const [busy, setBusy] = useState(false);
|
|
const [err, setErr] = useState("");
|
|
const [raised, setRaised] = useState<Raised[] | null>(null);
|
|
const [mailMsg, setMailMsg] = useState<Record<string, string>>({});
|
|
const [pick, setPick] = useState<{ sup: string; itemId: string; si: number; qty: number } | null>(null);
|
|
|
|
// Stock groups start from the needs; edits live in state until raised.
|
|
const stock: Record<string, EditLine[]> = useMemo(() => {
|
|
if (edit) return edit;
|
|
const g: Record<string, EditLine[]> = {};
|
|
for (const n of needs) {
|
|
const f = forecastFor(s, L, byId, key(n.itemId, n.si));
|
|
(g[n.supplier] = g[n.supplier] || []).push({ itemId: n.itemId, si: n.si, size: n.size, qty: n.qty, code: n.code, oh: n.oh, ro: n.ro, onOrder: n.onOrder, flag: f.runsOutBeforeDelivery });
|
|
}
|
|
for (const sup in g) g[sup].sort((a, b) => Number(b.flag) - Number(a.flag) || label(byId[a.itemId]).localeCompare(label(byId[b.itemId])) || a.si - b.si);
|
|
return g;
|
|
}, [edit, needs, s, L, byId]);
|
|
const drafts = useMemo(() => s.orders.filter((o) => o.status === "Draft" && o.lines.length > 0).sort((a, b) => a.supplier.localeCompare(b.supplier) || a.code.localeCompare(b.code)), [s.orders]);
|
|
|
|
const setLine = (sup: string, i: number, patch: Partial<EditLine> | null) => {
|
|
const next = { ...stock, [sup]: stock[sup].map((l, j) => (j === i ? { ...l, ...patch } : l)).filter((_, j) => patch !== null || j !== i) };
|
|
if (!next[sup].length) delete next[sup];
|
|
setEdit(next);
|
|
};
|
|
const addLine = () => {
|
|
if (!pick || !pick.itemId || pick.qty <= 0) return;
|
|
const it = byId[pick.itemId]; if (!it) return;
|
|
const k = key(it.id, pick.si);
|
|
const line: EditLine = { itemId: it.id, si: pick.si, size: String(it.sizes[pick.si]), qty: pick.qty, code: supplierCodeOf(s, k), oh: 0, ro: 0, onOrder: 0, flag: false };
|
|
const sup = pick.sup || it.supplier || s.settings.suppliers[0] || "Supplier";
|
|
setEdit({ ...stock, [sup]: [...(stock[sup] || []).filter((l) => !(l.itemId === line.itemId && l.si === line.si)), line] });
|
|
setPick(null);
|
|
};
|
|
|
|
const stockGroups = Object.entries(stock).filter(([, ls]) => ls.some((l) => l.qty > 0));
|
|
const nGroups = stockGroups.length + drafts.length;
|
|
const costOf = (itemId: string) => byId[itemId]?.cost || 0;
|
|
|
|
async function raise() {
|
|
setBusy(true); setErr("");
|
|
const groups = [
|
|
...stockGroups.map(([sup, ls]) => ({ kind: "stock", supplier: sup, ref: refs["s:" + sup] || "", lines: ls.filter((l) => l.qty > 0).map((l) => ({ itemId: l.itemId, size: l.size, qty: l.qty })) })),
|
|
...drafts.map((o) => ({ kind: "draft", id: o.id, ref: refs["d:" + o.id] || "" })),
|
|
];
|
|
const r = await mutate<{ raised: Raised[] }>("order.raiseList", { groups });
|
|
setBusy(false);
|
|
if (!r.ok) { setErr(r.error); return; }
|
|
setRaised(r.result.raised); setEdit(null);
|
|
}
|
|
function csv(o: Raised) {
|
|
const rows = o.lines.map((l) => { const it = byId[l.itemId]; const si = it ? it.sizes.map(String).indexOf(l.size) : -1; return [supplierCodeOf(s, key(l.itemId, si)) || it?.sku || "", label(it), l.size, l.qty, costOf(l.itemId)]; });
|
|
downloadCsv(`${o.code}${o.ref ? "-" + o.ref.replace(/[^A-Za-z0-9-]+/g, "_") : ""}-${o.supplier.replace(/[^A-Za-z0-9]+/g, "_")}.csv`, `Order,${o.code}\nSupplier,${o.supplier}\nSupplier order no.,${o.ref}\n\n` + csvOf(["Supplier code", "Description", "Size", "Qty", "Unit cost"], rows));
|
|
}
|
|
async function email(o: Raised) {
|
|
const r = await mutate<{ sentTo: string }>("order.email", { id: o.id });
|
|
setMailMsg((m) => ({ ...m, [o.id]: r.ok ? `Sent to ${r.result.sentTo}` : r.error }));
|
|
}
|
|
|
|
const th: React.CSSProperties = { fontSize: 11, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--color-neutral-600)" };
|
|
const cols = "minmax(110px,1fr) minmax(180px,2fr) 60px 70px 70px 70px 90px 60px";
|
|
|
|
if (!isAdmin) return <Empty>Admins only — ordering is an admin task.</Empty>;
|
|
|
|
if (raised) {
|
|
return (
|
|
<>
|
|
<PageHead eyebrow="Orders" title={`${raised.length} order${raised.length === 1 ? "" : "s"} raised`} sub="Print the sheet, download the CSV or email it to the supplier." />
|
|
<div className="tc-panel">
|
|
<div className="tc-panel-list">
|
|
{raised.map((o) => (
|
|
<div key={o.id} className="tc-row" style={{ alignItems: "center" }}>
|
|
<div className="tc-row-main">
|
|
<div className="tc-row-name"><Link href={`/app/orders/${o.id}`}>{o.code}</Link> · {o.supplier}{o.ref ? ` · ${o.ref}` : ""}</div>
|
|
<div className="tc-row-meta">{o.lines.length} line{o.lines.length === 1 ? "" : "s"} · {money(o.lines.reduce((t, l) => t + l.qty * costOf(l.itemId), 0))}{mailMsg[o.id] ? ` · ${mailMsg[o.id]}` : ""}</div>
|
|
</div>
|
|
<div style={{ display: "flex", gap: "var(--space-2)" }}>
|
|
<a className="btn btn-secondary" href={`/print/supplier-order?id=${o.id}`} target="_blank" rel="noreferrer">Print</a>
|
|
<button className="btn btn-ghost" onClick={() => csv(o)}>CSV</button>
|
|
<button className="btn btn-ghost" onClick={() => email(o)}>Email</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div style={{ marginTop: "var(--space-4)", display: "flex", gap: "var(--space-2)" }}>
|
|
<Link href="/app/orders" className="btn btn-primary">All orders</Link>
|
|
<button className="btn btn-ghost" onClick={() => setRaised(null)}>Order list</button>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<PageHead eyebrow="Orders" title="Order list" sub={`${needs.length} line${needs.length === 1 ? "" : "s"} at or below reorder level, plus ${drafts.length} draft${drafts.length === 1 ? "" : "s"}. Usage since the first record.`} />
|
|
{nGroups === 0 && <Empty>Nothing to order — every size is above its reorder level and there are no drafts.</Empty>}
|
|
{stockGroups.map(([sup, ls]) => {
|
|
const info = supplierInfo(s, sup);
|
|
const sub = ls.reduce((t, l) => t + l.qty * costOf(l.itemId), 0);
|
|
return (
|
|
<div key={sup} className="tc-panel" style={{ marginTop: "var(--space-4)" }}>
|
|
<div className="tc-panel-head">
|
|
<span>{sup} · stock</span>
|
|
<span className="tc-panel-aside">{info?.account ? `account ${info.account} · ` : ""}{ls.length} line{ls.length === 1 ? "" : "s"} · {money(sub)}</span>
|
|
</div>
|
|
<div className="table-wrap">
|
|
<div style={{ display: "grid", gridTemplateColumns: cols, gap: "var(--space-2)", padding: "var(--space-2) var(--space-4)", borderBottom: "2px solid var(--color-text)", ...th, minWidth: 760 }}>
|
|
<div>Code</div><div>Garment</div><div>Size</div><div style={{ textAlign: "right" }}>On hand</div><div style={{ textAlign: "right" }}>Reorder</div><div style={{ textAlign: "right" }}>On order</div><div style={{ textAlign: "right" }}>Order qty</div><div></div>
|
|
</div>
|
|
{ls.map((l, i) => (
|
|
<div key={l.itemId + ":" + l.si} style={{ display: "grid", gridTemplateColumns: cols, gap: "var(--space-2)", padding: "var(--space-2) var(--space-4)", borderBottom: "1px solid var(--color-divider)", alignItems: "center", fontSize: 13, minWidth: 760 }}>
|
|
<div style={{ fontFamily: "var(--font-mono, ui-monospace, monospace)" }}>{l.code || <Link href={`/app/stock/${l.itemId}`} style={{ color: "var(--color-accent-700)", fontWeight: 700 }}>no code</Link>}</div>
|
|
<div>{label(byId[l.itemId])}{l.flag && <span className="tag tag-flag" style={{ marginLeft: 6 }} title="Runs out before a delivery placed today would arrive">runs out</span>}</div>
|
|
<div>{l.size}</div>
|
|
<div style={{ textAlign: "right" }}>{l.oh}</div>
|
|
<div style={{ textAlign: "right" }}>{l.ro}</div>
|
|
<div style={{ textAlign: "right" }}>{l.onOrder}</div>
|
|
<div style={{ textAlign: "right" }}><input className="input" style={{ width: 72, textAlign: "right", minHeight: 30 }} type="number" min={0} aria-label={`Quantity of ${label(byId[l.itemId])} ${l.size}`} value={l.qty} onChange={(e) => setLine(sup, i, { qty: Math.max(0, parseInt(e.target.value || "0", 10) || 0) })} /></div>
|
|
<div style={{ textAlign: "right" }}><button className="btn btn-ghost" style={{ minHeight: 26, padding: "2px 8px" }} aria-label={`Remove ${label(byId[l.itemId])} ${l.size}`} onClick={() => setLine(sup, i, null)}>Remove</button></div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="tc-panel-foot" style={{ display: "flex", gap: "var(--space-3)", alignItems: "flex-end", flexWrap: "wrap" }}>
|
|
<Field label="Supplier order no." hint="Optional — the number the supplier gives you when you place it.">{(c) => <input {...c} className="input" style={{ width: 200 }} placeholder="e.g. NW-48211" value={refs["s:" + sup] || ""} onChange={(e) => setRefs({ ...refs, ["s:" + sup]: e.target.value })} />}</Field>
|
|
<button className="btn btn-ghost" onClick={() => setPick({ sup, itemId: "", si: 0, qty: 1 })}>Add a line</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{drafts.map((o) => (
|
|
<div key={o.id} className="tc-panel" style={{ marginTop: "var(--space-4)" }}>
|
|
<div className="tc-panel-head">
|
|
<span>{o.supplier || "No supplier"} · {o.staffId ? `for ${staffName(staffById[o.staffId])}${o.cc ? ` · ${o.cc}` : ""}` : "draft"} · <Link href={`/app/orders/${o.id}`}>{o.code}</Link></span>
|
|
<span className="tc-panel-aside">{o.lines.length} line{o.lines.length === 1 ? "" : "s"} · {money(o.lines.reduce((t, l) => t + l.qty * costOf(l.itemId), 0))}</span>
|
|
</div>
|
|
<div className="tc-panel-list">
|
|
{o.lines.map((l) => { const it = byId[l.itemId]; const si = it ? it.sizes.map(String).indexOf(l.size) : -1; const code = supplierCodeOf(s, key(l.itemId, si)); return (
|
|
<div key={l.id} className="tc-row"><div className="tc-row-main"><div className="tc-row-name"><span style={{ fontFamily: "var(--font-mono, ui-monospace, monospace)" }}>{code || "no code"}</span> · {label(it)} · {l.size}</div></div><div className="tc-row-fig">{l.qty}</div></div>
|
|
); })}
|
|
</div>
|
|
<div className="tc-panel-foot">
|
|
<Field label="Supplier order no." hint="Optional.">{(c) => <input {...c} className="input" style={{ width: 200 }} placeholder="e.g. Q-20931" value={refs["d:" + o.id] || o.ref || ""} onChange={(e) => setRefs({ ...refs, ["d:" + o.id]: e.target.value })} />}</Field>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{pick && (
|
|
<div className="tc-panel" style={{ marginTop: "var(--space-4)" }}>
|
|
<div className="tc-panel-head"><span>Add a line · {pick.sup}</span></div>
|
|
<div className="tc-panel-body" style={{ display: "flex", gap: "var(--space-3)", alignItems: "flex-end", flexWrap: "wrap" }}>
|
|
<Field label="Garment" style={{ minWidth: 240 }}>{(c) => <select {...c} className="input" value={pick.itemId} onChange={(e) => setPick({ ...pick, itemId: e.target.value, si: 0 })}><option value="">Choose…</option>{s.catalog.filter((it) => !it.archived).map((it) => <option key={it.id} value={it.id}>{label(it)}{it.supplier ? ` · ${it.supplier}` : ""}</option>)}</select>}</Field>
|
|
<Field label="Size">{(c) => <select {...c} className="input" value={pick.si} onChange={(e) => setPick({ ...pick, si: parseInt(e.target.value, 10) })}>{(byId[pick.itemId]?.sizes || []).map((sz, i) => <option key={i} value={i}>{String(sz)}</option>)}</select>}</Field>
|
|
<Field label="Qty">{(c) => <input {...c} className="input" style={{ width: 80 }} type="number" min={1} value={pick.qty} onChange={(e) => setPick({ ...pick, qty: Math.max(1, parseInt(e.target.value || "1", 10) || 1) })} />}</Field>
|
|
<button className="btn btn-secondary" disabled={!pick.itemId} onClick={addLine}>Add</button>
|
|
<button className="btn btn-ghost" onClick={() => setPick(null)}>Cancel</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{err && <ErrorLine msg={err} />}
|
|
{nGroups > 0 && (
|
|
<div style={{ marginTop: "var(--space-5)", display: "flex", gap: "var(--space-3)", alignItems: "center" }}>
|
|
<button className="btn btn-primary" disabled={busy} onClick={raise}>{busy ? "Raising…" : `Raise ${nGroups} order${nGroups === 1 ? "" : "s"}`}</button>
|
|
<Link href="/app/orders" className="btn btn-ghost">Back</Link>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|