96d5c10537
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 49da3a4 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
285 lines
25 KiB
TypeScript
285 lines
25 KiB
TypeScript
"use client";
|
||
import Link from "next/link";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { useDerived, useSnap } from "@/lib/client";
|
||
import { PageHead, Empty, InvTabs, KpiStrip, Seg, Notice } from "@/components/ui";
|
||
import { AdjustDialog, ItemDialog, BindDialog, ScanAddDialog } from "@/components/dialogs";
|
||
import Camera from "@/components/Camera";
|
||
import { ALL_GROUPS, bcBound, bcFor, bcParse, csvOf, fmtDate, genderLabel, groupKey, inBucket, key, lastCountMap, locTree, money, onOrderMap, onhand, reorderAt, touched, type Item, plOf } from "@/lib/compute";
|
||
import { downloadCsv } from "@/lib/print";
|
||
|
||
type SortKey = "" | "name" | "value" | "onorder" | "onhand";
|
||
const FILTERS = ["All", "In stock", "Flagged", "Out", "No barcode"] as const;
|
||
|
||
export default function StockPage() {
|
||
const { s, isAdmin, mutate } = useSnap();
|
||
const { L, byId, variants } = useDerived();
|
||
const [q, setQ] = useState("");
|
||
const [group, setGroup] = useState("All groups");
|
||
const [supplier, setSupplier] = useState("All suppliers");
|
||
const [filter, setFilter] = useState<(typeof FILTERS)[number]>("All");
|
||
const [sortKey, setSortKey] = useState<SortKey>("");
|
||
const [sortDir, setSortDir] = useState(1);
|
||
const [expand, setExpand] = useState<string | null>(null);
|
||
const [adjust, setAdjust] = useState<{ itemId: string; si: number } | null | false>(false);
|
||
const [newItem, setNewItem] = useState(false);
|
||
const [scanAdd, setScanAdd] = useState(false);
|
||
const [cam, setCam] = useState(false);
|
||
// SCAN button from the mobile bar lands here as ?scan=1 (garment lookup).
|
||
useEffect(() => { if (new URLSearchParams(window.location.search).get("scan") === "1") { setCam(true); window.history.replaceState(null, "", "/app/stock"); } const h = () => setCam(true); window.addEventListener("tc-scan", h); return () => window.removeEventListener("tc-scan", h); }, []);
|
||
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("");
|
||
|
||
const onOrder = useMemo(() => onOrderMap(s, byId), [s, byId]);
|
||
const lastCount = useMemo(() => lastCountMap(s), [s]);
|
||
const supplierOpts = ["All suppliers", ...new Set(s.catalog.map((it) => it.supplier).filter(Boolean))];
|
||
|
||
const kpi = useMemo(() => {
|
||
// Out and below-reorder stay on the live catalogue: nobody reorders a garment that has been
|
||
// retired, and flagging one would push it into Order flagged.
|
||
let out = 0, below = 0;
|
||
for (const v of variants) { if (!touched(s, L, v.key)) continue; const oh = onhand(s, L, v.key); if (oh <= 0) out++; if (oh <= reorderAt(s, v.key)) below++; }
|
||
// Value walks the whole catalogue, discontinued lines included, because they are still garments
|
||
// on a shelf. Deleting a product with stock on hand discontinues it instead (records stay
|
||
// intact), so counting only live items made 40 retired tunics — $1,200 — vanish from this figure
|
||
// the moment somebody pressed Delete, while the CSV below and Reports → Valuation both kept
|
||
// counting them. Three surfaces, one shelf: they have to agree.
|
||
let value = 0;
|
||
for (const it of s.catalog) it.sizes.forEach((_sz, si) => { const oh = onhand(s, L, key(it.id, si)); if (oh > 0) value += oh * it.cost; });
|
||
return { out, below, value };
|
||
}, [s, L, variants]);
|
||
|
||
const items = useMemo(() => {
|
||
const ql = q.trim().toLowerCase();
|
||
const out: { it: Item; sizes: { si: number; size: string; key: string; oh: number; ro: number; touched: boolean; barcode: string; bound: string; onOrd: number; pl: number }[]; tot: number; flagged: number; val: number; onOrd: number }[] = [];
|
||
for (const it of s.catalog) {
|
||
if (it.archived && filter !== "All") continue;
|
||
if (!inBucket(it, group)) continue;
|
||
if (supplier !== "All suppliers" && 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, pl: plOf(s, k) }; });
|
||
if (ql && !(it.item.toLowerCase().includes(ql) || it.sku.toLowerCase().includes(ql) || sizes.some((v) => v.barcode.includes(ql) || v.size.toLowerCase() === ql))) continue;
|
||
const tot = sizes.reduce((t, v) => t + v.oh, 0);
|
||
const flagged = sizes.filter((v) => v.touched && v.oh <= v.ro).length;
|
||
if (filter === "In stock" && tot <= 0) continue;
|
||
if (filter === "Flagged" && flagged === 0) continue;
|
||
if (filter === "Out" && !sizes.some((v) => v.touched && v.oh <= 0)) continue;
|
||
// Sizes still waiting on a supplier barcode — the work list for Scan sizes.
|
||
if (filter === "No barcode" && !sizes.some((v) => !v.bound)) continue;
|
||
out.push({ it, sizes, tot, flagged, val: sizes.reduce((t, v) => t + Math.max(0, v.oh) * it.cost, 0), onOrd: sizes.reduce((t, v) => t + v.onOrd, 0) });
|
||
}
|
||
if (sortKey) out.sort((a, b) => (sortKey === "name" ? a.it.item.localeCompare(b.it.item) : sortKey === "onhand" ? a.tot - b.tot : sortKey === "value" ? a.val - b.val : a.onOrd - b.onOrd) * sortDir);
|
||
return out;
|
||
}, [s, L, q, group, supplier, filter, sortKey, sortDir, onOrder]);
|
||
|
||
/* A real button, so the list can be sorted from the keyboard. The arrow glyph is decorative — the
|
||
direction is said in the accessible name instead, because "▲" reads as nothing useful. This is a
|
||
CSS grid rather than a <table>, so there is no columnheader for aria-sort to sit on. */
|
||
const head = (k: SortKey, t: string, right = false) => {
|
||
const on = sortKey === k;
|
||
return (
|
||
<button type="button"
|
||
aria-label={on ? `${t} — sorted ${sortDir > 0 ? "ascending" : "descending"}, sort the other way` : `Sort by ${t}`}
|
||
onClick={() => { if (on) setSortDir(-sortDir); else { setSortKey(k); setSortDir(1); } }}
|
||
style={{ font: "inherit", color: "inherit", background: "none", border: 0, padding: 0, cursor: "pointer", textAlign: right ? "right" : "left", letterSpacing: "inherit", textTransform: "inherit", fontWeight: "inherit" }}>
|
||
{t} <span aria-hidden="true">{on ? (sortDir > 0 ? "▲" : "▼") : ""}</span>
|
||
</button>
|
||
);
|
||
};
|
||
function camHit(raw: string) {
|
||
const p = bcParse(s, raw);
|
||
if (!p) { setCam(false); setBind(raw); return; }
|
||
setCam(false); setQ(""); setGroup("All groups"); setSupplier("All suppliers"); setFilter("All"); setExpand(p.itemId);
|
||
setTimeout(() => document.getElementById("item-" + p.itemId)?.scrollIntoView({ block: "center" }), 50);
|
||
}
|
||
/* Placing a size on a shelf and nudging a par level used to fire and forget. A refusal left the
|
||
<select> showing the shelf the coordinator picked until the next snapshot quietly snapped it
|
||
back, with nothing said — the worst of both, because the screen agreed with them for a while. */
|
||
async function act(op: string, payload: unknown) { const r = await mutate(op, payload); setMsg(r.ok ? "" : r.error); }
|
||
async function orderFlagged() {
|
||
setBusy(true);
|
||
const r = await mutate<{ added: number }>("stock.orderFlagged", {});
|
||
setBusy(false);
|
||
setMsg(!r.ok ? r.error : r.result.added ? `${r.result.added} line${r.result.added === 1 ? "" : "s"} added to draft supplier order(s) — review them under Ordering.` : "Everything flagged already has enough on order — nothing to add.");
|
||
}
|
||
function exportCsv() {
|
||
const rows: (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); rows.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"], rows));
|
||
}
|
||
const cols = (isAdmin ? "18px " : "") + "16px minmax(0,1fr) 110px 90px 140px 80px";
|
||
const locOpts = locTree(s).map(({ loc, depth }) => ({ id: loc.id, name: "\u00a0".repeat(depth * 2) + loc.name }));
|
||
const sizeCols = "70px 110px 1fr 80px 76px 80px 110px 130px 120px 100px";
|
||
|
||
const selIds = Object.keys(sel).filter((id) => sel[id] && byId[id]);
|
||
const shownIds = items.slice(0, limit).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; });
|
||
// The facility's own staff groups, not whatever garments happen to be tagged with: a group nothing
|
||
// is tagged for yet is exactly the one somebody is about to move garments into.
|
||
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);
|
||
const sm: React.CSSProperties = { minHeight: 28, padding: "2px 10px" };
|
||
const vr: React.CSSProperties = { width: 1, height: 22, background: "var(--color-divider)" };
|
||
const cb: React.CSSProperties = { width: 14, height: 14, accentColor: "var(--color-accent)", cursor: "pointer", margin: 0 };
|
||
async function bulk(action: string, value?: string, extra?: Record<string, unknown>) {
|
||
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); }
|
||
}
|
||
|
||
return (
|
||
<section>
|
||
<PageHead eyebrow="Inventory" title="Stock on Hand" below={<InvTabs active="stock" />}>
|
||
<div style={{ fontSize: 13, color: "var(--color-neutral-700)", textAlign: "right" }}>
|
||
<div>{variants.length} variants · {s.catalog.filter((i) => !i.archived).length} items</div>
|
||
</div>
|
||
<button className="btn btn-secondary" onClick={() => setAdjust(null)}>Adjust quantity</button>
|
||
{isAdmin && <button className="btn btn-secondary" onClick={() => setScanAdd(true)}>Scan to add</button>}
|
||
{isAdmin && <button className="btn btn-primary" onClick={() => setNewItem(true)}>Add item</button>}
|
||
</PageHead>
|
||
{/* An empty size and a size at its reorder level are the two figures that send somebody to the
|
||
Ordering screen, so they are the two that can be flagged. The value and the units on order
|
||
are facts, and a fact never wears the rule. */}
|
||
<KpiStrip items={[
|
||
{ val: money(kpi.value), label: "On-hand value", note: "every garment on the shelf, at cost" },
|
||
{ val: kpi.out, label: "Sizes out of stock", flag: kpi.out > 0, note: kpi.out > 0 ? "nothing to hand over the counter" : "every size has something on the shelf" },
|
||
{ val: kpi.below, label: "At or below reorder", flag: kpi.below > 0, note: kpi.below > 0 ? "Order flagged drafts the order" : "nothing to reorder" },
|
||
{ val: onOrder.total, label: "Units on open orders", note: "placed and not yet received" },
|
||
]} />
|
||
<div style={{ display: "flex", gap: "var(--space-3)", alignItems: "center", margin: "var(--space-4) 0", flexWrap: "wrap" }}>
|
||
<input className="input" style={{ width: 240 }} aria-label="Search the catalogue by item, SKU or barcode" placeholder="Search item, SKU or barcode" value={q} onChange={(e) => setQ(e.target.value)} />
|
||
<select className="input" style={{ width: 160 }} aria-label="Staff group" value={group} onChange={(e) => setGroup(e.target.value)}>{[ALL_GROUPS, ...s.settings.staffGroups].map((g) => <option key={g}>{g}</option>)}</select>
|
||
<select className="input" style={{ width: 180 }} aria-label="Supplier" value={supplier} onChange={(e) => setSupplier(e.target.value)}>{supplierOpts.map((g) => <option key={g}>{g}</option>)}</select>
|
||
<span role="group" aria-label="Which lines to show"><Seg opts={FILTERS} value={filter} onChange={(f) => { setFilter(f); setMsg(""); }} /></span>
|
||
<button className="btn btn-ghost" onClick={() => setCam(true)}>Camera lookup</button>
|
||
<div style={{ marginLeft: "auto", display: "flex", gap: "var(--space-2)", alignItems: "center" }}>
|
||
{kpi.below > 0 && <button className="btn btn-secondary" onClick={orderFlagged} disabled={busy}>Order flagged ({kpi.below})</button>}
|
||
<button className="btn btn-ghost" onClick={exportCsv}>Export CSV</button>
|
||
</div>
|
||
</div>
|
||
{isAdmin && selIds.length > 0 && (
|
||
<div style={{ display: "flex", gap: "var(--space-2)", alignItems: "center", border: "2px solid var(--color-text)", background: "var(--color-surface)", padding: "var(--space-2) var(--space-3)", marginBottom: "var(--space-3)", flexWrap: "wrap" }}>
|
||
<b style={{ fontSize: 13, flex: "none" }}>{selIds.length} selected</b>
|
||
<button className="btn btn-ghost" style={sm} onClick={() => setSel({})}>Clear</button>
|
||
<span style={vr} />
|
||
<button className="btn btn-ghost" style={sm} disabled={busy} onClick={() => bulk("discontinue")}>Discontinue</button>
|
||
<button className="btn btn-ghost" style={sm} disabled={busy} onClick={() => bulk("reinstate")}>Reinstate</button>
|
||
<button className="btn btn-ghost" style={sm} disabled={busy} onClick={() => { if (confirm(`Delete ${selIds.length} product${selIds.length === 1 ? "" : "s"}? Anything with history or stock on hand is discontinued instead.`)) bulk("delete"); }}>Delete</button>
|
||
<span style={vr} />
|
||
<select className="input" style={{ ...sm, width: 160, fontSize: 12 }} aria-label="Change the supplier on the selected products" value="" disabled={busy} onChange={(e) => { if (e.target.value) bulk("supplier", e.target.value); }}>
|
||
<option value="">Change supplier…</option>{s.settings.suppliers.map((o) => <option key={o} value={o}>{o}</option>)}
|
||
</select>
|
||
<select className="input" style={{ ...sm, width: 160, fontSize: 12 }} aria-label="Change the staff group on the selected products" value="" disabled={busy} onChange={(e) => { const v = e.target.value; if (v) bulk("group", undefined, { groups: v === ALL_GROUPS ? [] : [v] }); }}>
|
||
<option value="">Change group…</option>{groupOpts.map((o) => <option key={o} value={o}>{o}</option>)}
|
||
</select>
|
||
<span style={{ display: "flex", gap: 4, alignItems: "center" }}>
|
||
<input className="input" style={{ ...sm, width: 64, fontSize: 12 }} aria-label="Reorder level to set on the selected products" placeholder="Level" inputMode="numeric" value={bulkRo} onChange={(e) => setBulkRo(e.target.value.replace(/[^0-9]/g, ""))} />
|
||
<button className="btn btn-ghost" style={sm} disabled={busy || bulkRo === ""} onClick={() => bulk("reorder", bulkRo)}>Set reorder</button>
|
||
</span>
|
||
<span style={{ display: "flex", gap: 4, alignItems: "center" }}>
|
||
<input className="input" style={{ ...sm, width: 84, fontSize: 12 }} aria-label="New price, or a percentage change, for the selected products" placeholder="$ or +5%" value={bulkPrice} onChange={(e) => setBulkPrice(e.target.value)} />
|
||
<button className="btn btn-ghost" style={sm} disabled={busy || !priceOk} onClick={() => bulk("price", bulkPrice.trim())}>Apply price</button>
|
||
</span>
|
||
</div>
|
||
)}
|
||
<Notice msg={msg} />
|
||
<div className="table-wrap">
|
||
<div style={{ display: "grid", gridTemplateColumns: cols, gap: "var(--space-3)", padding: "0 var(--space-2) var(--space-1)", fontSize: 11, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--color-neutral-600)", fontWeight: 600, minWidth: 640 }}>
|
||
{isAdmin && <input type="checkbox" checked={allSel} onChange={selectAll} title="Select all shown" aria-label="Select every item shown" style={cb} />}
|
||
<div></div>{head("name", "Item")}{head("value", "Value", true)}{head("onorder", "On order", true)}<div style={{ textAlign: "right" }}>Status</div>{head("onhand", "On hand", true)}
|
||
</div>
|
||
<div style={{ borderTop: "2px solid var(--color-text)", minWidth: 640 }}>
|
||
{items.length === 0 && <Empty>{s.catalog.length === 0 ? "The catalogue is empty — add an item, or import it in Settings → Data." : "No items match."}</Empty>}
|
||
{items.slice(0, limit).map((x) => {
|
||
const open = expand === x.it.id;
|
||
const inStock = x.sizes.filter((v) => v.oh > 0).length;
|
||
return (
|
||
<div key={x.it.id} id={"item-" + x.it.id} style={{ borderBottom: "1px solid var(--color-divider)", opacity: x.it.archived ? 0.55 : 1 }}>
|
||
{/* The row can't become one button: it already carries a select-all checkbox and a
|
||
link to the product page, and nesting those inside a button makes both unreachable.
|
||
The caret is promoted to a real disclosure control instead, so the sizes can be
|
||
opened from the keyboard; clicking the row stays a mouse convenience. */}
|
||
<div className="row-hover" onClick={() => setExpand(open ? null : x.it.id)} style={{ display: "grid", gridTemplateColumns: cols, gap: "var(--space-3)", alignItems: "center", padding: "var(--space-3) var(--space-2)", cursor: "pointer" }}>
|
||
{isAdmin && <input type="checkbox" checked={!!sel[x.it.id]} aria-label={`Select ${x.it.item}`} onClick={(e) => e.stopPropagation()} onChange={() => setSel((m) => ({ ...m, [x.it.id]: !m[x.it.id] }))} style={cb} />}
|
||
<button type="button" aria-expanded={open} aria-label={`${open ? "Hide" : "Show"} the sizes of ${x.it.item}`} onClick={(e) => { e.stopPropagation(); setExpand(open ? null : x.it.id); }} style={{ font: "inherit", fontSize: 11, color: "var(--color-neutral-600)", background: "none", border: 0, padding: 0, cursor: "pointer", lineHeight: 1 }}>{open ? "▾" : "▸"}</button>
|
||
<div style={{ minWidth: 0 }}>
|
||
<div style={{ fontSize: 14, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||
<Link href={`/app/stock/${x.it.id}`} className="link-name" onClick={(e) => e.stopPropagation()}>{x.it.item}</Link>
|
||
{x.it.archived && <span className="tag tag-outline" style={{ marginLeft: 8 }}>Discontinued</span>}
|
||
</div>
|
||
<div style={{ fontSize: 12, color: "var(--color-neutral-700)" }}>{genderLabel(x.it.gender)} · {x.it.sku || "—"} · {x.it.supplier || "—"} · {money(x.it.cost)} each · {inStock} of {x.sizes.length} sizes in stock{x.sizes.reduce((t, v) => t + v.pl, 0) > 0 ? ` · ${x.sizes.reduce((t, v) => t + v.pl, 0)} pre-loved` : ""}</div>
|
||
</div>
|
||
<div style={{ textAlign: "right", fontSize: 13, fontWeight: 600 }}>{money(x.val)}</div>
|
||
<div style={{ textAlign: "right", fontSize: 13, color: x.onOrd > 0 ? "var(--color-text)" : "var(--color-neutral-600)" }}>{x.onOrd > 0 ? "+" + x.onOrd : "—"}</div>
|
||
{/* tag-flag carries the mark the plain accent tag does not: on a list this long the
|
||
colour alone is a row you have to go looking for. */}
|
||
<div style={{ textAlign: "right" }}>{x.flagged > 0 && <span className="tag tag-flag">{x.flagged} to reorder</span>}</div>
|
||
<div className="tc-row-fig" style={{ textAlign: "right", color: x.tot > 0 ? "var(--color-text)" : "var(--color-neutral-600)" }}>{x.tot}</div>
|
||
</div>
|
||
{open && (
|
||
<div style={{ padding: "0 var(--space-2) var(--space-3) calc(16px + var(--space-3) + var(--space-2))" }}>
|
||
<div style={{ display: "grid", gridTemplateColumns: sizeCols, gap: "var(--space-2)", fontSize: 11, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--color-neutral-600)", borderBottom: "2px solid var(--color-text)", paddingBottom: "var(--space-1)" }}>
|
||
<div>Size</div><div>Barcode</div><div></div><div style={{ textAlign: "right" }}>On hand</div><div style={{ textAlign: "right" }}>Pre-loved</div><div style={{ textAlign: "right" }}>On order</div><div style={{ textAlign: "right" }}>Last counted</div><div>Location</div><div style={{ textAlign: "right" }}>Reorder at</div><div></div>
|
||
</div>
|
||
{x.sizes.map((v) => {
|
||
const status = v.oh <= 0 ? (v.touched ? "OUT" : "—") : v.oh <= v.ro ? "REORDER" : "OK";
|
||
return (
|
||
<div key={v.si} style={{ display: "grid", gridTemplateColumns: sizeCols, gap: "var(--space-2)", alignItems: "center", padding: "var(--space-1) 0", borderBottom: "1px solid var(--color-divider)", fontSize: 13 }}>
|
||
<div style={{ fontWeight: 600 }}>{v.size}</div>
|
||
{/* Everything muted on this screen is 600, not 400: 400 is the meta colour
|
||
for the dark rail and reads at under 2:1 on paper, which turned a
|
||
"Not bound" and every dash for nothing-on-order into a ghost. */}
|
||
<div style={{ fontSize: 12, color: "var(--color-neutral-600)" }}>{v.bound || "Not bound"}</div>
|
||
<div><span className={status === "OK" ? "tag tag-neutral" : status === "—" ? "tag tag-outline" : "tag tag-flag"}>{status}</span></div>
|
||
<div style={{ textAlign: "right", fontWeight: 700, color: status === "OK" || status === "—" ? "var(--color-text)" : "var(--color-accent-700)" }}>{v.oh}</div>
|
||
<div style={{ textAlign: "right", color: v.pl > 0 ? "var(--color-text)" : "var(--color-neutral-600)" }}>{v.pl > 0 ? v.pl : "—"}</div>
|
||
<div style={{ textAlign: "right", color: v.onOrd > 0 ? "var(--color-text)" : "var(--color-neutral-600)" }}>{v.onOrd > 0 ? "+" + v.onOrd : "—"}</div>
|
||
<div style={{ textAlign: "right", fontSize: 12, color: "var(--color-neutral-600)" }}>{lastCount[v.key] ? fmtDate(lastCount[v.key]) : "never"}</div>
|
||
<div>
|
||
{/* Where this size lives. Counting a location on the phone walks its bays too. */}
|
||
<select className="input" style={{ minHeight: 26, padding: "1px 4px", fontSize: 12 }} value={s.placed[v.key] || ""}
|
||
aria-label={`Where ${x.it.item} size ${v.size} lives`}
|
||
onChange={(e) => act("location.place", { itemId: x.it.id, si: v.si, locationId: e.target.value })}
|
||
disabled={s.locations.length === 0} title={s.locations.length === 0 ? "Add locations in Settings first" : "Where this size lives"}>
|
||
<option value="">{s.locations.length === 0 ? "—" : "Unplaced"}</option>
|
||
{locOpts.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||
</select>
|
||
</div>
|
||
<div style={{ textAlign: "right" }}>
|
||
<span style={{ display: "inline-flex", alignItems: "center", gap: "var(--space-1)" }}>
|
||
{isAdmin && <button className="btn btn-ghost" style={{ padding: "0 6px", minHeight: 22 }} aria-label={`Lower the reorder level for ${x.it.item} size ${v.size}`} onClick={() => act("stock.reorder", { itemId: x.it.id, si: v.si, reorder: Math.max(0, v.ro - 1) })}>−</button>}
|
||
<span style={{ width: 20, textAlign: "center" }}>{v.ro}</span>
|
||
{isAdmin && <button className="btn btn-ghost" style={{ padding: "0 6px", minHeight: 22 }} aria-label={`Raise the reorder level for ${x.it.item} size ${v.size}`} onClick={() => act("stock.reorder", { itemId: x.it.id, si: v.si, reorder: v.ro + 1 })}>+</button>}
|
||
</span>
|
||
</div>
|
||
<div style={{ textAlign: "right" }}><button className="btn btn-ghost" style={{ minHeight: 26, padding: "2px 8px" }} aria-label={`Adjust the quantity of ${x.it.item} size ${v.size}`} onClick={() => setAdjust({ itemId: x.it.id, si: v.si })}>Adjust</button></div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
{items.length > limit && <button className="btn btn-secondary" style={{ marginTop: "var(--space-3)" }} onClick={() => setLimit(100000)}>Show all {items.length} items</button>}
|
||
<div style={{ marginTop: "var(--space-3)", fontSize: 12, color: "var(--color-neutral-700)" }}>Showing {Math.min(limit, items.length)} of {items.length} matching items — click a row for its sizes, click the name for the product page. Order flagged drafts a supplier order for everything at or below its reorder level, netting off what's already on order.</div>
|
||
{adjust !== false && <AdjustDialog init={adjust} onClose={() => setAdjust(false)} />}
|
||
{newItem && <ItemDialog onClose={() => setNewItem(false)} onSaved={(id) => { setQ(""); setGroup("All groups"); setFilter("All"); setExpand(id); }} />}
|
||
{scanAdd && <ScanAddDialog onClose={() => setScanAdd(false)} />}
|
||
{cam && <Camera onHit={camHit} message="" onClose={() => setCam(false)} />}
|
||
{bind && <BindDialog code={bind} onClose={() => setBind("")} onBound={(itemId) => setExpand(itemId)} />}
|
||
</section>
|
||
);
|
||
}
|