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 38e16eb on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { Empty, Notice } from "@/components/ui";
|
||||
import { BindDialog } from "@/components/dialogs";
|
||||
import Camera from "@/components/Camera";
|
||||
import { Bar, Panel, Seg, SelectButton } from "@/components/portal";
|
||||
import { ALL_GROUPS, bcBound, bcFor, bcParse, csvEsc, csvOf, fmtDate, formatInZone, inBucket, key, label, locMap, locPath, locSubtree, locTree, money, onhand, plOf, signedInt, signedMoney } from "@/lib/compute";
|
||||
import { downloadCsv, esc, openPrintWindow } from "@/lib/print";
|
||||
import { setQuery } from "./url";
|
||||
|
||||
/* An in-progress count belongs to the person doing it, not to the browser: the key is scoped to the
|
||||
user id, and the saved-at stamp says how old a restored tally is. */
|
||||
const countsKey = (userId: string) => `threadcount-counts:${userId}`;
|
||||
type Saved = { counts: Record<string, string>; savedAt: string };
|
||||
const VIEWS = ["All", "Uncounted"] as const;
|
||||
const MODES = ["Normal", "Blind"] as const;
|
||||
const POOLS = ["Shelf", "Pre-loved"] as const;
|
||||
/* The same four the phone offers on its variance screen, so shrinkage reports stay in one wording. */
|
||||
const REASONS = ["At laundry", "Condemned", "Missing", "Other"];
|
||||
|
||||
export default function CountTab({ initLocation }: { initLocation: string }) {
|
||||
const { s, mutate } = useSnap();
|
||||
const { L, byId, variants } = useDerived();
|
||||
const [counts, setCountsRaw] = useState<Record<string, string>>({});
|
||||
const countsRef = useRef(counts);
|
||||
countsRef.current = counts;
|
||||
const [reason, setReason] = useState<Record<string, string>>({});
|
||||
const [savedAt, setSavedAt] = useState("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [scan, setScan] = useState("");
|
||||
const [msg, setMsg] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const [group, setGroup] = useState(ALL_GROUPS);
|
||||
const [loc, setLoc] = useState(s.locations.some((l) => l.id === initLocation) ? initLocation : "");
|
||||
const [view, setView] = useState<(typeof VIEWS)[number]>("All");
|
||||
const [mode, setMode] = useState<(typeof MODES)[number]>("Normal");
|
||||
const [pool, setPool] = useState<(typeof POOLS)[number]>("Shelf");
|
||||
const [cam, setCam] = useState(false);
|
||||
const [camMsg, setCamMsg] = useState("");
|
||||
const [bind, setBind] = useState("");
|
||||
const [expand, setExpand] = useState<string | null>(null);
|
||||
const [limit, setLimit] = useState(60);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const blind = mode === "Blind";
|
||||
const plMode = pool === "Pre-loved";
|
||||
// Pre-loved counts live under a "pl:" prefix so a shelf take and a pool take can run together.
|
||||
const kOf = (k: string) => (plMode ? "pl:" + k : k);
|
||||
const sysOf = (k: string) => (plMode ? plOf(s, k) : onhand(s, L, k));
|
||||
|
||||
const KEY = countsKey(s.session.userId);
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(KEY) || "{}") as Partial<Saved>;
|
||||
if (raw && typeof raw.counts === "object" && raw.counts) { setCountsRaw(raw.counts); setSavedAt(typeof raw.savedAt === "string" ? raw.savedAt : ""); }
|
||||
} catch { /* ignore */ }
|
||||
setLoaded(true);
|
||||
}, [KEY]);
|
||||
const setCounts = (c: Record<string, string>) => {
|
||||
countsRef.current = c;
|
||||
setCountsRaw(c);
|
||||
const at = new Date().toISOString();
|
||||
setSavedAt(at);
|
||||
try { localStorage.setItem(KEY, JSON.stringify({ counts: c, savedAt: at } satisfies Saved)); } catch { /* ignore */ }
|
||||
};
|
||||
|
||||
// A location scopes the count to the sizes placed at it or anywhere under it; the server refuses
|
||||
// a line from anywhere else, so lines outside the scope are never counted or sent.
|
||||
const scopeLocs = useMemo(() => (loc ? locSubtree(s, loc) : null), [s, loc]);
|
||||
const inLoc = (k: string) => !scopeLocs || scopeLocs.has(s.placed[k] || "");
|
||||
const poolVariants = useMemo(() => variants.filter((v) => !scopeLocs || scopeLocs.has(s.placed[v.key] || "")), [variants, scopeLocs, s.placed]);
|
||||
|
||||
const has = (k: string, from = counts) => from[k] !== undefined && from[k] !== "";
|
||||
function countPlus(itemId: string, si: number) {
|
||||
const it = byId[itemId];
|
||||
const name = `${label(it)} ${it?.sizes[si] ?? ""}`;
|
||||
if (!inLoc(key(itemId, si))) return `${name} isn't placed under this location — not counted.`;
|
||||
const cur = countsRef.current;
|
||||
const k = kOf(key(itemId, si));
|
||||
const n = (parseInt(cur[k] || "0", 10) || 0) + 1;
|
||||
setCounts({ ...cur, [k]: String(n) });
|
||||
return `${name} → ${n}`;
|
||||
}
|
||||
function handleScan(raw: string) {
|
||||
const p = bcParse(s, raw);
|
||||
setScan("");
|
||||
if (!p) { setBind(raw.trim()); return; }
|
||||
setMsg(countPlus(p.itemId, p.si));
|
||||
}
|
||||
function camHit(raw: string) {
|
||||
const p = bcParse(s, raw);
|
||||
if (!p) { setCam(false); setBind(raw.trim()); return; }
|
||||
setCamMsg(countPlus(p.itemId, p.si));
|
||||
}
|
||||
// The phone SCAN button opens the camera; the desk scanner, typed outside any field, arrives as a
|
||||
// garment the shell has already resolved.
|
||||
const hitRef = useRef(countPlus);
|
||||
hitRef.current = countPlus;
|
||||
useEffect(() => {
|
||||
const onCam = () => { setCamMsg(""); setCam(true); };
|
||||
const onGarment = (e: Event) => { const d = (e as CustomEvent<{ itemId: string; si: number }>).detail; if (d) setMsg(hitRef.current(d.itemId, d.si)); };
|
||||
window.addEventListener("tc-scan", onCam);
|
||||
window.addEventListener("tc-scan-garment", onGarment);
|
||||
return () => { window.removeEventListener("tc-scan", onCam); window.removeEventListener("tc-scan-garment", onGarment); };
|
||||
}, []);
|
||||
|
||||
const tq = q.trim().toLowerCase();
|
||||
const scopeAll = useMemo(() => poolVariants.filter((v) => inBucket(v.item, group)), [poolVariants, group]);
|
||||
const match = useMemo(() => scopeAll.filter((v) => (view !== "Uncounted" || !has(kOf(v.key))) && (!tq || v.item.item.toLowerCase().includes(tq) || v.item.sku.toLowerCase().includes(tq) || v.size.toLowerCase() === tq || bcFor(s, v.item, v.si).includes(tq))),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[scopeAll, view, tq, counts, s, plMode]);
|
||||
// A gap this big or bigger has to say why; the server refuses the whole count otherwise.
|
||||
const gate = Math.max(1, s.settings.varianceReason);
|
||||
let counted = 0, variances = 0, netVal = 0;
|
||||
const bigGaps: string[] = [];
|
||||
for (const v of poolVariants) if (has(kOf(v.key))) { counted++; const diff = (parseInt(counts[kOf(v.key)], 10) || 0) - sysOf(v.key); if (diff !== 0) { variances++; netVal += diff * (plMode ? 0 : v.item.cost); if (Math.abs(diff) >= gate) bigGaps.push(kOf(v.key)); } }
|
||||
const needsReason = bigGaps.filter((k) => !reason[k]);
|
||||
const scopeCounted = scopeAll.filter((v) => has(kOf(v.key))).length;
|
||||
const pct = Math.round((scopeCounted / Math.max(scopeAll.length, 1)) * 100);
|
||||
// A line still owing a reason stays reachable whatever the filter or the row limit hides.
|
||||
const needSet = new Set(needsReason);
|
||||
const forced = needSet.size ? poolVariants.filter((v) => needSet.has(kOf(v.key)) && !match.includes(v)) : [];
|
||||
const rows = [...forced, ...[...match].sort((a, b) => (has(kOf(b.key)) ? 1 : 0) - (has(kOf(a.key)) ? 1 : 0))];
|
||||
|
||||
async function apply() {
|
||||
if (counted === 0 || busy) return;
|
||||
if (needsReason.length) { setMsg(`A gap of ${gate} or more needs a reason — ${needsReason.length} line${needsReason.length === 1 ? "" : "s"} still to go.`); return; }
|
||||
setBusy(true);
|
||||
const sent = poolVariants.filter((v) => has(kOf(v.key)));
|
||||
const lines = sent.map((v) => ({ itemId: v.itemId, si: v.si, counted: parseInt(counts[kOf(v.key)], 10) || 0, reason: reason[kOf(v.key)] || "" }));
|
||||
const r = await mutate("stocktake.apply", { lines, mode: plMode ? "preloved" : "shelf", ...(loc ? { locationId: loc } : {}) });
|
||||
setBusy(false);
|
||||
if (!r.ok) { setMsg(r.error); return; }
|
||||
const done = new Set(sent.map((v) => kOf(v.key)));
|
||||
const kept: Record<string, string> = {};
|
||||
for (const k in counts) {
|
||||
if (done.has(k)) continue;
|
||||
// A whole-room count clears the pool's tally, as it always has; a location count keeps the rest.
|
||||
if (!loc && (plMode ? k.startsWith("pl:") : !k.startsWith("pl:"))) continue;
|
||||
kept[k] = counts[k];
|
||||
}
|
||||
const keptReasons: Record<string, string> = {}; for (const k in reason) if (kept[k]) keptReasons[k] = reason[k];
|
||||
setCounts(kept); setReason(keptReasons);
|
||||
setMsg(variances ? (plMode ? "Pre-loved pool updated and filed." : "Adjustments applied and filed.") : "Count filed — everything matched.");
|
||||
}
|
||||
function zeroFill() {
|
||||
const c = { ...counts }; let n = 0;
|
||||
for (const v of match) if (!has(kOf(v.key))) { c[kOf(v.key)] = "0"; n++; }
|
||||
setCounts(c); setMsg(n ? `${n} uncounted line${n === 1 ? "" : "s"} set to zero.` : "Everything in scope is already counted.");
|
||||
}
|
||||
const lById = useMemo(() => locMap(s), [s]);
|
||||
const trail = (id: string | null | undefined) => locPath(lById, id).map((l) => l.name).join(" · ");
|
||||
function printCountSheet() {
|
||||
const byItem: Record<string, typeof match> = {};
|
||||
for (const v of match) (byItem[v.itemId] = byItem[v.itemId] || []).push(v);
|
||||
let rowsHtml = "";
|
||||
for (const itemId in byItem) {
|
||||
const it = byId[itemId];
|
||||
rowsHtml += `<tr class="ih"><td colspan="4">${esc(label(it))}${it?.sku ? " · " + esc(it.sku) : ""}</td></tr>`;
|
||||
// Only the real supplier code goes on paper; a generated id isn't on the garment.
|
||||
for (const v of byItem[itemId]) rowsHtml += `<tr><td>${esc(v.size)}</td><td>${esc(bcBound(s, v.item, v.si))}</td><td class="r">${blind ? "" : sysOf(v.key)}</td><td class="box"></td></tr>`;
|
||||
}
|
||||
openPrintWindow("Count sheet", `<h1>ThreadCount — ${plMode ? "Pre-loved pool" : "Stocktake"} count sheet</h1><div class="meta">${esc(s.settings.facility)} · Scope: ${esc(group)}${loc ? " · " + esc(trail(loc)) : ""}${tq ? " · filter “" + esc(q) + "”" : ""} · ${match.length} lines · Printed ${esc(fmtDate(s.today))} · Counted by ____________ ${blind ? "· BLIND COUNT" : ""}</div><table><tr><th>Size</th><th>Barcode</th><th class="r">${blind ? "" : "System"}</th><th>Counted</th></tr>${rowsHtml}</table>`, { width: 780, height: 920 });
|
||||
}
|
||||
function historyCsv(h: (typeof s.stocktakes)[number]) {
|
||||
downloadCsv(`threadcount-stocktake-${h.date}.csv`, `Stocktake ${h.date} by ${csvEsc(h.by)}${h.mode === "preloved" ? " · pre-loved pool" : ""}${h.locationId ? " · " + csvEsc(trail(h.locationId)) : ""}\n` + csvOf(["Item", "Size", "System", "Counted", "Variance", "Unit cost", "Variance value"], h.lines.filter((l) => l.counted !== l.sys).map((l) => { const it = byId[l.itemId]; const diff = l.counted - l.sys; return [label(it), it ? String(it.sizes[l.si]) : "?", l.sys, l.counted, diff, it ? it.cost : "", it ? (diff * it.cost).toFixed(2) : ""]; })));
|
||||
}
|
||||
|
||||
const locOpts = [{ value: "", label: "All" }, ...locTree(s).map(({ loc: l }) => ({ value: l.id, label: trail(l.id) }))];
|
||||
const groupOpts = [ALL_GROUPS, ...s.settings.staffGroups.filter((g) => g !== ALL_GROUPS)].map((g) => ({ value: g, label: g }));
|
||||
const fileLabel = variances === 0 ? "File count" : "Apply adjustments";
|
||||
|
||||
return (
|
||||
<div className="tc-stk">
|
||||
<div className="tc-stk-row">
|
||||
<input className="input" style={{ width: 280, maxWidth: "100%", background: "#fff" }} aria-label="Scan a barcode to add one to its count" placeholder="Scan to count +1" value={scan} onChange={(e) => setScan(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && scan.trim()) handleScan(scan); }} autoFocus />
|
||||
<button type="button" className="btn btn-ghost" onClick={() => { setCamMsg(""); setCam(true); }}>Camera</button>
|
||||
<input className="input" style={{ width: 200, maxWidth: "100%", background: "#fff" }} type="search" aria-label="Filter the lines to count" placeholder="Filter garments" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<SelectButton label="Group" value={group} anyValue={ALL_GROUPS} options={groupOpts} onChange={setGroup} />
|
||||
<SelectButton label="Location" value={loc} anyValue="" options={locOpts} onChange={(v) => { setLoc(v); setQuery({ location: v || null }); }} />
|
||||
<Seg label="Pool" opts={POOLS} value={pool} onChange={setPool} />
|
||||
<Seg label="Show" opts={VIEWS} value={view} onChange={setView} />
|
||||
<Seg label="Mode" opts={MODES} value={mode} onChange={setMode} />
|
||||
</div>
|
||||
<div className="tc-stk-row">
|
||||
<div style={{ flex: 1, minWidth: 160 }}><Bar value={scopeCounted} max={scopeAll.length} label={`${pct}% counted`} /></div>
|
||||
<span className="tc-stk-meta tc-mono">{pct}% counted · {scopeCounted} of {scopeAll.length}</span>
|
||||
<span className="tc-stk-row" style={{ gap: 16, marginLeft: "auto" }}>
|
||||
<button type="button" className="btn btn-ghost" onClick={zeroFill}>Zero uncounted</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={printCountSheet}>Print count sheet</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => { setCounts({}); setReason({}); setMsg(""); }}>Clear counts</button>
|
||||
<button type="button" className="btn btn-primary" onClick={apply} disabled={counted === 0 || busy || !loaded || needsReason.length > 0}
|
||||
title={needsReason.length ? `${needsReason.length} large gap${needsReason.length === 1 ? "" : "s"} still need a reason` : undefined}>{fileLabel}</button>
|
||||
</span>
|
||||
</div>
|
||||
{counted > 0 && (
|
||||
<div className="tc-stk-meta">
|
||||
{needsReason.length > 0 && <span className="tc-mark" aria-hidden="true" />}
|
||||
{!blind && <><span className="tc-mono">{variances}</span> variance{variances === 1 ? "" : "s"} · <span className="tc-mono">{signedMoney(netVal)}</span> · </>}
|
||||
<span className="tc-mono">{needsReason.length}</span> need a reason
|
||||
</div>
|
||||
)}
|
||||
{loaded && counted > 0 && savedAt && <div className="tc-stk-meta">Saved tally · last entry <span className="tc-mono">{formatInZone(savedAt, s.tz, { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" })}</span></div>}
|
||||
<Notice msg={msg} />
|
||||
|
||||
<Panel title={`${plMode ? "Pre-loved pool" : "Shelf"} — lines to count`} aside={<span className="tc-mono">{Math.min(limit, rows.length)} of {scopeAll.length}{blind ? " · blind" : ""}</span>}
|
||||
foot={rows.length > limit ? <button type="button" className="btn btn-ghost" onClick={() => setLimit(100000)}>Show all</button> : undefined}>
|
||||
{variants.length === 0 ? <div className="tc-stk-pad"><Empty pad={2}>No garments to count yet.</Empty></div>
|
||||
: rows.length === 0 ? <div className="tc-stk-pad"><Empty pad={2}>{loc && poolVariants.length === 0 ? "Nothing is placed under this location." : "No lines match."}</Empty></div> : (
|
||||
<div className="table-wrap">
|
||||
<table className="tc-table">
|
||||
<thead>
|
||||
<tr><th>Garment</th><th>Size</th><th className="num">{blind ? <span className="sr-only">System</span> : "System"}</th><th className="num">Counted</th><th className="num">{blind ? "Counted?" : "Variance"}</th><th>Reason</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.slice(0, limit).map((v) => {
|
||||
const ck = kOf(v.key); const sys = sysOf(v.key); const h = has(ck); const varr = h ? (parseInt(counts[ck], 10) || 0) - sys : 0;
|
||||
const big = h && Math.abs(varr) >= gate;
|
||||
const name = `${label(v.item)} size ${v.size}`;
|
||||
return (
|
||||
<tr key={v.key}>
|
||||
<td style={{ fontWeight: 600 }}>{label(v.item)}</td>
|
||||
<td className="tc-mono">{v.size}</td>
|
||||
<td className="num">{blind ? "" : sys}</td>
|
||||
<td className="num"><input className="input tc-stk-cnt" inputMode="numeric" aria-label={`Counted — ${name}`} value={h ? counts[ck] : ""} onChange={(e) => setCounts({ ...counts, [ck]: e.target.value.replace(/[^0-9]/g, "") })} /></td>
|
||||
<td className="num" style={{ fontWeight: 600, color: !blind && h && varr !== 0 ? "var(--color-accent-700)" : undefined }}>{blind ? (h ? "✓" : "") : h ? signedInt(varr) : "—"}</td>
|
||||
{/* Only big gaps get the chooser, blind or not: the line can't be filed without one. */}
|
||||
<td>{big && (<>
|
||||
{!reason[ck] && <span className="tc-mark" aria-hidden="true" />}
|
||||
<select className="input tc-stk-tight" style={{ borderColor: reason[ck] ? undefined : "var(--color-accent-600)" }} aria-label={`Reason for the gap on ${name}`} value={reason[ck] || ""} onChange={(e) => setReason({ ...reason, [ck]: e.target.value })}>
|
||||
<option value="">Needs a reason…</option>
|
||||
{REASONS.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||
</select>
|
||||
</>)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Stocktake history" aside={s.stocktakes.length > 0 ? <span className="tc-mono">{s.stocktakes.length} filed</span> : undefined}>
|
||||
{s.stocktakes.length === 0 && <div className="tc-stk-pad"><Empty pad={2}>No stocktakes filed yet.</Empty></div>}
|
||||
{s.stocktakes.map((h) => {
|
||||
const net = h.lines.reduce((t, l) => t + (l.counted - l.sys), 0);
|
||||
const nv = h.mode === "preloved" ? 0 : h.lines.reduce((t, l) => t + (l.counted - l.sys) * (byId[l.itemId]?.cost || 0), 0);
|
||||
const open = expand === h.id;
|
||||
const where = h.locationId ? trail(h.locationId) : "";
|
||||
return (
|
||||
<div key={h.id} className="tc-stk-hist">
|
||||
<div className="tc-stk-histrow">
|
||||
<span className="tc-mono" style={{ width: 70, flex: "none" }}>{fmtDate(h.date).replace(/\s\d{4}$/, "")}</span>
|
||||
<span className="tc-stk-histmain">
|
||||
{h.by}{h.mode === "preloved" ? " · pre-loved pool" : ""}{where ? ` · ${where}` : ""}
|
||||
<span className="tc-stk-meta"> · <span className="tc-mono">{h.counted}</span> lines · <span className="tc-mono">{h.variances}</span> variances · net <span className="tc-mono">{signedInt(net)} ({signedMoney(nv)})</span></span>
|
||||
</span>
|
||||
<button type="button" className="btn btn-ghost" aria-label={`Download the ${fmtDate(h.date)} count as CSV`} onClick={() => historyCsv(h)}>CSV</button>
|
||||
<button type="button" className="btn btn-ghost" aria-expanded={open} aria-label={`${open ? "Hide" : "Show"} the variances from the ${fmtDate(h.date)} count`} onClick={() => setExpand(open ? null : h.id)}>{open ? "Hide" : "Variances"}</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div style={{ padding: "0 16px 12px" }}>
|
||||
{h.variances === 0 && <Empty pad={2}>No variances.</Empty>}
|
||||
{h.lines.filter((l) => l.counted !== l.sys).map((l, i) => { const diff = l.counted - l.sys; return (
|
||||
<Fragment key={i}>
|
||||
<div className="tc-stk-row" style={{ justifyContent: "space-between", padding: "4px 0", fontSize: 13, borderTop: i ? "1px solid #e4e2e1" : undefined }}>
|
||||
<span>{label(byId[l.itemId])} · <span className="tc-mono">{byId[l.itemId]?.sizes[l.si] ?? "?"}</span>{l.reason ? <span className="tc-stk-meta"> · {l.reason}</span> : null}</span>
|
||||
<span className="tc-mono">{l.sys} → {l.counted} · <b style={{ color: "var(--color-accent-700)" }}>{signedInt(diff)}</b> ({money(Math.abs(diff) * (byId[l.itemId]?.cost || 0))})</span>
|
||||
</div>
|
||||
</Fragment>
|
||||
); })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Panel>
|
||||
|
||||
{cam && <Camera onHit={camHit} message={camMsg} onClose={() => setCam(false)} />}
|
||||
{bind && <BindDialog code={bind} onClose={() => setBind("")} onBound={(itemId, si) => setMsg(countPlus(itemId, si))} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { Empty, Field, LiveRegion } from "@/components/ui";
|
||||
import { Panel } from "@/components/portal";
|
||||
import { LOCATION_KINDS, csvOf, locMap, locPath, locTree } from "@/lib/compute";
|
||||
import { downloadCsv } from "@/lib/print";
|
||||
|
||||
/* Where garments live: rooms hold shelves, shelves hold bays. Moved here from Settings. Editing is
|
||||
admin-only on the server (location.save / location.delete); issuers see the tree read-only. */
|
||||
export default function Locations() {
|
||||
const { s, isAdmin, mutate } = useSnap();
|
||||
const [nl, setNl] = useState({ name: "", kind: "Shelf", parentId: "" });
|
||||
const [msg, setMsg] = useState<{ text: string; err: boolean }>({ text: "", err: false });
|
||||
const say = (r: { ok: true } | { ok: false; error: string }, ok: string) => setMsg(r.ok ? { text: ok, err: false } : { text: r.error, err: true });
|
||||
|
||||
const tree = locTree(s, true);
|
||||
// How many sizes sit on each location, so an empty shelf is obvious before it is removed.
|
||||
const locCounts: Record<string, number> = {};
|
||||
for (const k in s.placed) locCounts[s.placed[k]] = (locCounts[s.placed[k]] || 0) + 1;
|
||||
|
||||
function exportLocations() {
|
||||
const byId = locMap(s);
|
||||
// Each row carries its full path as well as its own name: "Bay B3" alone is on every shelf.
|
||||
const rows = tree.map(({ loc }) => [loc.name, loc.kind, loc.parentId ? byId[loc.parentId]?.name ?? "" : "", locPath(byId, loc.id).map((l) => l.name).join(" · "), locCounts[loc.id] || 0]);
|
||||
downloadCsv(`threadcount-locations-${s.today}.csv`, csvOf(["Location", "Kind", "Inside", "Full path", "Sizes"], rows));
|
||||
}
|
||||
|
||||
const parentOpts = (exclude?: string) => tree.filter(({ loc }) => loc.id !== exclude).map(({ loc, depth }) => <option key={loc.id} value={loc.id}>{" ".repeat(depth * 2)}{loc.name}</option>);
|
||||
|
||||
return (
|
||||
<div className="tc-stk">
|
||||
<LiveRegion msg={msg.text} tone={msg.err ? "alert" : "status"} className={msg.err ? "notice tc-flag" : "notice"} />
|
||||
<Panel
|
||||
title="Where garments live"
|
||||
aside={<span className="tc-stk-row" style={{ gap: 16 }}><span className="tc-mono">{s.locations.length} location{s.locations.length === 1 ? "" : "s"}</span><button type="button" className="btn btn-ghost" onClick={exportLocations} disabled={s.locations.length === 0}>Export CSV</button></span>}
|
||||
foot={isAdmin ? (
|
||||
<div className="tc-stk-row" style={{ alignItems: "flex-end", width: "100%" }}>
|
||||
<Field label="New location" style={{ flex: 1, minWidth: 160 }}>{(c) => <input {...c} className="input" value={nl.name} onChange={(e) => setNl({ ...nl, name: e.target.value })} placeholder="e.g. Shelf B" />}</Field>
|
||||
<Field label="Kind" style={{ width: 140 }}>{(c) => <select {...c} className="input" value={nl.kind} onChange={(e) => setNl({ ...nl, kind: e.target.value })}>{LOCATION_KINDS.map((k) => <option key={k}>{k}</option>)}</select>}</Field>
|
||||
<Field label="Inside" style={{ width: 220 }}>{(c) => <select {...c} className="input" value={nl.parentId} onChange={(e) => setNl({ ...nl, parentId: e.target.value })}><option value="">— top level —</option>{parentOpts()}</select>}</Field>
|
||||
<button type="button" className="btn btn-secondary" disabled={!nl.name.trim()} onClick={async () => { const r = await mutate("location.save", nl); say(r, "Added."); if (r.ok) setNl({ name: "", kind: nl.kind, parentId: nl.parentId }); }}>Add</button>
|
||||
</div>
|
||||
) : undefined}
|
||||
>
|
||||
{tree.length === 0 ? <div className="tc-stk-pad"><Empty pad={2}>No locations yet.</Empty></div> : (
|
||||
<div className="table-wrap">
|
||||
<table className="tc-table">
|
||||
<thead>
|
||||
<tr><th>Location</th><th>Kind</th><th>Inside</th><th className="num">Sizes</th>{isAdmin && <th><span className="sr-only">Remove</span></th>}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tree.map(({ loc, depth }) => (
|
||||
<tr key={loc.id}>
|
||||
<td style={{ fontWeight: 600, paddingLeft: 12 + depth * 18 }}>{loc.name}</td>
|
||||
<td>{loc.kind}</td>
|
||||
<td>
|
||||
<select className="input tc-stk-tight" aria-label={`What ${loc.name} sits inside`} value={loc.parentId || ""} disabled={!isAdmin}
|
||||
onChange={async (e) => { const r = await mutate("location.save", { id: loc.id, name: loc.name, kind: loc.kind, parentId: e.target.value }); say(r, "Moved."); }}>
|
||||
<option value="">— top level —</option>
|
||||
{parentOpts(loc.id)}
|
||||
</select>
|
||||
</td>
|
||||
<td className="num">{locCounts[loc.id] || 0}</td>
|
||||
{isAdmin && (
|
||||
<td style={{ width: 40, textAlign: "right" }}>
|
||||
<button type="button" className="btn btn-ghost btn-icon" title="Remove — anything on it becomes unplaced" aria-label={`Remove ${loc.name}`}
|
||||
onClick={async () => { const r = await mutate("location.delete", { id: loc.id }); say(r, "Removed."); }}>×</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
"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<StockFilter, string> = { 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<StockFilter>(init.filter);
|
||||
const [sortKey, setSortKey] = useState<SortKey>("");
|
||||
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<Record<string, boolean>>({});
|
||||
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<StockFilter, (r: Row) => 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<StockFilter, number>;
|
||||
|
||||
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<SortKey, "">, t: string, num = false) {
|
||||
const on = sortKey === k;
|
||||
return (
|
||||
<th className={num ? "num" : undefined} aria-sort={on ? (sortDir > 0 ? "ascending" : "descending") : "none"}>
|
||||
<button type="button" className="tc-stk-sort" onClick={() => { if (on) setSortDir(-sortDir); else { setSortKey(k); setSortDir(1); } }}>
|
||||
{t}<span aria-hidden="true">{on ? (sortDir > 0 ? " ▲" : " ▼") : ""}</span>
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, unknown>) {
|
||||
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 ? <Tag tone="accent">{r.out} out</Tag> : r.low > 0 ? <Tag tone="low">{r.low} low</Tag> : r.unbound ? <Tag tone="quiet">no barcode</Tag> : null;
|
||||
|
||||
return (
|
||||
<div className="tc-stk">
|
||||
<div className="tc-stk-row">
|
||||
<div className="tc-stk-segscroll">
|
||||
<Seg label="Which garments" opts={STOCK_FILTERS} value={filter} onChange={changeFilter} labels={FILTER_LABELS} counts={counts} />
|
||||
</div>
|
||||
<label className="tc-stk-search">
|
||||
<Icon name="search" size={16} />
|
||||
<input className="input" type="search" placeholder="Filter garments" aria-label="Filter garments by name, SKU, size or barcode" value={q} onChange={(e) => changeQ(e.target.value)} />
|
||||
</label>
|
||||
<SelectButton label="Group" value={group} anyValue={ALL_GROUPS} options={groupFilterOpts} onChange={changeGroup} />
|
||||
<SelectButton label="Supplier" value={supplier} anyValue="" options={supplierOpts} onChange={changeSupplier} />
|
||||
<span className="tc-stk-totals">On hand <b>{wholeMoney(totals.value)}</b> · on order <b>{wholeMoney(totals.ordered)}</b></span>
|
||||
</div>
|
||||
|
||||
{isAdmin && selIds.length > 0 && (
|
||||
<div className="tc-stk-bulk" role="group" aria-label="Change the selected garments">
|
||||
<b className="tc-stk-mono" style={{ fontSize: 13 }}>{selIds.length} selected</b>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setSel({})}>Clear</button>
|
||||
<span className="tc-stk-vr" aria-hidden="true" />
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => bulk("discontinue")}>Discontinue</button>
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => bulk("reinstate")}>Reinstate</button>
|
||||
<button type="button" className="btn btn-ghost" disabled={busy} onClick={() => { if (confirm(`Delete ${selIds.length} garment${selIds.length === 1 ? "" : "s"}? Anything with history or stock on hand is discontinued instead.`)) bulk("delete"); }}>Delete</button>
|
||||
<span className="tc-stk-vr" aria-hidden="true" />
|
||||
<SelectButton label="Set supplier" value="" anyValue="" options={[{ value: "", label: "choose" }, ...s.settings.suppliers.map((o) => ({ value: o, label: o }))]} onChange={(v) => { if (v) bulk("supplier", v); }} />
|
||||
<SelectButton label="Set group" value="" anyValue="" options={[{ value: "", label: "choose" }, ...groupOpts.map((o) => ({ value: o, label: o }))]} onChange={(v) => { if (v) bulk("group", undefined, { groups: v === ALL_GROUPS ? [] : [v] }); }} />
|
||||
<span className="tc-stk-row" style={{ gap: 6 }}>
|
||||
<input className="input tc-stk-mono" style={{ width: 70 }} aria-label="Reorder level to set on the selected garments" placeholder="Level" inputMode="numeric" value={bulkRo} onChange={(e) => setBulkRo(e.target.value.replace(/[^0-9]/g, ""))} />
|
||||
<button type="button" className="btn btn-ghost" disabled={busy || bulkRo === ""} onClick={() => bulk("reorder", bulkRo)}>Set reorder</button>
|
||||
</span>
|
||||
<span className="tc-stk-row" style={{ gap: 6 }}>
|
||||
<input className="input tc-stk-mono" style={{ width: 96 }} aria-label="New price, or a percentage change, for the selected garments" placeholder="$ or +5%" value={bulkPrice} onChange={(e) => setBulkPrice(e.target.value)} />
|
||||
<button type="button" className="btn btn-ghost" disabled={busy || !priceOk} onClick={() => bulk("price", bp)}>Apply price</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Notice msg={msg} />
|
||||
|
||||
<div className="tc-pp">
|
||||
{s.catalog.length === 0 ? (
|
||||
<div className="tc-stk-pad"><Empty pad={2}>No garments yet.{isAdmin && <> <Link href="/app/settings?tab=data">Import them</Link></>}</Empty></div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="tc-stk-pad"><Empty pad={2}>No garments match.</Empty></div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className={"tc-table tc-stk-table cards" + (isAdmin ? "" : " noselect")}>
|
||||
<thead>
|
||||
<tr>
|
||||
{isAdmin && <th style={{ width: 18 }}><input type="checkbox" className="tc-stk-check" checked={allSel} onChange={selectAll} aria-label="Select every garment shown" /></th>}
|
||||
{sortTh("name", "Garment")}
|
||||
<th>Sizes · on hand</th>
|
||||
{sortTh("onhand", "On hand", true)}
|
||||
{sortTh("value", "Value", true)}
|
||||
{sortTh("onorder", "On order", true)}
|
||||
<th><span className="sr-only">Status</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.map((r) => {
|
||||
const status = statusOf(r);
|
||||
return (
|
||||
<tr key={r.it.id} className={r.it.archived ? "archived" : undefined}>
|
||||
{isAdmin && <td className="tc-stk-c-check" style={{ width: 18 }}><input type="checkbox" className="tc-stk-check" checked={!!sel[r.it.id]} aria-label={`Select ${label(r.it)}`} onChange={() => setSel((m) => ({ ...m, [r.it.id]: !m[r.it.id] }))} /></td>}
|
||||
<td className="tc-stk-c-name">
|
||||
<Link href={`/app/stock/${r.it.id}`} className="tc-stk-name">{label(r.it)}</Link>
|
||||
{r.it.archived && <> <Tag>Discontinued</Tag></>}
|
||||
<div className="tc-stk-meta"><span className="tc-mono">{r.it.sku || "—"}</span> · {r.it.supplier || "No supplier"}</div>
|
||||
</td>
|
||||
<td className="tc-stk-c-sizes">
|
||||
<SizeStrip itemLabel={label(r.it)} cells={cellsOf(r)} action="Adjust" opensDialog onCell={(si) => setAdjust({ itemId: r.it.id, si })} />
|
||||
</td>
|
||||
<td className="num tc-stk-desk" style={{ fontWeight: 600 }}>{r.tot}</td>
|
||||
<td className="num tc-stk-desk">{wholeMoney(r.val)}</td>
|
||||
<td className="num tc-stk-desk">{r.onOrd > 0 ? r.onOrd : "–"}</td>
|
||||
<td className="tc-stk-desk">{status}</td>
|
||||
<td className="tc-stk-mob">
|
||||
<span>On hand <b className="tc-mono">{r.tot}</b> · <span className="tc-mono">{wholeMoney(r.val)}</span> · on order <span className="tc-mono">{r.onOrd}</span></span>
|
||||
{status}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<div className="tc-stk-foot">
|
||||
<span className="tc-stk-meta tc-mono">Showing {shown.length} of {rows.length}</span>
|
||||
{rows.length > limit && <button type="button" className="btn btn-ghost" onClick={() => setLimit(100000)}>Show all</button>}
|
||||
<span className="tc-stk-foot-right">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setAdjust(null)}>Adjust quantity</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={exportCsv}>Export CSV</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{adjust !== false && <AdjustDialog init={adjust} onClose={() => setAdjust(false)} />}
|
||||
{cam && <Camera onHit={camHit} message="" onClose={() => setCam(false)} />}
|
||||
{bind && <BindDialog code={bind} onClose={() => setBind("")} onBound={(itemId, si) => router.push(`/app/stock/${encodeURIComponent(itemId)}?size=${si}`)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Layout rules only the Stock screens use: the filter row, the garment table turning into cards
|
||||
under 780px, and the size-row highlight on the garment page. Scoped to .tc-stk-* class names. */
|
||||
export function StockStyles() {
|
||||
return null; // the rules are in app/globals.css under portal redesign
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/* Filters on the Stock tabs live in the address, so a link or the help mark lands on the same view.
|
||||
replaceState keeps typing out of the history and Next keeps useSearchParams in step with it. */
|
||||
export function setQuery(updates: Record<string, string | null | undefined>) {
|
||||
if (typeof window === "undefined") return;
|
||||
const u = new URL(window.location.href);
|
||||
for (const [k, v] of Object.entries(updates)) {
|
||||
if (v === null || v === undefined || v === "") u.searchParams.delete(k);
|
||||
else u.searchParams.set(k, v);
|
||||
}
|
||||
window.history.replaceState(null, "", u.pathname + u.search + u.hash);
|
||||
}
|
||||
|
||||
/** Whole dollars for the stock table and totals: $1,008. */
|
||||
export const wholeMoney = (n: number) => (n < 0 ? "−" : "") + "$" + Math.round(Math.abs(n || 0)).toLocaleString("en-AU");
|
||||
Reference in New Issue
Block a user