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 a113353 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; })} />
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 A–Z: 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);
|
||||
Reference in New Issue
Block a user