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 e2d6d42 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-13 11:16:36 +10:00
commit 057da00fd2
406 changed files with 47822 additions and 0 deletions
+513
View File
@@ -0,0 +1,513 @@
"use client";
/* The product card, on the phone.
*
* Two halves, because they are two different jobs. The top is the garment's description, which is
* typed once and rarely changed. The bottom is per size — par level, barcode, what's on hand —
* which is what someone standing at a shelf actually came here to adjust.
*
* The size index — a position in that list — is what every issue, order line and barcode points at,
* so the order of the list is never offered for editing: shuffling it would silently repoint years
* of records. One size can be taken off, though, and the server does the deciding: it shifts every
* later size down across the ten tables that store a position, in one transaction, and refuses
* outright when the size being removed has anything recorded against it. So this screen offers the
* removal on every size and shows whatever comes back. */
import { useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { bcBound, formatInZone, key as vkey, label, onhand, reorderAt, splitKey, type CostRec, type Item } from "@/lib/compute";
import { isNative } from "@/lib/nativescan";
import MScan from "@/components/MScan";
import {
ACCENT, GROUND, INK, ON_DARK, MBar, MBody, MError, MField, MNote, MRule, MSection, MTop, inputStyle,
} from "@/components/m";
/* The two ways to get a code onto a size, side by side. Scanning stays the primary act, ink-filled:
it is the fastest when the camera cooperates. Typing sits beside it rather than behind it — a
label in your hand beats a camera that won't focus, and it is the only way to reach a code the
scanner keeps putting on the wrong garment. */
const codeBtn: React.CSSProperties = {
flex: 1, minHeight: 48, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800,
fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer",
};
/* Undoing rather than doing: the quieter kind of action on a size row. No border, so it reads as a
link; 44px tall, so it is still a target you can hit with gloves on. */
const quietAction: React.CSSProperties = {
display: "flex", alignItems: "center", width: "100%", minHeight: 44, background: "none", border: 0,
padding: 0, font: "inherit", fontSize: 12.5, fontWeight: 700, color: "var(--color-neutral-700)",
textAlign: "left", cursor: "pointer",
};
/* What a freshly minted number is, and what it still isn't.
*
* The code exists in ThreadCount the moment it is made, but the garment on the rack carries nothing
* until somebody prints it and sticks it on — so the confirmation carries the print with it rather
* than leaving it to be found at the foot of a fifteen-size screen. */
function MMade({ made, inApp, labels, inset, onPrint }: {
made: { size: string; code: string }[]; inApp: boolean; labels: number; inset?: boolean; onPrint: () => void;
}) {
if (!made.length) return null;
return (
<div style={{ margin: inset ? "10px 0 0" : 16, padding: 16, background: INK, color: GROUND, fontSize: 13.5, lineHeight: 1.6 }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 16, letterSpacing: "-0.01em" }}>
{made.length === 1 ? `Size ${made[0].size} has a barcode now` : `${made.length} sizes have a barcode now`}
</div>
<div style={{ marginTop: 8, color: ON_DARK, fontSize: 12.5, fontVariantNumeric: "tabular-nums" }}>
{made.map((m) => <div key={m.code}>{m.size} · {m.code}</div>)}
</div>
<div style={{ marginTop: 10 }}>
{inApp
? "Nothing is on the garments yet. Printing is a desktop job — the app cant open a label sheet — so open ThreadCount at threadcount.tech and print this garments labels from there."
: labels
? "Nothing is on the garments yet. Print the labels and stick one on each."
: "Nothing is on the garments yet, and nothing in a labelled size is on the shelf to stick one on. Count some in and the labels will print, one for each garment."}
</div>
{!inApp && labels > 0 && (
<button onClick={onPrint}
style={{ width: "100%", minHeight: 48, marginTop: 12, border: "2px solid " + GROUND, background: GROUND, color: INK, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer" }}>
Print labels
</button>
)}
</div>
);
}
export default function MProductCard() {
const { id } = useParams<{ id: string }>();
const { s, isAdmin, mutate, busy } = useSnap();
const { L, byId } = useDerived();
const router = useRouter();
const it = s.catalog.find((x: Item) => x.id === id);
const [err, setErr] = useState("");
const [editing, setEditing] = useState(false);
const [f, setF] = useState(() => ({
item: it?.item ?? "", type: it?.type ?? "", group: it?.group ?? "All",
supplier: it?.supplier ?? "", sku: it?.sku ?? "", cost: it ? String(it.cost) : "", notes: it?.notes ?? "",
}));
const [newSize, setNewSize] = useState("");
const [scanFor, setScanFor] = useState<number | null>(null);
const [typeFor, setTypeFor] = useState<number | null>(null);
const [typed, setTyped] = useState("");
/* Which run is in flight, so only the button that was pressed says so: `busy` is true for every
mutation on the screen, and fifteen rows all reading "Generating…" because somebody nudged a
par level is a lie. -1 is the whole-garment run. */
const [genFor, setGenFor] = useState<number | null>(null);
const [made, setMade] = useState<{ si: number; size: string; code: string }[]>([]);
/* The Android shell cannot print: its WebView opens no second window, so the label sheet would
replace the app, and window.print() doesn't exist there. Same reading as the reprint screen,
taken after mount — the server render doesn't know which shell it is being sent to. */
const [inApp, setInApp] = useState(false);
useEffect(() => { setInApp(isNative()); }, []);
const groups = useMemo(() => {
const set = new Set<string>(["All"]);
for (const st of s.staff) if (st.group) set.add(st.group);
for (const i of s.catalog) if (i.group) set.add(i.group);
return [...set].sort();
}, [s.staff, s.catalog]);
if (!it) {
return (
<>
<MTop title="Garment" back />
<MRule />
<MBody><MNote tone="warn">That garment isn&rsquo;t in the catalogue any more.</MNote></MBody>
</>
);
}
const name = label(byId[it.id] ?? it);
// Newest first; the snapshot already caps how many it carries.
const costs: CostRec[] = s.costs.filter((c) => c.itemId === it.id);
const readOnly = !isAdmin;
// How many sizes a whole-garment run would cover, and how much paper a print run would produce —
// one label per garment on the shelf. Both read through the same bcBound and onhand the rows
// below use, so the two numbers on this screen can never disagree with each other.
const unlabelled = it.sizes.filter((_: string, si: number) => !bcBound(s, it, si)).length;
const labels = it.sizes.reduce((n: number, _: string, si: number) =>
n + (bcBound(s, it, si) ? Math.max(0, onhand(s, L, vkey(it.id, si))) : 0), 0);
/* Fill the boxes from the garment as it stands right now, not as it stood when the screen was
* opened. The card refreshes underneath without remounting, so a coordinator can be looking at a
* unit cost somebody else raised on the desktop minutes ago while this form still holds the old
* one — and saving would quietly put the old price back and file a "Down from $24.00" cost change
* in the wrong person's name. Every issue costed after that would use the stale figure. */
function startEdit() {
setErr("");
setF({
item: it!.item, type: it!.type, group: it!.group,
supplier: it!.supplier, sku: it!.sku, cost: String(it!.cost), notes: it!.notes,
});
setEditing(true);
}
async function saveDetails() {
setErr("");
if (!f.item.trim()) { setErr("The garment needs a name."); return; }
const c = f.cost.trim() ? Number(f.cost) : 0;
if (!(c >= 0) || Number.isNaN(c)) { setErr("Cost has to be a number."); return; }
const r = await mutate("catalog.update", {
id: it!.id, item: f.item.trim(), type: f.type.trim(), group: f.group,
supplier: f.supplier.trim(), sku: f.sku.trim(), cost: c, notes: f.notes,
});
if (!r.ok) { setErr(r.error); return; }
setEditing(false);
}
async function addSize() {
const sz = newSize.trim();
if (!sz) return;
setErr("");
const r = await mutate("catalog.update", { id: it!.id, addSize: sz });
if (!r.ok) { setErr(r.error); return; }
setNewSize("");
}
async function setPar(si: number, next: number) {
const r = await mutate("stock.reorder", { itemId: it!.id, si, reorder: Math.max(0, next) });
if (!r.ok) setErr(r.error);
}
/** Where a code already sits, named the way a person would name it, or "" if this snapshot has
* never seen it. */
function boundElsewhere(code: string): string {
const at = s.barcodes[code];
if (!at) return "";
const { itemId, si } = splitKey(at);
const other = byId[itemId];
return other ? `${label(other)} · size ${other.sizes[si] ?? si}` : "";
}
/* Binding, including the refusal that used to be a dead end.
*
* A code scanned onto the wrong garment can only be put right by moving it, and barcode.bind
* won't move one unless it is told to — so when that is why it refused, offer the move rather
* than printing the message and stopping there. The snapshot is asked where the code sits so the
* question can name the garment it would come off; the server's own sentence, which names it too,
* is the fallback for a code somebody else bound since this page loaded. The other refusal — a
* generated 93XXXXXXX code, which stands for a garment rather than sitting on a label — is
* refused with or without force, matches neither test, and is shown as it came. */
async function bind(si: number, raw: string) {
const code = raw.trim();
if (!code) return;
setErr(""); setMade([]);
const r = await mutate("barcode.bind", { code, itemId: it!.id, si });
if (r.ok) { setTypeFor(null); setTyped(""); return; }
const at = boundElsewhere(code);
if (!at && !/is already on/.test(r.error)) { setErr(r.error); return; }
const ask = at
? `${code} is on ${at}. Take it off there and put it on ${name} · size ${it!.sizes[si]}?`
: `${r.error}\n\nMove it onto ${name} · size ${it!.sizes[si]}?`;
if (!confirm(ask)) { setErr(r.error); return; }
const moved = await mutate("barcode.bind", { code, itemId: it!.id, si, force: true });
if (!moved.ok) { setErr(moved.error); return; }
setTypeFor(null); setTyped("");
}
async function unbind(si: number, code: string) {
if (!confirm(`Unbind ${code} from ${name} · size ${it!.sizes[si]}? Scanning that label won't find this size any more.`)) return;
setErr(""); setMade([]);
const r = await mutate("barcode.unbind", { code });
if (!r.ok) setErr(r.error);
}
/* The server decides whether a size can go — it is the one that can count what has been recorded
* against this exact position — so the offer is made on every size and the refusal is shown when
* one comes back. Removing shifts the sizes after it down a place, so anything this screen is
* holding open against a position has to let go of it. */
async function removeSize(si: number) {
if (!confirm(`Remove size ${it!.sizes[si]} from ${name}? Its par level and any barcode on it go with it.`)) return;
setErr("");
const r = await mutate("catalog.removeSize", { id: it!.id, si });
if (!r.ok) { setErr(r.error); return; }
setScanFor(null); setTypeFor(null); setTyped(""); setMade([]);
}
/* Printing our own barcode for stock that arrived without one — the cafe shirts came with nothing
* printed on any of fifteen sizes, and a garment nobody can scan is invisible to a count and
* cannot be issued by scanning. The number is a real EAN-13 from the range GS1 keeps for exactly
* this, so every scanner in the building already reads it.
*
* The server decides what is missing: it fills only the gaps, leaves a size carrying a supplier's
* code alone, and refuses outright when there is nothing to do. So the offer is made and whatever
* comes back is shown, rather than the button being hidden on this screen's guess about a
* snapshot that may be a few seconds old. */
async function generate(si?: number) {
setErr(""); setMade([]);
setGenFor(si ?? -1);
const r = await mutate<{ made: { si: number; size: string; code: string }[]; count: number }>(
"barcode.generate", si === undefined ? { itemId: it!.id } : { itemId: it!.id, si },
);
setGenFor(null);
if (!r.ok) { setErr(r.error); return; }
setMade(r.result.made);
}
/* Not destructive, but it does put numbers on garments — and on a rack of fifteen sizes it is a
good deal more than the person pressing it can see at once. So it says how many first. */
async function generateAll() {
const ask = `Generate a barcode for ${unlabelled} size${unlabelled === 1 ? "" : "s"} on ${name}? Sizes that already carry a supplier's code keep theirs, and nothing is on a garment until the labels are printed.`;
if (unlabelled > 0 && !confirm(ask)) return;
await generate();
}
/* A whole garment's labels: one per garment on hand, every size that carries a code. A second
window rather than this one, because leaving the screen would lose the size list somebody is
halfway through labelling — and inside the app there is no second window to open, which is why
every path to here is closed off when `inApp`. */
function printLabels() {
window.open(`/print/labels?item=${encodeURIComponent(it!.id)}`, "_blank", "noopener");
}
async function archive() {
const r = await mutate("catalog.update", { id: it!.id, archived: !it!.archived });
if (!r.ok) { setErr(r.error); return; }
if (!it!.archived) router.replace("/m/catalogue");
}
return (
<>
<MTop title={it.archived ? "Archived" : "Garment"} right={`${it.sizes.length} size${it.sizes.length === 1 ? "" : "s"}`} back />
<MRule />
<MBody>
<MError msg={err} onDismiss={() => setErr("")} />
{it.archived && <MNote tone="warn">This garment is archived. It stays on old records but can&rsquo;t be issued.</MNote>}
{/* ---- the description ---- */}
{!editing ? (
<>
<div style={{ padding: "18px 16px", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em", lineHeight: 1.1 }}>{name}</div>
<div style={{ fontSize: 13.5, color: "var(--color-neutral-700)", marginTop: 8, lineHeight: 1.6 }}>
{[it.type, it.group === "All" ? "Anyone" : it.group, it.supplier, it.sku].filter(Boolean).join(" · ") || "No details yet"}
<br />
{it.cost ? `$${it.cost.toFixed(2)} each` : "No unit cost set"}
</div>
{it.notes && <div style={{ fontSize: 13, color: "var(--color-neutral-800)", marginTop: 10, lineHeight: 1.6 }}>{it.notes}</div>}
</div>
{!readOnly && (
<div style={{ padding: 16, borderBottom: "2px solid " + INK }}>
<button onClick={startEdit}
style={{ width: "100%", minHeight: 52, border: "2px solid " + INK, background: "transparent", color: INK, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>
Edit details
</button>
</div>
)}
</>
) : (
<>
<MField label="Garment">
<input value={f.item} onChange={(e) => setF({ ...f, item: e.target.value })} autoCapitalize="words" style={inputStyle} />
</MField>
<MField label="Type">
<input value={f.type} onChange={(e) => setF({ ...f, type: e.target.value })} style={inputStyle} />
</MField>
<MField label="Who wears it">
<select value={f.group} onChange={(e) => setF({ ...f, group: e.target.value })} style={{ ...inputStyle, appearance: "none" }}>
{groups.map((g) => <option key={g} value={g}>{g === "All" ? "Anyone" : g}</option>)}
</select>
</MField>
<MField label="Supplier">
<input value={f.supplier} onChange={(e) => setF({ ...f, supplier: e.target.value })} style={inputStyle} />
</MField>
<MField label="Supplier code">
<input value={f.sku} onChange={(e) => setF({ ...f, sku: e.target.value })} autoCapitalize="characters" autoCorrect="off" spellCheck={false} style={inputStyle} />
</MField>
<MField label="Unit cost">
<input value={f.cost} onChange={(e) => setF({ ...f, cost: e.target.value })} inputMode="decimal" style={inputStyle} />
</MField>
<MField label="Notes">
<input value={f.notes} onChange={(e) => setF({ ...f, notes: e.target.value })} placeholder="Optional" style={inputStyle} />
</MField>
<div style={{ padding: 16, display: "grid", gap: 10, borderBottom: "2px solid " + INK }}>
<button onClick={saveDetails} disabled={busy}
style={{ width: "100%", minHeight: 52, border: "2px solid " + ACCENT, background: ACCENT, color: "#fff", font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>
{busy ? "Saving…" : "Save details"}
</button>
<button onClick={() => { setEditing(false); setErr(""); setF({ item: it.item, type: it.type, group: it.group, supplier: it.supplier, sku: it.sku, cost: String(it.cost), notes: it.notes }); }}
style={{ width: "100%", minHeight: 48, border: 0, background: "none", color: "var(--color-neutral-700)", font: "inherit", fontSize: 13.5, fontWeight: 700, cursor: "pointer" }}>
Cancel
</button>
</div>
</>
)}
{/* ---- per size ---- */}
<MSection label="Sizes" right="On hand · par" />
{it.sizes.map((sz: string, si: number) => {
const k = vkey(it.id, si);
const oh = onhand(s, L, k);
const par = reorderAt(s, k);
// The bound supplier code only. bcFor()'s generated 93XXXXXXX fallback is printed on no
// garment, so showing it made every size look labelled and hid the ones that need one.
const code = bcBound(s, it, si);
return (
<div key={si} style={{ padding: "14px 16px", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, minWidth: 54 }}>{sz}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, color: "var(--color-neutral-700)" }}>
<b style={{ color: oh <= par ? "var(--color-accent-700)" : INK, fontSize: 15 }}>{oh}</b> on hand
</div>
<div style={{ fontSize: 12, color: code ? "var(--color-neutral-700)" : "var(--color-neutral-600)", marginTop: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{code || "No barcode bound"}
</div>
</div>
{!readOnly && (
<div style={{ display: "flex", alignItems: "center", gap: 0 }}>
<button onClick={() => setPar(si, par - 1)} aria-label={`Lower par for ${sz}`}
style={{ width: 44, height: 44, border: "2px solid " + INK, background: "transparent", color: INK, fontSize: 20, fontWeight: 800, cursor: "pointer" }}></button>
<div style={{ minWidth: 44, height: 44, border: "2px solid " + INK, borderLeft: 0, borderRight: 0, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 16 }}>{par}</div>
<button onClick={() => setPar(si, par + 1)} aria-label={`Raise par for ${sz}`}
style={{ width: 44, height: 44, border: "2px solid " + INK, background: "transparent", color: INK, fontSize: 20, fontWeight: 800, cursor: "pointer" }}>+</button>
</div>
)}
</div>
{!readOnly && (
<div style={{ marginTop: 10 }}>
{typeFor === si ? (
<div style={{ display: "grid", gap: 8 }}>
{/* A numeric keypad, because a supplier code is thirteen digits and that is
the keyboard you can hit accurately while holding the garment. It is only
a hint to the keyboard: whatever arrives is taken as typed, so the
alphanumeric codes some labels carry go through on a keyboard that offers
letters, and pasting is unaffected either way. */}
<input value={typed} onChange={(e) => setTyped(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") bind(si, typed); }}
placeholder="Barcode on the label" inputMode="numeric" autoFocus
autoCapitalize="characters" autoCorrect="off" spellCheck={false}
aria-label={`Barcode for size ${sz}`} style={inputStyle} />
<div style={{ display: "flex", gap: 8 }}>
<button onClick={() => bind(si, typed)} disabled={busy || !typed.trim()}
style={{ ...codeBtn, border: "2px solid " + ACCENT, background: ACCENT, color: "#fff", opacity: typed.trim() ? 1 : 0.4 }}>
{busy ? "Binding…" : "Bind"}
</button>
<button onClick={() => { setTypeFor(null); setTyped(""); }}
style={{ ...codeBtn, border: "2px solid var(--color-neutral-400)", background: "transparent", color: "var(--color-neutral-700)" }}>
Cancel
</button>
</div>
</div>
) : (
<>
<div style={{ display: "flex", gap: 8 }}>
<button onClick={() => setScanFor(si)} aria-label={`Scan a barcode for size ${sz}`}
style={{ ...codeBtn, border: "2px solid " + INK, background: INK, color: GROUND }}>
{code ? "Scan a new one" : "Scan"}
</button>
<button onClick={() => { setErr(""); setTyped(""); setTypeFor(si); }} aria-label={`Type a barcode for size ${sz}`}
style={{ ...codeBtn, border: "2px solid " + INK, background: "transparent", color: INK }}>
Type it in
</button>
</div>
{/* Stock that turned up with nothing printed on it has no label to scan and no
number to type, so the third way is to make one. Offered only where nothing
is bound: wherever the supplier printed a code, that code is the one the
delivery note will use next time and it stays. */}
{!code && (
<button onClick={() => generate(si)} disabled={busy} aria-label={`Generate a barcode for size ${sz}`}
style={{ ...codeBtn, width: "100%", marginTop: 8, border: "2px solid " + INK, background: "transparent", color: INK, opacity: busy ? 0.5 : 1 }}>
{genFor === si ? "Generating…" : "Generate a barcode"}
</button>
)}
</>
)}
{code && <button onClick={() => unbind(si, code)} style={quietAction}>Unbind {code}</button>}
<button onClick={() => removeSize(si)} style={quietAction}>Remove size {sz}</button>
{made.length === 1 && made[0].si === si && <MMade made={made} inApp={inApp} labels={labels} inset onPrint={printLabels} />}
</div>
)}
</div>
);
})}
{!readOnly && (
<div style={{ padding: 16, borderBottom: "2px solid " + INK, display: "flex", gap: 10 }}>
<input value={newSize} onChange={(e) => setNewSize(e.target.value)} placeholder="Add a size"
autoCapitalize="characters" autoCorrect="off" spellCheck={false}
style={{ ...inputStyle, flex: 1 }} />
<button onClick={addSize} disabled={busy || !newSize.trim()}
style={{ minWidth: 96, border: "2px solid " + INK, background: INK, color: GROUND, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer", opacity: newSize.trim() ? 1 : 0.4 }}>
Add
</button>
</div>
)}
{!readOnly && (
<>
<MSection label="Barcodes" right={unlabelled ? `${unlabelled} without` : "All labelled"} />
{made.length > 1 && <MMade made={made} inApp={inApp} labels={labels} onPrint={printLabels} />}
<div style={{ padding: 16, display: "grid", gap: 10, borderBottom: "2px solid " + INK }}>
<button onClick={generateAll} disabled={busy}
style={{ width: "100%", minHeight: 52, border: "2px solid " + INK, background: "transparent", color: INK, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer", opacity: busy ? 0.5 : 1 }}>
{genFor === -1 ? "Generating…" : "Generate for every unlabelled size"}
</button>
<button onClick={printLabels} disabled={inApp || labels === 0}
style={{ width: "100%", minHeight: 52, border: "2px solid " + ACCENT, background: ACCENT, color: "#fff", font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase", cursor: inApp || labels === 0 ? "not-allowed" : "pointer", opacity: inApp || labels === 0 ? 0.5 : 1 }}>
{inApp ? "Print on the desktop" : "Print labels"}
</button>
<div style={{ fontSize: 12.5, color: "var(--color-neutral-700)", lineHeight: 1.6 }}>
{inApp
? "Printing is a desktop job — the app cant open a label sheet. Open ThreadCount at threadcount.tech and print this garments labels from there."
: labels
? `One label for every garment on hand in a size that carries a code — ${labels} at the moment, six to an A4 sheet.`
: "Nothing on the shelf carries a code yet, so there is nothing to print."}
</div>
</div>
</>
)}
{!readOnly && (
<div style={{ padding: 16 }}>
<button onClick={archive}
style={{ background: "none", border: 0, padding: 0, font: "inherit", fontSize: 13, fontWeight: 700, color: it.archived ? "var(--color-accent-700)" : "var(--color-neutral-700)", cursor: "pointer" }}>
{it.archived ? "Put this garment back in the catalogue" : "Archive this garment"}
</button>
</div>
)}
{/* What we used to pay. CatalogItem.cost is a single field, so without this a price rise
silently erased the old figure — and "what did these cost last year" is a question
finance asks every year. */}
{costs.length > 0 && (
<>
<MSection label="What it has cost" right="Changed by" />
{costs.map((c) => (
<div key={c.id} style={{ display: "flex", alignItems: "baseline", gap: 12, padding: "12px 16px", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 17, minWidth: 78, fontVariantNumeric: "tabular-nums" }}>
${c.cost.toFixed(2)}
</div>
<div style={{ flex: 1, minWidth: 0, fontSize: 12.5, color: "var(--color-neutral-700)" }}>
{c.previous === null
? "Opening price"
: `${c.previous > c.cost ? "Down" : "Up"} from $${c.previous.toFixed(2)}`}
{" · "}
{/* The facility's zone, not the device's. A price change stamped at 09:00 in
Perth is a different calendar day on a phone left set to Sydney, and this
page is server-rendered first: with no zone pinned the server and the browser
formatted the same instant differently and React threw the markup away. */}
{formatInZone(c.at, s.tz)}
</div>
<div style={{ fontSize: 12, color: "var(--color-neutral-600)", whiteSpace: "nowrap" }}>{c.byName}</div>
</div>
))}
</>
)}
{readOnly && <MNote>Only an admin can change the catalogue.</MNote>}
</MBody>
{!readOnly && !it.archived && <MBar label="Done" glyph="check" tone="ink" onClick={() => router.push("/m/catalogue")} />}
{scanFor !== null && (
<MScan
title={`Barcode for ${it.sizes[scanFor]}`}
onClose={() => setScanFor(null)}
onHit={(raw) => { const si = scanFor; setScanFor(null); if (si !== null) bind(si, raw); }}
/>
)}
</>
);
}
+155
View File
@@ -0,0 +1,155 @@
"use client";
/* Create a garment from the counter.
*
* The desktop form asks for everything at once, which is right when you are importing a range.
* Here the only genuinely required things are a name and at least one size — the server enforces
* exactly that — so everything else can be filled in later from the product card. A coordinator
* with a new garment in one hand and a phone in the other should be able to make it exist in about
* twenty seconds and scan it in.
*
* Sizes are entered as a run rather than one at a time, because that is how they arrive: a garment
* comes in S-M-L-XL, not as four separate decisions. */
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useSnap } from "@/lib/client";
import type { Item } from "@/lib/compute";
import { INK, MBar, MBody, MError, MField, MNote, MRule, MTop, inputStyle } from "@/components/m";
const COMMON_RUNS: [string, string][] = [
["XS S M L XL", "XS · S · M · L · XL"],
["S M L XL 2XL", "S · M · L · XL · 2XL"],
["8 10 12 14 16 18", "8 18"],
["77R 82R 87R 92R", "77R 92R"],
];
export default function MCatalogueNew() {
const { s, isAdmin, mutate, busy } = useSnap();
const router = useRouter();
const [item, setItem] = useState("");
const [type, setType] = useState("");
const [group, setGroup] = useState("All");
const [supplier, setSupplier] = useState("");
const [sku, setSku] = useState("");
const [cost, setCost] = useState("");
const [sizeText, setSizeText] = useState("");
const [err, setErr] = useState("");
// Split on commas, slashes or whitespace so a run can be typed however it comes to hand.
const sizes = useMemo(
() => sizeText.split(/[,/\s]+/).map((x) => x.trim()).filter(Boolean),
[sizeText],
);
const dupSize = useMemo(() => sizes.length !== new Set(sizes).size, [sizes]);
/* The facility's configured groups are the vocabulary; the register and the catalogue only ever
* add to it.
*
* This used to be built from the groups already in USE, which made it impossible to put a garment
* on a role nothing had used yet — the first Kitchen shirt could never be added from the counter,
* because "Kitchen" only appeared in the list once a Kitchen garment existed. On a facility whose
* register has not been imported yet it collapsed to "Anyone" and whatever one or two groups the
* first few items happened to carry. The desktop dialog has always read settings.staffGroups;
* this is the same field and now has the same source. The in-use ones are still folded in so a
* group that predates the configured list, or arrived on a CSV import, does not vanish. */
const groups = useMemo(() => {
const set = new Set<string>(["All", ...s.settings.staffGroups]);
for (const st of s.staff) if (st.group) set.add(st.group);
for (const i of s.catalog) if (i.group) set.add(i.group);
return [...set].sort();
}, [s.staff, s.catalog, s.settings.staffGroups]);
const types = useMemo(() => [...new Set(s.catalog.map((i: Item) => i.type).filter(Boolean))].sort(), [s.catalog]);
const suppliers = useMemo(() => s.supplierDir.map((x) => x.name).sort(), [s.supplierDir]);
if (!isAdmin) {
return (
<>
<MTop title="New garment" back />
<MRule />
<MBody><MNote tone="warn">Only an admin can add to the catalogue.</MNote></MBody>
</>
);
}
async function save() {
setErr("");
if (!item.trim()) { setErr("Give the garment a name."); return; }
if (!sizes.length) { setErr("Add at least one size."); return; }
if (dupSize) { setErr("The same size is listed twice."); return; }
const c = cost.trim() ? Number(cost) : 0;
if (!(c >= 0) || Number.isNaN(c)) { setErr("Cost has to be a number, or left blank."); return; }
const r = await mutate<{ id: string }>("catalog.add", {
item: item.trim(), type: type.trim(), group, supplier: supplier.trim(),
sku: sku.trim(), cost: c, sizes,
});
if (!r.ok) { setErr(r.error); return; }
// Straight to the product card: the next thing anyone does is bind a barcode or set par.
router.replace(`/m/catalogue/${r.result.id}`);
}
return (
<>
<MTop title="New garment" back />
<MRule />
<MBody>
<MError msg={err} onDismiss={() => setErr("")} />
<MField label="Garment">
<input value={item} onChange={(e) => { setItem(e.target.value); setErr(""); }}
placeholder="Scrub top" autoCapitalize="words" enterKeyHint="next" style={inputStyle} />
</MField>
<MField label="Sizes">
<input value={sizeText} onChange={(e) => { setSizeText(e.target.value); setErr(""); }}
placeholder="S M L XL" autoCapitalize="characters" autoCorrect="off" spellCheck={false} style={inputStyle} />
</MField>
<div style={{ padding: "0 16px 14px", display: "flex", flexWrap: "wrap", gap: 8 }}>
{COMMON_RUNS.map(([run, pretty]) => (
<button key={run} onClick={() => { setSizeText(run); setErr(""); }}
style={{ border: "2px solid " + INK, background: "transparent", color: INK, padding: "8px 12px", fontSize: 12.5, fontWeight: 700, cursor: "pointer" }}>
{pretty}
</button>
))}
</div>
{sizes.length > 0 && (
<div style={{ padding: "0 16px 14px", fontSize: 13, color: dupSize ? "var(--color-accent-700)" : "var(--color-neutral-700)", fontWeight: dupSize ? 700 : 400 }}>
{dupSize ? "The same size is listed twice." : `${sizes.length} size${sizes.length === 1 ? "" : "s"}: ${sizes.join(" · ")}`}
</div>
)}
<MField label="Type">
<input list="tc-types" value={type} onChange={(e) => setType(e.target.value)} placeholder="Scrub top" style={inputStyle} />
<datalist id="tc-types">{types.map((t) => <option key={t} value={t} />)}</datalist>
</MField>
<MField label="Who wears it">
<select value={group} onChange={(e) => setGroup(e.target.value)} style={{ ...inputStyle, appearance: "none" }}>
{groups.map((g) => <option key={g} value={g}>{g === "All" ? "Anyone" : g}</option>)}
</select>
</MField>
<MField label="Supplier">
<input list="tc-suppliers" value={supplier} onChange={(e) => setSupplier(e.target.value)} placeholder="Optional" style={inputStyle} />
<datalist id="tc-suppliers">{suppliers.map((x) => <option key={x} value={x} />)}</datalist>
</MField>
<MField label="Supplier code">
<input value={sku} onChange={(e) => setSku(e.target.value)} placeholder="Optional" autoCapitalize="characters" autoCorrect="off" spellCheck={false} style={inputStyle} />
</MField>
<MField label="Unit cost">
<input value={cost} onChange={(e) => { setCost(e.target.value); setErr(""); }}
inputMode="decimal" placeholder="0.00" style={inputStyle} />
</MField>
<MNote>
Barcodes, par levels and opening stock are set on the product card once this exists it
is quicker to scan a garment in than to type its code.
</MNote>
</MBody>
<MBar label={busy ? "Saving…" : "Create garment"} onClick={save} disabled={busy} />
</>
);
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
/* The catalogue on the phone.
*
* This used to be one of the rows under "On the desktop" — listed, greyed out, untappable, with
* the note that adding garments is a sit-down job. It genuinely is, for a bulk import of two
* hundred lines. It is not for the thing that actually happens in a linen room: a new garment
* turns up at the counter and needs to exist before it can be scanned in.
*
* So this is the whole catalogue, not just what's on the shelf — /m/stock deliberately shows only
* variants with history, which means a garment created five minutes ago wouldn't appear there. */
import Link from "next/link";
import { useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { label, type Item } from "@/lib/compute";
import { INK, IconPlus, MBody, MEmpty, MNav, MNote, MRow, MRule, MSection, MTop, inputStyle } from "@/components/m";
export default function MCatalogue() {
const { s, isAdmin } = useSnap();
const { byId } = useDerived();
const [q, setQ] = useState("");
const [showArchived, setShowArchived] = useState(false);
const rows = useMemo(() => {
const needle = q.trim().toLowerCase();
return s.catalog
.filter((i: Item) => (showArchived ? i.archived : !i.archived))
.map((i: Item) => ({
...i,
name: label(byId[i.id] ?? i),
sub: [i.sizes.length ? `${i.sizes.length} size${i.sizes.length === 1 ? "" : "s"}` : "No sizes yet", i.supplier || "No supplier", i.sku].filter(Boolean).join(" · "),
}))
.filter((i) => !needle || `${i.name} ${i.sku} ${i.supplier} ${i.type} ${i.group}`.toLowerCase().includes(needle))
.sort((a, b) => a.name.localeCompare(b.name));
}, [s.catalog, byId, q, showArchived]);
const archivedCount = s.catalog.filter((i: Item) => i.archived).length;
const addLink: React.CSSProperties = {
display: "flex", alignItems: "center", gap: 12, minHeight: 64, padding: "0 20px",
border: "2px solid " + INK, color: INK, textDecoration: "none",
fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase",
};
return (
<>
<MTop title="Catalogue" right={`${rows.length} item${rows.length === 1 ? "" : "s"}`} back />
<MRule />
<MBody>
<div style={{ padding: 16, borderBottom: "2px solid " + INK }}>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Garment, code, supplier" aria-label="Filter the catalogue" style={inputStyle} />
</div>
{isAdmin && (
<div style={{ padding: 16, borderBottom: "1px solid var(--color-divider)" }}>
<Link href="/m/catalogue/new" style={addLink}>
<IconPlus /><span style={{ flex: 1 }}>New garment</span>
</Link>
</div>
)}
<MSection label={showArchived ? "Archived" : "Garments"} right={isAdmin ? "Tap to edit" : undefined} />
{rows.length === 0
? <MEmpty
title={q ? "Nothing matches" : showArchived ? "Nothing archived" : "No garments yet"}
sub={q ? "Try a shorter search." : isAdmin ? "Add the first one and it can be scanned in straight away." : "An admin sets the catalogue up."} />
: rows.slice(0, 300).map((i) => (
<MRow
key={i.id}
href={isAdmin ? `/m/catalogue/${i.id}` : undefined}
mark={i.archived ? "mute" : "ink"}
title={i.name}
sub={i.sub}
right={<span style={{ fontSize: 12, color: "var(--color-neutral-600)" }}>{i.cost ? `$${i.cost.toFixed(2)}` : ""}</span>}
/>
))}
{archivedCount > 0 && (
<div style={{ padding: 16 }}>
<button
onClick={() => setShowArchived(!showArchived)}
style={{ background: "none", border: 0, padding: 0, font: "inherit", fontSize: 13, fontWeight: 700, color: "var(--color-accent-700)", cursor: "pointer" }}>
{showArchived ? "Back to the current catalogue" : `Show ${archivedCount} archived`}
</button>
</div>
)}
{!isAdmin && <MNote>Only an admin can change the catalogue. You can still see what exists.</MNote>}
</MBody>
<MNav />
</>
);
}