"use client"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import { useDerived, useSnap } from "@/lib/client"; import { Empty, Notice } from "@/components/ui"; import { AdjustDialog, BindDialog } from "@/components/dialogs"; import Camera from "@/components/Camera"; import { Icon, Seg, SelectButton, SizeStrip, Tag, type SizeCell } from "@/components/portal"; import { ALL_GROUPS, bcBound, bcFor, bcParse, csvOf, genderLabel, label, groupKey, inBucket, key, lastCountMap, onOrderMap, onhand, reorderAt, touched, type Item } from "@/lib/compute"; import { downloadCsv } from "@/lib/print"; import { setQuery, wholeMoney } from "./url"; export const STOCK_FILTERS = ["all", "reorder", "out", "onorder", "nobarcode"] as const; export type StockFilter = (typeof STOCK_FILTERS)[number]; const FILTER_LABELS: Record = { all: "All", reorder: "At reorder", out: "Out", onorder: "On order", nobarcode: "No barcode" }; type SortKey = "" | "name" | "value" | "onorder" | "onhand"; type SizeRow = { si: number; size: string; key: string; oh: number; ro: number; touched: boolean; barcode: string; bound: string; onOrd: number }; type Row = { it: Item; sizes: SizeRow[]; tot: number; val: number; onOrd: number; out: number; low: number; reorder: boolean; unbound: boolean }; export function asStockFilter(v: string | null | undefined): StockFilter { return (STOCK_FILTERS as readonly string[]).includes(v || "") ? (v as StockFilter) : "all"; } export default function OnHand({ init }: { init: { filter: StockFilter; q: string; group: string; supplier: string } }) { const router = useRouter(); const { s, isAdmin, mutate } = useSnap(); const { L, byId } = useDerived(); const [q, setQ] = useState(init.q); const [group, setGroup] = useState(init.group || ALL_GROUPS); const [supplier, setSupplier] = useState(init.supplier); const [filter, setFilter] = useState(init.filter); const [sortKey, setSortKey] = useState(""); const [sortDir, setSortDir] = useState(1); const [adjust, setAdjust] = useState<{ itemId: string; si: number } | null | false>(false); const [cam, setCam] = useState(false); const [bind, setBind] = useState(""); const [limit, setLimit] = useState(40); const [msg, setMsg] = useState(""); const [busy, setBusy] = useState(false); const [sel, setSel] = useState>({}); const [bulkRo, setBulkRo] = useState(""); const [bulkPrice, setBulkPrice] = useState(""); // The SCAN button on a phone lands here as ?scan=1: a camera lookup of one garment. useEffect(() => { if (new URLSearchParams(window.location.search).get("scan") === "1") { setCam(true); setQuery({ scan: null }); } }, []); const onOrder = useMemo(() => onOrderMap(s, byId), [s, byId]); const lastCount = useMemo(() => lastCountMap(s), [s]); const supplierOpts = useMemo(() => [{ value: "", label: "All suppliers" }, ...[...new Set(s.catalog.map((it) => it.supplier).filter(Boolean))].sort().map((x) => ({ value: x, label: x }))], [s.catalog]); const groupFilterOpts = [ALL_GROUPS, ...s.settings.staffGroups.filter((g) => g !== ALL_GROUPS)].map((g) => ({ value: g, label: g })); // Value walks the whole catalogue, discontinued lines included: they are still garments on a // shelf, and the CSV and Reports → Valuation count them too. const totals = useMemo(() => { let value = 0, ordered = 0; for (const it of s.catalog) it.sizes.forEach((_sz, si) => { const k = key(it.id, si); const oh = onhand(s, L, k); if (oh > 0) value += oh * it.cost; ordered += (onOrder.byKey[k] || 0) * it.cost; }); return { value, ordered }; }, [s, L, onOrder]); // Every garment the text, group and supplier filters let through; the segment then picks from it. const matched = useMemo(() => { const ql = q.trim().toLowerCase(); const out: Row[] = []; for (const it of s.catalog) { if (!inBucket(it, group)) continue; if (supplier && it.supplier !== supplier) continue; const sizes = it.sizes.map((sz, si) => { const k = key(it.id, si); return { si, size: String(sz), key: k, oh: onhand(s, L, k), ro: reorderAt(s, k), touched: touched(s, L, k), barcode: bcFor(s, it, si), bound: bcBound(s, it, si), onOrd: onOrder.byKey[k] || 0 }; }); if (ql && !(label(it).toLowerCase().includes(ql) || it.sku.toLowerCase().includes(ql) || sizes.some((v) => v.barcode.includes(ql) || v.size.toLowerCase() === ql))) continue; const out0 = sizes.filter((v) => v.touched && v.oh <= 0).length; const low = sizes.filter((v) => v.touched && v.oh > 0 && v.oh <= v.ro).length; out.push({ it, sizes, out: out0, low, tot: sizes.reduce((t, v) => t + v.oh, 0), val: sizes.reduce((t, v) => t + Math.max(0, v.oh) * it.cost, 0), onOrd: sizes.reduce((t, v) => t + v.onOrd, 0), reorder: sizes.some((v) => v.touched && v.oh <= v.ro), unbound: sizes.some((v) => !v.bound), }); } return out; }, [s, L, q, group, supplier, onOrder]); const test: Record boolean> = { all: () => true, reorder: (r) => !r.it.archived && r.reorder, out: (r) => !r.it.archived && r.out > 0, onorder: (r) => !r.it.archived && r.onOrd > 0, nobarcode: (r) => !r.it.archived && r.unbound, }; const counts = Object.fromEntries(STOCK_FILTERS.map((f) => [f, matched.filter(test[f]).length])) as Record; const rows = useMemo(() => { const list = matched.filter(test[filter]); if (sortKey) list.sort((a, b) => (sortKey === "name" ? label(a.it).localeCompare(label(b.it)) : sortKey === "onhand" ? a.tot - b.tot : sortKey === "value" ? a.val - b.val : a.onOrd - b.onOrd) * sortDir); return list; // eslint-disable-next-line react-hooks/exhaustive-deps }, [matched, filter, sortKey, sortDir]); const shown = rows.slice(0, limit); function changeFilter(f: StockFilter) { setFilter(f); setMsg(""); setQuery({ filter: f === "all" ? null : f }); } function changeQ(v: string) { setQ(v); setQuery({ q: v.trim() ? v : null }); } function changeGroup(v: string) { setGroup(v); setQuery({ group: v === ALL_GROUPS ? null : v }); } function changeSupplier(v: string) { setSupplier(v); setQuery({ supplier: v || null }); } function sortTh(k: Exclude, t: string, num = false) { const on = sortKey === k; return ( 0 ? "ascending" : "descending") : "none"}> ); } function camHit(raw: string) { const p = bcParse(s, raw); setCam(false); if (!p) { if (isAdmin) setBind(raw.trim()); else setMsg(`No garment carries ${raw.trim()}.`); return; } router.push(`/app/stock/${encodeURIComponent(p.itemId)}?size=${p.si}`); } function exportCsv() { const out: (string | number)[][] = []; for (const it of s.catalog) it.sizes.forEach((sz, si) => { const k = key(it.id, si); const oh = onhand(s, L, k); out.push([it.item, genderLabel(it.gender), it.sku, it.supplier, String(sz), bcBound(s, it, si), oh, reorderAt(s, k), onOrder.byKey[k] || 0, lastCount[k] || "", it.cost, (Math.max(0, oh) * it.cost).toFixed(2)]); }); downloadCsv(`threadcount-stock-${s.today}.csv`, csvOf(["Item", "Gender", "SKU", "Supplier", "Size", "Barcode", "On hand", "Reorder at", "On order", "Last counted", "Unit cost", "Value"], out)); } const selIds = Object.keys(sel).filter((id) => sel[id] && byId[id]); const shownIds = shown.map((x) => x.it.id); const allSel = shownIds.length > 0 && shownIds.every((id) => sel[id]); const selectAll = () => setSel((m) => { const n = { ...m }; for (const id of shownIds) n[id] = !allSel; return n; }); const groupOpts = [ALL_GROUPS, ...s.settings.staffGroups.filter((g) => groupKey(g) !== "all" && groupKey(g) !== groupKey(ALL_GROUPS))]; const bp = bulkPrice.trim(); const priceOk = /^[+-]\d+(\.\d+)?%$/.test(bp) || /^\$?\d+(\.\d+)?$/.test(bp); async function bulk(action: string, value?: string, extra?: Record) { if (busy) return; setBusy(true); try { const r = await mutate<{ message: string }>("catalog.bulk", { ids: selIds, action, value, ...extra }); setMsg(r.ok ? r.result.message : r.error); if (r.ok) { setSel({}); setBulkRo(""); setBulkPrice(""); } } finally { setBusy(false); } } const cellsOf = (r: Row): SizeCell[] => r.sizes.map((v) => ({ si: v.si, size: v.size, count: v.oh, state: v.touched && v.oh <= 0 ? "out" : v.touched && v.oh <= v.ro ? "low" : !v.touched && v.oh === 0 ? "none" : "ok", })); const statusOf = (r: Row) => r.out > 0 ? {r.out} out : r.low > 0 ? {r.low} low : r.unbound ? no barcode : null; return (
On hand {wholeMoney(totals.value)} · on order {wholeMoney(totals.ordered)}
{isAdmin && selIds.length > 0 && (
{selIds.length} selected
)}
{s.catalog.length === 0 ? (
No garments yet.{isAdmin && <> Import them}
) : rows.length === 0 ? (
No garments match.
) : (
{isAdmin && } {sortTh("name", "Garment")} {sortTh("onhand", "On hand", true)} {sortTh("value", "Value", true)} {sortTh("onorder", "On order", true)} {shown.map((r) => { const status = statusOf(r); return ( {isAdmin && } ); })}
Sizes · on handStatus
setSel((m) => ({ ...m, [r.it.id]: !m[r.it.id] }))} /> {label(r.it)} {r.it.archived && <> Discontinued}
{r.it.sku || "—"} · {r.it.supplier || "No supplier"}
setAdjust({ itemId: r.it.id, si })} /> {r.tot} {wholeMoney(r.val)} {r.onOrd > 0 ? r.onOrd : "–"} {status} On hand {r.tot} · {wholeMoney(r.val)} · on order {r.onOrd} {status}
)}
Showing {shown.length} of {rows.length} {rows.length > limit && }
{adjust !== false && setAdjust(false)} />} {cam && setCam(false)} />} {bind && setBind("")} onBound={(itemId, si) => router.push(`/app/stock/${encodeURIComponent(itemId)}?size=${si}`)} />}
); }