"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; raised?: { orders: Raised[]; mail: Record }; onRaised: (orders: Raised[], mail: Record) => void; onDone: () => void; }) { const { s, mutate } = useSnap(); const { L, byId, staffById } = useDerived(); const hid = useId(); const [qty, setQty] = useState>({}); const [removed, setRemoved] = useState([]); 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>({}); 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>({}); 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 = `

${esc(supplier)}

${esc(facts)}
`; if (stockRows.length) body += `

For stock${ref ? ` · ${esc(ref)}` : ""}

` + 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 += `

${esc(o.code)} · for ${esc(who)}${dRef ? ` · ${esc(dRef)}` : ""}

` + 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 += `
Total ${esc(money(total))}
`; 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 = {}; 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) => (

{supplier}

{lead ? `lead ${lead} days · ` : ""}{email || "no email on file"}
{aside}
); if (raised) { return (
{head({plural(raised.orders.length, "order")} raised)}
{raised.orders.map((o) => { const msg = mailMsg[o.id] ?? raised.mail[o.id]; return (
{o.code} · {o.supplier}{o.ref ? ` · ${o.ref}` : ""}
{plural(o.lines.length, "line")} · {money(o.lines.reduce((t, l) => t + l.qty * costOf(l.itemId), 0))}
); })}
); } return (
{head({plural(nLines, "line")} · {money(total)})} {lines.length > 0 && (
{lines.map((l) => ( {/* 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. */} ))}
CodeGarment On handReorderOn orderPer week OrderCost Remove
{l.code || no code}
{l.name} · {l.size}
{l.runsOut &&
runs out before this arrives
}
{l.oh} {l.ro} {l.onOrder} {l.perWeek === null ? "–" : l.perWeek.toFixed(1)} setQty((q) => ({ ...q, [l.key]: n }))} /> {money(l.qty * l.cost)}
)} {drafts.map((o) => { const who = o.staffId ? staffName(staffById[o.staffId], "staff member") : "stock"; return (
{o.staffId ? "staff" : "draft"} for {who} · {o.code} {plural(o.lines.length, "line")} { const v = e.target.value; setDraftRefs((m) => ({ ...m, [o.id]: v })); }} />
{o.lines.map((l) => { const it = byId[l.itemId]; const code = supplierCodeOf(s, key(l.itemId, siOf(it, l.size))); return ( ); })}
CodeGarmentQtyCost
{code || "no code"} {label(it)} · {l.size} {l.qty} {money(l.qty * (it?.cost || 0))}
); })} {nLines === 0 &&
Nothing to order.
} {pick && (
{(c) => ( )} {(c) => ( )} {(c) => setPick({ ...pick, qty: Math.max(0, parseInt(e.target.value || "0", 10) || 0) })} />}
)}
{lines.length > 0 && setRef(e.target.value)} />} {!pick && }
{err &&
}
); }