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 a113353 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -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 can’t open a label sheet — so open ThreadCount on the desktop and print this garment’s 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’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’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 can’t open a label sheet. Open ThreadCount on the desktop and print this garment’s 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); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user