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 f976bd5 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-15 23:18:05 +10:00
commit c89010a4f1
424 changed files with 53588 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { Panel, Tag } from "@/components/portal";
import { ReceiveDialog } from "@/components/dialogs";
import { daysBetween, isOverdue, isPlacedOpen, label, staffName, type OrderRec } from "@/lib/compute";
import { plural, shortDate } from "./bits";
/* Placed orders still open: overdue first (most days late first), then by expected date, then undated. */
export default function OnTheWay() {
const { s } = useSnap();
const { byId, staffById } = useDerived();
const [rcv, setRcv] = useState<OrderRec | null>(null);
const rows = useMemo(() => s.orders.filter(isPlacedOpen).map((o) => ({ o, late: isOverdue(o, s.today) ? daysBetween(o.expected, s.today) : 0 })).sort((a, b) => {
if ((a.late > 0) !== (b.late > 0)) return a.late > 0 ? -1 : 1;
if (a.late !== b.late) return b.late - a.late;
if (!a.o.expected !== !b.o.expected) return a.o.expected ? -1 : 1;
return a.o.expected.localeCompare(b.o.expected);
}), [s.orders, s.today]);
return (
<Panel title="On the way" aside={plural(rows.length, "order")}>
{rows.length === 0 && <div className="tc-orders-row"><span className="tc-orders-rowmeta">Nothing on the way.</span></div>}
{rows.map(({ o, late }) => {
const st = o.staffId ? staffById[o.staffId] : undefined;
const what = o.lines.length === 1 ? `${label(byId[o.lines[0].itemId])} ×${o.lines[0].qty}` : plural(o.lines.length, "line");
const sup = (o.supplier || "No supplier").split(/\s+/)[0];
return (
<div key={o.id} className={"tc-orders-row" + (late > 0 ? " urgent" : "")} style={{ flexWrap: "nowrap" }}>
<div className="tc-orders-rowmain">
<div className="tc-orders-rowtitle">
<Link href={`/app/orders/${o.id}`} style={{ color: "inherit" }}>{o.code}</Link>
{late > 0 ? <Tag tone="accent">{late === 1 ? "1 day late" : `${late} days late`}</Tag> : o.staffId ? <Tag>staff</Tag> : null}
{(o.status === "Shipped" || o.status === "Back Order") && <Tag tone="quiet">{o.status}</Tag>}
</div>
<div className="tc-orders-rowmeta">
{sup} · {what} · {o.expected ? `expected ${shortDate(o.expected)}` : "no date"}{st ? ` · for ${staffName(st)}` : ""}
</div>
</div>
<button type="button" className={"btn " + (late > 0 ? "btn-primary" : "btn-ghost")} onClick={() => setRcv(o)} aria-label={`Receive ${o.code}`}>Receive</button>
</div>
);
})}
{rcv && <ReceiveDialog order={rcv} onClose={() => setRcv(null)} />}
</Panel>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { Panel } from "@/components/portal";
import { money, orderTotal, staffName, statusTag } from "@/lib/compute";
import { shortDate } from "./bits";
/* An issuer's left column: raising orders from the list is an admin task, so they see the latest ten. */
export default function RecentOrders() {
const { s } = useSnap();
const { byId, staffById } = useDerived();
const rows = useMemo(() => [...s.orders].sort((a, b) => b.date.localeCompare(a.date) || b.createdAt.localeCompare(a.createdAt)).slice(0, 10), [s.orders]);
return (
<Panel title="Recent orders" foot={<Link href="/app/orders/all" className="btn btn-ghost">Open the ledger</Link>}>
{rows.length === 0 && <div className="tc-orders-row"><span className="tc-orders-rowmeta">No orders yet.</span></div>}
{rows.map((o) => (
<Link key={o.id} href={`/app/orders/${o.id}`} className="tc-orders-row">
<div className="tc-orders-rowmain">
<div className="tc-orders-rowtitle">{o.code}</div>
<div className="tc-orders-rowmeta">{o.staffId ? `For ${staffName(staffById[o.staffId], "staff member")}` : "For stock"} · {o.supplier} · {shortDate(o.date)}</div>
</div>
<span className={statusTag(o.status)}>{o.status}</span>
<span className="tc-mono">{money(orderTotal(o, byId))}</span>
</Link>
))}
</Panel>
);
}
+256
View File
@@ -0,0 +1,256 @@
"use client";
/* One supplier's panel in To order: the sizes at reorder with editable quantities, the drafts that
* belong to the supplier, and one "Order and email" that raises the lot through order.raiseList. */
import Link from "next/link";
import { useId, useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { ErrorLine, Field, LiveRegion } from "@/components/ui";
import { Icon, QtyStepper, Tag } from "@/components/portal";
import { csvOf, fmtDate, key, label, money, staffName, supplierCodeOf } from "@/lib/compute";
import { downloadCsv, esc, openPrintWindow, tbl } from "@/lib/print";
import { draftValue, lineFor, siOf, type SupplierGroup, type ToOrderLine } from "./toOrder";
import { plural } from "./bits";
/** Number column headers: right-aligned, but in the 11px uppercase label face like Code and Garment. */
const NUM_TH = { textAlign: "right" } as const;
export type Raised = { id: string; code: string; supplier: string; ref: string; lines: { itemId: string; size: string; qty: number }[] };
export function SupplierPanel({ group, oo, raised, onRaised, onDone }: {
group: SupplierGroup;
oo: Record<string, number>;
raised?: { orders: Raised[]; mail: Record<string, string> };
onRaised: (orders: Raised[], mail: Record<string, string>) => void;
onDone: () => void;
}) {
const { s, mutate } = useSnap();
const { L, byId, staffById } = useDerived();
const hid = useId();
const [qty, setQty] = useState<Record<string, number>>({});
const [removed, setRemoved] = useState<string[]>([]);
const [added, setAdded] = useState<{ itemId: string; si: number }[]>([]);
const [ref, setRef] = useState("");
// A draft keeps its own supplier order no.; the panel's box is for the stock order only.
const [draftRefs, setDraftRefs] = useState<Record<string, string>>({});
const [pick, setPick] = useState<{ itemId: string; si: number; qty: number } | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const [mailMsg, setMailMsg] = useState<Record<string, string>>({});
const { supplier, lead, email, drafts } = group;
const lines: ToOrderLine[] = useMemo(() => {
const base = group.lines.filter((l) => !removed.includes(l.key));
const have = new Set(group.lines.map((l) => l.key));
const extra = added.map((a) => lineFor(s, L, byId, oo, a.itemId, a.si)).filter((l): l is ToOrderLine => !!l && !have.has(l.key));
return [...base, ...extra].map((l) => (qty[l.key] !== undefined ? { ...l, qty: qty[l.key] } : l));
}, [group.lines, removed, added, qty, s, L, byId, oo]);
const catalog = useMemo(() => s.catalog.filter((it) => !it.archived).sort((a, b) => Number((b.supplier || "") === supplier) - Number((a.supplier || "") === supplier) || label(a).localeCompare(label(b))), [s.catalog, supplier]);
const draftLines = drafts.reduce((t, o) => t + o.lines.length, 0);
const total = lines.reduce((t, l) => t + l.qty * l.cost, 0) + drafts.reduce((t, o) => t + draftValue(o, byId), 0);
const nLines = lines.length + draftLines;
const canRaise = lines.some((l) => l.qty > 0) || drafts.length > 0;
function removeLine(l: ToOrderLine) {
if (group.lines.some((g) => g.key === l.key)) setRemoved((r) => [...r, l.key]);
else setAdded((a) => a.filter((x) => key(x.itemId, x.si) !== l.key));
}
function addLine() {
if (!pick || !pick.itemId) return;
const k = key(pick.itemId, pick.si);
setRemoved((r) => r.filter((x) => x !== k));
if (!group.lines.some((g) => g.key === k)) setAdded((a) => (a.some((x) => key(x.itemId, x.si) === k) ? a : [...a, { itemId: pick.itemId, si: pick.si }]));
setQty((q) => ({ ...q, [k]: Math.max(0, pick.qty) }));
setPick(null);
}
function sheet() {
const facts = [lead ? `Lead ${lead} days` : "", email || "No email on file", fmtDate(s.today)].filter(Boolean).join(" · ");
const cols = [{ t: "Code" }, { t: "Garment" }, { t: "Size" }, { t: "Qty", r: true }, { t: "Unit", r: true }, { t: "Total", r: true }];
const stockRows = lines.filter((l) => l.qty > 0).map((l) => [l.code || "no code", l.name, l.size, l.qty, money(l.cost), money(l.qty * l.cost)]);
let body = `<h1><span class="sq"></span>${esc(supplier)}</h1><div class="meta">${esc(facts)}</div>`;
if (stockRows.length) body += `<h2>For stock${ref ? ` · ${esc(ref)}` : ""}</h2>` + tbl(cols, stockRows);
for (const o of drafts) {
const who = o.staffId ? staffName(staffById[o.staffId], "staff member") : "stock";
const dRef = draftRefs[o.id] ?? o.ref ?? "";
body += `<h2>${esc(o.code)} · for ${esc(who)}${dRef ? ` · ${esc(dRef)}` : ""}</h2>` + tbl(cols, o.lines.map((l) => { const it = byId[l.itemId]; const c = it?.cost || 0; return [supplierCodeOf(s, key(l.itemId, siOf(it, l.size))) || "no code", label(it), l.size, l.qty, money(c), money(l.qty * c)]; }));
}
body += `<div class="meta" style="margin-top:12px;text-align:right;font-weight:700">Total ${esc(money(total))}</div>`;
openPrintWindow(`${supplier} order`, body);
}
async function orderAndEmail() {
setBusy(true); setErr("");
const stockLines = lines.filter((l) => l.qty > 0).map((l) => ({ itemId: l.itemId, size: l.size, qty: l.qty }));
const groups = [
...(stockLines.length ? [{ kind: "stock", supplier, ref, lines: stockLines }] : []),
...drafts.map((d) => ({ kind: "draft", id: d.id, ref: draftRefs[d.id] ?? "" })),
];
const r = await mutate<{ raised: Raised[] }>("order.raiseList", { groups });
if (!r.ok) { setBusy(false); setErr(r.error); return; }
const mail: Record<string, string> = {};
if (email) {
for (const o of r.result.raised) {
const m = await mutate<{ sentTo: string }>("order.email", { id: o.id });
mail[o.id] = m.ok ? `Sent to ${m.result.sentTo}` : m.error;
}
}
setBusy(false);
setQty({}); setRemoved([]); setAdded([]); setRef(""); setDraftRefs({}); setPick(null);
onRaised(r.result.raised, mail);
}
const costOf = (itemId: string) => byId[itemId]?.cost || 0;
function csv(o: Raised) {
const rows = o.lines.map((l) => { const it = byId[l.itemId]; return [supplierCodeOf(s, key(l.itemId, siOf(it, l.size))) || 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 emailAgain(o: Raised) {
setMailMsg((m) => ({ ...m, [o.id]: "Sending…" }));
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 head = (aside: React.ReactNode) => (
<div className="tc-pp-head">
<span className="tc-pp-title" style={{ flexWrap: "wrap" }}>
<h3 id={hid} className="tc-pp-h">{supplier}</h3>
<span className="tc-pp-aside">{lead ? `lead ${lead} days · ` : ""}{email || "no email on file"}</span>
</span>
{aside}
</div>
);
if (raised) {
return (
<section className="tc-pp" aria-labelledby={hid}>
{head(<span className="tc-pp-aside tc-mono">{plural(raised.orders.length, "order")} raised</span>)}
<div>
{raised.orders.map((o) => {
const msg = mailMsg[o.id] ?? raised.mail[o.id];
return (
<div key={o.id} className="tc-orders-row">
<div className="tc-orders-rowmain">
<div className="tc-orders-rowtitle"><Link href={`/app/orders/${o.id}`}>{o.code}</Link> · {o.supplier}{o.ref ? ` · ${o.ref}` : ""}</div>
<div className="tc-orders-rowmeta">
<span className="tc-mono">{plural(o.lines.length, "line")} · {money(o.lines.reduce((t, l) => t + l.qty * costOf(l.itemId), 0))}</span>
</div>
<LiveRegion msg={msg} className="tc-orders-rowmeta" />
</div>
<div style={{ display: "flex", gap: 10, alignItems: "center", flexWrap: "wrap" }}>
<a className="btn btn-secondary" href={`/print/supplier-order?id=${o.id}`} target="_blank" rel="noreferrer" onClick={() => { void mutate("order.printed", { id: o.id }); }}>Print</a>
<button type="button" className="btn btn-ghost" onClick={() => csv(o)}>CSV</button>
<button type="button" className="btn btn-ghost" onClick={() => emailAgain(o)}>Email</button>
</div>
</div>
);
})}
</div>
<div className="tc-orders-foot"><button type="button" className="btn btn-ghost" onClick={onDone}>Done</button></div>
</section>
);
}
return (
<section className="tc-pp" aria-labelledby={hid}>
{head(<span className="tc-pp-aside tc-mono">{plural(nLines, "line")} · {money(total)}</span>)}
{lines.length > 0 && (
<div className="table-wrap">
<table className="tc-table" style={{ minWidth: 760 }}>
<thead>
<tr>
<th scope="col">Code</th><th scope="col">Garment</th>
<th scope="col" style={NUM_TH}>On hand</th><th scope="col" style={NUM_TH}>Reorder</th><th scope="col" style={NUM_TH}>On order</th><th scope="col" style={NUM_TH}>Per week</th>
<th scope="col" style={NUM_TH}>Order</th><th scope="col" style={NUM_TH}>Cost</th>
<th scope="col"><span className="sr-only">Remove</span></th>
</tr>
</thead>
<tbody>
{lines.map((l) => (
<tr key={l.key}>
{/* Codes and "no code" stay on one line, and the garment keeps enough width for a
long name and its size: squeezed, both broke over two or three lines. */}
<td className="tc-mono" style={{ fontSize: 12, color: "#57534f", whiteSpace: "nowrap" }}>{l.code || <Link href={`/app/stock/${l.itemId}`} style={{ color: "var(--color-accent-700)", fontWeight: 600 }}>no code</Link>}</td>
<td style={{ minWidth: 180 }}>
<div style={{ fontWeight: 600 }}>{l.name} · <span className="tc-mono" style={{ whiteSpace: "nowrap" }}>{l.size}</span></div>
{l.runsOut && <div style={{ fontSize: 12, fontWeight: 600, color: "var(--color-accent-700)" }}>runs out before this arrives</div>}
</td>
<td className="num" style={l.oh <= 0 ? { color: "var(--color-accent-700)", fontWeight: 600 } : undefined}>{l.oh}</td>
<td className="num">{l.ro}</td>
<td className="num">{l.onOrder}</td>
<td className="num">{l.perWeek === null ? "" : l.perWeek.toFixed(1)}</td>
<td className="num"><QtyStepper size="sm" min={0} value={l.qty} label={`${l.name} ${l.size}`} onChange={(n) => setQty((q) => ({ ...q, [l.key]: n }))} /></td>
<td className="num">{money(l.qty * l.cost)}</td>
<td style={{ padding: "9px 8px 9px 0" }}>
<button type="button" className="btn btn-ghost" style={{ minHeight: 26, padding: "0 4px" }} aria-label={`Remove ${l.name} ${l.size} from the list`} title="Remove from the list" onClick={() => removeLine(l)}>×</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{drafts.map((o) => {
const who = o.staffId ? staffName(staffById[o.staffId], "staff member") : "stock";
return (
<div key={o.id} className="tc-orders-draft">
<div className="tc-orders-drafthead">
<Tag tone={o.staffId ? "outline" : "quiet"}>{o.staffId ? "staff" : "draft"}</Tag>
<span>for {who} · <Link href={`/app/orders/${o.id}`} className="tc-mono">{o.code}</Link></span>
<span className="tc-mono" style={{ marginLeft: "auto", fontSize: 12, color: "#57534f" }}>{plural(o.lines.length, "line")}</span>
<input className="input tc-orders-ref" placeholder="Supplier order no." aria-label={`${o.code} supplier order number`} maxLength={120}
value={draftRefs[o.id] ?? o.ref ?? ""} onChange={(e) => { const v = e.target.value; setDraftRefs((m) => ({ ...m, [o.id]: v })); }} />
</div>
<div className="table-wrap">
<table className="tc-table" style={{ minWidth: 520 }}>
<thead className="sr-only"><tr><th scope="col">Code</th><th scope="col">Garment</th><th scope="col">Qty</th><th scope="col">Cost</th></tr></thead>
<tbody>
{o.lines.map((l) => {
const it = byId[l.itemId];
const code = supplierCodeOf(s, key(l.itemId, siOf(it, l.size)));
return (
<tr key={l.id}>
<td className="tc-mono" style={{ fontSize: 12, color: "#57534f", width: "18%" }}>{code || "no code"}</td>
<td><span style={{ fontWeight: 600 }}>{label(it)} · <span className="tc-mono">{l.size}</span></span></td>
<td className="num">{l.qty}</td>
<td className="num">{money(l.qty * (it?.cost || 0))}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
})}
{nLines === 0 && <div className="tc-orders-rowmeta" style={{ padding: "11px 16px" }}>Nothing to order.</div>}
{pick && (
<div className="tc-orders-add">
<Field label="Garment" style={{ minWidth: 220 }}>{(c) => (
<select {...c} className="input" value={pick.itemId} onChange={(e) => setPick({ ...pick, itemId: e.target.value, si: 0 })}>
<option value="">Choose</option>
{catalog.map((it) => <option key={it.id} value={it.id}>{label(it)}{it.supplier && it.supplier !== supplier ? ` · ${it.supplier}` : ""}</option>)}
</select>
)}</Field>
<Field label="Size">{(c) => (
<select {...c} className="input" value={pick.si} disabled={!pick.itemId} 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={0} inputMode="numeric" value={pick.qty} onChange={(e) => setPick({ ...pick, qty: Math.max(0, parseInt(e.target.value || "0", 10) || 0) })} />}</Field>
<button type="button" className="btn btn-secondary" disabled={!pick.itemId} onClick={addLine}>Add</button>
<button type="button" className="btn btn-ghost" onClick={() => setPick(null)}>Cancel</button>
</div>
)}
<div className="tc-orders-foot">
{lines.length > 0 && <input className="input tc-orders-ref" placeholder="Supplier order no." aria-label={`${supplier} stock order number`} maxLength={120} value={ref} onChange={(e) => setRef(e.target.value)} />}
{!pick && <button type="button" className="btn btn-ghost" onClick={() => setPick({ itemId: "", si: 0, qty: 1 })}>Add a line</button>}
<button type="button" className="btn btn-secondary" style={{ marginLeft: "auto" }} onClick={sheet} disabled={nLines === 0}><Icon name="print" size={16} />Sheet</button>
<button type="button" className="btn btn-primary" onClick={orderAndEmail} disabled={busy || !canRaise}>
{email && <Icon name="mail" size={16} />}{busy ? "Ordering…" : email ? "Order and email" : "Order"}
</button>
</div>
{err && <div style={{ padding: "0 16px 12px" }}><ErrorLine msg={err} /></div>}
</section>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { Panel } from "@/components/portal";
import { money, monthLabel, orderTotal } from "@/lib/compute";
import { plural } from "./bits";
/* Placed uses the Reports rule (dated this month, placed, not a back order); Received is by receipt month. */
export default function ThisMonth() {
const { s } = useSnap();
const { byId } = useDerived();
const month = s.today.slice(0, 7);
const f = useMemo(() => {
const placed = s.orders.filter((o) => o.date.slice(0, 7) === month && o.status !== "Draft" && o.status !== "Cancelled" && !o.parentId);
const received = s.orders.filter((o) => o.status === "Received" && (o.received || "").slice(0, 7) === month);
const sum = (xs: typeof placed) => xs.reduce((t, o) => t + orderTotal(o, byId), 0);
return { placed: placed.length, placedVal: sum(placed), received: received.length, receivedVal: sum(received) };
}, [s.orders, byId, month]);
return (
<Panel title="This month" aside={monthLabel(month, { month: "long" })}>
<div className="tc-orders-row"><span style={{ flex: 1 }}>Placed</span><span className="tc-mono">{plural(f.placed, "order")} · {money(f.placedVal)}</span></div>
<div className="tc-orders-row"><span style={{ flex: 1 }}>Received</span><span className="tc-mono">{plural(f.received, "order")} · {money(f.receivedVal)}</span></div>
<div className="tc-orders-row">
<span className="tc-orders-rowmeta" style={{ flex: 1, marginTop: 0 }}>All orders, drafts and history</span>
<Link href="/app/orders/all" className="btn btn-ghost" style={{ minHeight: 0 }}>Open the ledger</Link>
</div>
</Panel>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
/* The left column of /app/orders for admins: "To order", one panel per supplier. It replaces Order
* flagged, the Suggested order panel and the separate order list. */
import { useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { onOrderByKey, supplierMeta, toOrderGroups, type SupplierGroup } from "./toOrder";
import { SupplierPanel, type Raised } from "./SupplierPanel";
import { plural } from "./bits";
export default function ToOrder() {
const { s } = useSnap();
const { L, byId } = useDerived();
const groups = useMemo(() => toOrderGroups(s, L, byId), [s, L, byId]);
const oo = useMemo(() => onOrderByKey(s, byId), [s, byId]);
// Results stay on screen after raising even when the supplier no longer has anything to order.
const [raised, setRaised] = useState<Record<string, { orders: Raised[]; mail: Record<string, string> }>>({});
const panels: SupplierGroup[] = [...groups];
for (const sup of Object.keys(raised)) {
if (!panels.some((g) => g.supplier === sup)) panels.push({ supplier: sup, ...supplierMeta(s, sup), lines: [], drafts: [] });
}
panels.sort((a, b) => a.supplier.localeCompare(b.supplier));
const lineCount = groups.reduce((t, g) => t + g.lines.length + g.drafts.reduce((u, o) => u + o.lines.length, 0), 0);
const supplierCount = groups.filter((g) => g.lines.length + g.drafts.length > 0).length;
return (
<>
<div className="tc-orders-title">
<h2>To order</h2>
<span className="tc-mono" style={{ fontSize: 12, color: "#57534f" }}>{plural(lineCount, "line")} · {plural(supplierCount, "supplier")}</span>
</div>
{panels.length === 0 && <div className="tc-orders-rowmeta">Nothing to order.</div>}
{panels.map((g) => {
const full = groups.find((x) => x.supplier === g.supplier) || g;
return (
<SupplierPanel key={g.supplier} group={full} oo={oo} raised={raised[g.supplier]}
onRaised={(orders, mail) => setRaised((r) => ({ ...r, [g.supplier]: { orders, mail } }))}
onDone={() => setRaised((r) => { const n = { ...r }; delete n[g.supplier]; return n; })} />
);
})}
</>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
/* Small pieces the Orders screens share. */
import Link from "next/link";
import { Icon } from "@/components/portal";
import { NewOrderDialog } from "@/components/dialogs";
/* ` Parent / Current` directly under the page head (the staff-record pattern). */
export function Crumb({ href, parent, current }: { href: string; parent: string; current: string }) {
return (
<nav aria-label="Breadcrumb" className="tc-orders-crumb">
<Icon name="chevronLeft" size={16} />
<Link href={href}>{parent}</Link>
<span aria-hidden="true">/</span>
<span aria-current="page" className="tc-orders-crumb-here">{current}</span>
</nav>
);
}
/* NewOrderDialog with the S2 pre-fill props (initOrderFor / initStaffId / initSupplier). Typed here so
* this file compiles whether or not the shared dialog has picked the props up yet. */
type NewOrderProps = Parameters<typeof NewOrderDialog>[0] & { initOrderFor?: "Stock" | "Staff Member"; initStaffId?: string; initSupplier?: string };
export const NewOrder = NewOrderDialog as unknown as (p: NewOrderProps) => React.ReactElement;
export const plural = (n: number, one: string, many = one + "s") => `${n} ${n === 1 ? one : many}`;
/* "11 Sep": the short date the boards print. */
export function shortDate(iso: string): string {
if (!iso || iso.length < 10) return "";
const d = new Date(+iso.slice(0, 4), +iso.slice(5, 7) - 1, +iso.slice(8, 10));
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleDateString("en-AU", { day: "numeric", month: "short" });
}
/* Layout for the Orders screens. The page grid needs a media query for the phone order
* (On the way, To order, This month), which an inline style cannot carry. */
export function OrdersStyles() {
return null; // the rules are in app/globals.css under portal redesign
}
+77
View File
@@ -0,0 +1,77 @@
/* The To order list, pure. Membership comes only from atReorderVariants() (lib/portalcounts.ts) so
* the Orders badge, the Stock filter and this list agree; this file adds the per-line fields. */
import { atReorderVariants } from "@/lib/portalcounts";
import {
forecastFor, key, label, onOrderMap, onhand, reorderAt, supplierCodeOf, supplierInfo,
type Item, type Ledger, type OrderRec, type Snapshot,
} from "@/lib/compute";
export type ToOrderLine = {
key: string; itemId: string; si: number; size: string; name: string;
supplier: string; code: string; oh: number; ro: number; onOrder: number;
qty: number; perWeek: number | null; runsOut: boolean; cost: number;
};
export type SupplierGroup = {
supplier: string; lead: number | null; email: string;
lines: ToOrderLine[]; drafts: OrderRec[];
};
export const supplierOf = (s: Snapshot, it: Item | undefined) => it?.supplier || s.settings.suppliers[0] || "Supplier";
export function supplierMeta(s: Snapshot, name: string): { lead: number | null; email: string } {
const info = supplierInfo(s, name);
return { lead: info?.lead && info.lead > 0 ? info.lead : null, email: info?.email || "" };
}
/** Stock on every open order, drafts included. Drafts are raised from the same panel as the
* suggested lines, so a quantity already sitting in any draft (a replenishment draft too) must not
* be suggested a second time. */
export function onOrderByKey(s: Snapshot, byId: Record<string, Item>): Record<string, number> {
return onOrderMap(s, byId).byKey;
}
/** One size's figures, whether it came from the reorder rule or was added by hand. */
export function lineFor(s: Snapshot, L: Ledger, byId: Record<string, Item>, oo: Record<string, number>, itemId: string, si: number): ToOrderLine | null {
const it = byId[itemId];
if (!it || si < 0 || si >= it.sizes.length) return null;
const k = key(itemId, si);
const oh = onhand(s, L, k), ro = reorderAt(s, k);
const onOrder = oo[k] || 0;
const f = forecastFor(s, L, byId, k);
return {
key: k, itemId, si, size: String(it.sizes[si]), name: label(it), supplier: supplierOf(s, it),
code: supplierCodeOf(s, k), oh, ro, onOrder,
qty: Math.max(ro * 2 - oh - onOrder, 0),
perWeek: f.avgWeekly === null ? null : Math.round(f.avgWeekly * 10) / 10,
runsOut: f.runsOutBeforeDelivery || (oh <= 0 && f.avgWeekly !== null),
cost: it.cost || 0,
};
}
export const sortLines = (a: ToOrderLine, b: ToOrderLine) =>
Number(b.runsOut) - Number(a.runsOut) || a.name.localeCompare(b.name) || a.si - b.si;
/** Supplier panels AZ: every supplier with a size at reorder or a draft with lines. */
export function toOrderGroups(s: Snapshot, L: Ledger, byId: Record<string, Item>): SupplierGroup[] {
const oo = onOrderByKey(s, byId);
const by = new Map<string, SupplierGroup>();
const group = (name: string) => {
let g = by.get(name);
if (!g) { g = { supplier: name, ...supplierMeta(s, name), lines: [], drafts: [] }; by.set(name, g); }
return g;
};
for (const v of atReorderVariants(s, L)) {
const line = lineFor(s, L, byId, oo, v.itemId, v.si);
if (line) group(line.supplier).lines.push(line);
}
for (const o of s.orders) if (o.status === "Draft" && o.lines.length > 0) group(o.supplier || "No supplier").drafts.push(o);
for (const g of by.values()) { g.lines.sort(sortLines); g.drafts.sort((a, b) => a.code.localeCompare(b.code)); }
return [...by.values()].sort((a, b) => a.supplier.localeCompare(b.supplier));
}
/** A draft's value at catalogue cost (a draft has no receipts). */
export const draftValue = (o: OrderRec, byId: Record<string, Item>) => o.lines.reduce((t, l) => t + l.qty * (byId[l.itemId]?.cost || 0), 0);
/** The size index of an order line, or -1. */
export const siOf = (it: Item | undefined, size: string) => (it ? it.sizes.map(String).indexOf(size) : -1);