Files
threadcount-community/app/app/stock/page.tsx
T
ThreadCount 96d5c10537 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 49da3a4 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-13 11:38:24 +10:00

285 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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&apos;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>
);
}