"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 | null>(null); const [refs, setRefs] = useState>({}); const [busy, setBusy] = useState(false); const [err, setErr] = useState(""); const [raised, setRaised] = useState(null); const [mailMsg, setMailMsg] = useState>({}); 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 = useMemo(() => { if (edit) return edit; const g: Record = {}; 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 | 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 Admins only — ordering is an admin task.; if (raised) { return ( <>
{raised.map((o) => (
{o.code} · {o.supplier}{o.ref ? ` · ${o.ref}` : ""}
{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]}` : ""}
Print
))}
All orders
); } return ( <> {nGroups === 0 && Nothing to order — every size is above its reorder level and there are no drafts.} {stockGroups.map(([sup, ls]) => { const info = supplierInfo(s, sup); const sub = ls.reduce((t, l) => t + l.qty * costOf(l.itemId), 0); return (
{sup} · stock {info?.account ? `account ${info.account} · ` : ""}{ls.length} line{ls.length === 1 ? "" : "s"} · {money(sub)}
Code
Garment
Size
On hand
Reorder
On order
Order qty
{ls.map((l, i) => (
{l.code || no code}
{label(byId[l.itemId])}{l.flag && runs out}
{l.size}
{l.oh}
{l.ro}
{l.onOrder}
setLine(sup, i, { qty: Math.max(0, parseInt(e.target.value || "0", 10) || 0) })} />
))}
{(c) => setRefs({ ...refs, ["s:" + sup]: e.target.value })} />}
); })} {drafts.map((o) => (
{o.supplier || "No supplier"} · {o.staffId ? `for ${staffName(staffById[o.staffId])}${o.cc ? ` · ${o.cc}` : ""}` : "draft"} · {o.code} {o.lines.length} line{o.lines.length === 1 ? "" : "s"} · {money(o.lines.reduce((t, l) => t + l.qty * costOf(l.itemId), 0))}
{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 (
{code || "no code"} · {label(it)} · {l.size}
{l.qty}
); })}
{(c) => setRefs({ ...refs, ["d:" + o.id]: e.target.value })} />}
))} {pick && (
Add a line · {pick.sup}
{(c) => } {(c) => } {(c) => setPick({ ...pick, qty: Math.max(1, parseInt(e.target.value || "1", 10) || 1) })} />}
)} {err && } {nGroups > 0 && (
Back
)} ); }