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).
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); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
/* Counting — the screen the app exists for. Scan a garment, the active line goes up by one.
|
||||
Expected quantities stay visible throughout: this is a sighted count, not a blind one.
|
||||
The tally lives in localStorage, so backgrounding the app mid-shelf loses nothing. */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { bcBound, locMap, locSubtree, locUnder, onhand, touched, UNPLACED, variantName } from "@/lib/compute";
|
||||
import MScan from "@/components/MScan";
|
||||
import { scanReject } from "@/lib/feedback";
|
||||
import { track } from "@/lib/analytics";
|
||||
import { useKeepAwake } from "@/lib/wakelock";
|
||||
import { INK, MAction, MBody, MEmpty, MError, MFigures, MInkLink, MPanel, MRow, MRule, MSection, MSplit, MTop, ON_DARK, inputStyle } from "@/components/m";
|
||||
import { readCount, writeCount } from "@/lib/opencount";
|
||||
|
||||
export default function MCounting() {
|
||||
const { s } = useSnap();
|
||||
const { L, byId, variants } = useDerived();
|
||||
const router = useRouter();
|
||||
const locationId = String(useParams().id || "");
|
||||
const locs = useMemo(() => locMap(s), [s]);
|
||||
const loc = locs[locationId];
|
||||
const locName = locationId === UNPLACED ? "Not on a shelf" : loc?.name || "Location";
|
||||
|
||||
// The lines on this shelf, in catalogue order.
|
||||
//
|
||||
// Being placed on the shelf is enough to be countable: a size placed from the desktop but never
|
||||
// stocked has no history at all, and filtering it out meant the six of them you have just found
|
||||
// on the shelf could not be counted in from the count that found them. The unplaced bucket still
|
||||
// needs the history test, or it would be the whole catalogue.
|
||||
//
|
||||
// The variance screen repeats this test verbatim, and the two have to keep listing the same
|
||||
// lines: anything countable here but missing there is counted on the phone and then dropped at
|
||||
// commit, with the tally cleared behind it and nothing said.
|
||||
const lines = useMemo(() => {
|
||||
const sub = locationId === UNPLACED ? null : locSubtree(s, locationId);
|
||||
return variants
|
||||
// A bound barcode counts as much as stock history does. Somebody stood at the counter with
|
||||
// the garment in one hand and scanned its label onto that size — that is a stronger statement
|
||||
// that the size physically exists than a stock figure, which on a room being set up is
|
||||
// precisely what nobody has yet. Without this the first count after building a catalogue can
|
||||
// reach nothing at all: every size is unplaced and untouched, so the list is empty and every
|
||||
// scan is refused as belonging somewhere else.
|
||||
.filter((v) => (sub ? sub.has(s.placed[v.key] || "") : !s.placed[v.key] && (touched(s, L, v.key) || !!bcBound(s, v.item, v.si))))
|
||||
.map((v) => ({ ...v, expected: onhand(s, L, v.key), code: bcBound(s, v.item, v.si), where: locUnder(locs, s.placed[v.key], locationId) }));
|
||||
}, [s, L, variants, locationId, locs]);
|
||||
|
||||
const [counted, setCounted] = useState<Record<string, number>>({});
|
||||
// The line being counted is held by its variant key, never by its position in `lines`. The list
|
||||
// is rebuilt on every live refresh, and a size sorting earlier in the catalogue being inserted
|
||||
// ahead of it would leave an index pointing at the neighbouring garment: the next Undo would then
|
||||
// take one off a line that was counted correctly and leave the double-scan where it was.
|
||||
const [activeKey, setActiveKey] = useState("");
|
||||
const [scan, setScan] = useState<null | "single" | "live">(null);
|
||||
const [live, setLive] = useState(false);
|
||||
const [log, setLog] = useState<string[]>([]);
|
||||
const [manual, setManual] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Restore this person's open count of this shelf. The tally is keyed on the signed-in user as
|
||||
// well as the location: the phone is shared, and resuming somebody else's abandoned count under
|
||||
// your own name is worse than starting again.
|
||||
//
|
||||
// It reads once per shelf and deliberately does not re-run on `lines`. The list is rebuilt on
|
||||
// every live refresh, and rebuilding the tally from it dropped any key that had just left this
|
||||
// shelf — the coordinator placing a size from the desktop while the trolley is being counted —
|
||||
// which the write below then made permanent. The garments the counter had already found went
|
||||
// with it, silently. Counts are held by key whether or not the key is still listed here.
|
||||
const me = s.session.userId;
|
||||
useEffect(() => {
|
||||
setCounted(readCount(me, locationId)?.n ?? {});
|
||||
setLoaded(true);
|
||||
}, [me, locationId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded) return;
|
||||
writeCount(me, locationId, counted);
|
||||
}, [counted, me, locationId, loaded]);
|
||||
|
||||
useKeepAwake(true);
|
||||
|
||||
const total = lines.reduce((t, l) => t + (counted[l.key] ?? 0), 0);
|
||||
const expectedAll = lines.reduce((t, l) => t + l.expected, 0);
|
||||
const cur = lines.find((l) => l.key === activeKey) || lines[0];
|
||||
|
||||
const bump = useCallback((k: string, by: number) => {
|
||||
setCounted((c) => ({ ...c, [k]: Math.max(0, (c[k] ?? 0) + by) }));
|
||||
}, []);
|
||||
|
||||
/** A scanned code lands on its own line, whichever line was active — the barcode is the truth. */
|
||||
const onCode = useCallback((raw: string) => {
|
||||
const code = raw.trim();
|
||||
const hit = s.barcodes[code];
|
||||
const ix = hit ? lines.findIndex((l) => l.key === hit) : lines.findIndex((l) => l.code === code);
|
||||
if (ix < 0) {
|
||||
scanReject();
|
||||
const known = Object.prototype.hasOwnProperty.call(s.barcodes, code);
|
||||
// Never the barcode itself — only whether ThreadCount knew it. "unknown" in volume means
|
||||
// labels are being printed outside the catalogue.
|
||||
track("scan_miss", { kind: known ? "wrong_shelf" : "unknown" });
|
||||
/* "Somewhere else" is only true when it IS somewhere. A code bound to a size that has never
|
||||
been placed and never been stocked is on no shelf at all, and telling somebody to go and
|
||||
look for it elsewhere sends them hunting for a garment nothing has ever recorded. Say which
|
||||
of the two it is, and name the shelf when there is one to name. */
|
||||
const placedAt = hit ? locs[s.placed[hit] || ""]?.name : "";
|
||||
setErr(!known ? `${code} isn’t a garment ThreadCount knows. Bind it to a size first — you can type it in on the garment’s page.`
|
||||
: placedAt ? `${code} is on ${placedAt}, not this shelf.`
|
||||
: `${code} isn’t in this count. It hasn’t been placed on a shelf, so it sits under “Not on a shelf”.`);
|
||||
setLog((g) => [`${code} — not on this shelf`, ...g]);
|
||||
return;
|
||||
}
|
||||
setActiveKey(lines[ix].key);
|
||||
bump(lines[ix].key, 1);
|
||||
setErr("");
|
||||
setLog((g) => [`${variantName(byId[lines[ix].itemId], lines[ix].size)}`, ...g].slice(0, 8));
|
||||
}, [s.barcodes, lines, bump, byId]);
|
||||
|
||||
if (loaded && !lines.length) {
|
||||
return (
|
||||
<>
|
||||
<MTop title={locName} back />
|
||||
<MRule />
|
||||
<MBody><MEmpty title="Nothing on this shelf" sub="No garment has been placed here yet. Place sizes against a location from Inventory on the desktop, then come back." /></MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title={locName} right={`${total} / ${expectedAll}`} back />
|
||||
<MRule n={total} of={expectedAll} />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
|
||||
{cur && (
|
||||
<MPanel kicker="Now counting" kickerRight={<MInkLink label="Hands-free" onClick={() => { setScan("live"); setLive(true); }} />}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 26, letterSpacing: "-0.02em", lineHeight: 1.1 }}>
|
||||
{variantName(byId[cur.itemId], cur.size)}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: ON_DARK, marginTop: 6 }}>
|
||||
{[cur.code || (cur.item.sku ? `SKU ${cur.item.sku}` : "No barcode bound"), cur.where].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
<MFigures counted={counted[cur.key] ?? 0} expected={cur.expected} />
|
||||
</MPanel>
|
||||
)}
|
||||
|
||||
<MSplit>
|
||||
<MAction label="Scan" flex={2} glyph="scan" onClick={() => setScan("single")} />
|
||||
<MAction label="Undo" flex={1} tone="grey" onClick={() => cur && bump(cur.key, -1)} disabled={!cur || (counted[cur.key] ?? 0) <= 0} />
|
||||
</MSplit>
|
||||
|
||||
<MBody>
|
||||
<div ref={listRef}>
|
||||
<MSection label="Lines" right="Counted / expected" />
|
||||
{lines.map((l) => {
|
||||
const n = counted[l.key] ?? 0;
|
||||
const on = !!cur && l.key === cur.key;
|
||||
return (
|
||||
<MRow key={l.key} onClick={() => setActiveKey(l.key)} attention={on}
|
||||
mark={on ? "accent" : n === l.expected ? "ink" : "mute"}
|
||||
title={`${variantName(byId[l.itemId], l.size)}`}
|
||||
sub={[l.code || (l.item.sku ? `SKU ${l.item.sku}` : "No barcode bound"), l.where].filter(Boolean).join(" · ")}
|
||||
right={
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, fontVariantNumeric: "tabular-nums" }}>
|
||||
{/* The expected figure is the whole point of the row, so it is readable ink,
|
||||
not the near-invisible neutral-400 it used to be drawn in. */}
|
||||
{n}<span style={{ color: "var(--color-neutral-700)" }}>/{l.expected}</span>
|
||||
</span>
|
||||
} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 16 }}>
|
||||
{manual && cur ? (
|
||||
<div style={{ border: "2px solid " + INK, background: "#fff", padding: 16 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>Counted for {variantName(byId[cur.itemId], cur.size)}</div>
|
||||
{/* Keyed on the line so the box is rebuilt when the counter taps a different one. An
|
||||
uncontrolled input keeps its first value, so it went on showing the figure typed
|
||||
for the previous line under the new line's heading — read as "counted at 7", the
|
||||
new line was then committed at 0 and the gap blamed on the shelf. */}
|
||||
<input key={cur.key} type="number" inputMode="numeric" min={0} defaultValue={counted[cur.key] ?? 0} autoFocus style={{ ...inputStyle, marginTop: 8 }}
|
||||
onChange={(e) => setCounted((c) => ({ ...c, [cur.key]: Math.max(0, parseInt(e.target.value || "0", 10) || 0) }))} />
|
||||
<button onClick={() => setManual(false)} style={{ marginTop: 12, minHeight: 44, width: "100%", border: "2px solid " + INK, background: "transparent", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>Done</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setManual(true)} style={{ background: "none", border: 0, padding: "8px 0", color: "var(--color-accent-700)", fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>
|
||||
Type a count instead — for a label that won’t scan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</MBody>
|
||||
|
||||
<MAction label="Finish count" glyph="none" onClick={() => router.push(`/m/count/${locationId}/variance`)} />
|
||||
|
||||
{scan && (
|
||||
<MScan
|
||||
title="Scan a garment"
|
||||
live={scan === "live"}
|
||||
running={live}
|
||||
onToggle={() => setLive((v) => !v)}
|
||||
log={log}
|
||||
onHit={(raw) => { onCode(raw); if (scan === "single") setScan(null); }}
|
||||
onClose={() => { setScan(null); setLive(false); }}
|
||||
figure={scan === "live" && cur ? (
|
||||
<MPanel pad={14}>
|
||||
<div style={{ fontSize: 13, color: ON_DARK }}>{variantName(byId[cur.itemId], cur.size)}</div>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 14, marginTop: 4 }}>
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 40, lineHeight: 1, fontVariantNumeric: "tabular-nums" }}>{counted[cur.key] ?? 0}</span>
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 22, color: "var(--color-neutral-300)", fontVariantNumeric: "tabular-nums" }}>{cur.expected}</span>
|
||||
<span style={{ marginLeft: "auto", fontSize: 12, color: ON_DARK }}>{total} / {expectedAll} on this shelf</span>
|
||||
</div>
|
||||
</MPanel>
|
||||
) : undefined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
/* Variance — only the lines that don't match, what happens when the count commits, and the commit.
|
||||
A gap at or over the facility's threshold has to carry a reason before anything is filed. */
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { UNPLACED, bcBound, formatInZone, locMap, locSubtree, locUnder, onhand, reorderAt, touched, variantName } from "@/lib/compute";
|
||||
import { INK, MBar, MBody, MEmpty, MError, MRule, MTop, MPanel, MInkLink } from "@/components/m";
|
||||
import { clearCount, readCount } from "@/lib/opencount";
|
||||
|
||||
const REASONS = ["At laundry", "Condemned", "Missing", "Other"];
|
||||
|
||||
export default function MVariance() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
const { L, byId, variants } = useDerived();
|
||||
const router = useRouter();
|
||||
const locationId = String(useParams().id || "");
|
||||
const locs = useMemo(() => locMap(s), [s]);
|
||||
const locName = locationId === UNPLACED ? "Not on a shelf" : locs[locationId]?.name || "Location";
|
||||
|
||||
const lines = useMemo(() => {
|
||||
const sub = locationId === UNPLACED ? null : locSubtree(s, locationId);
|
||||
// Exactly the set the counting screen lists, and it has to stay the same test. A placed size
|
||||
// counts even with no history, and so does an unplaced size with a barcode bound to it —
|
||||
// somebody stood at the counter and scanned that label onto that size, which is why the
|
||||
// counting screen lets you count it. Leave that arm off here and a size counted on the phone
|
||||
// has no row on this screen and no line in the payload: committing files a stocktake without
|
||||
// it, the garments found on the trolley are never counted in, and clearCount() then wipes the
|
||||
// tally that was the only record they had been found.
|
||||
return variants
|
||||
.filter((v) => (sub ? sub.has(s.placed[v.key] || "") : !s.placed[v.key] && (touched(s, L, v.key) || !!bcBound(s, v.item, v.si))))
|
||||
.map((v) => ({ ...v, expected: onhand(s, L, v.key), code: bcBound(s, v.item, v.si), where: locUnder(locs, s.placed[v.key], locationId) }));
|
||||
}, [s, L, variants, locationId, locs]);
|
||||
|
||||
const [counted, setCounted] = useState<Record<string, number> | null>(null);
|
||||
const [savedAt, setSavedAt] = useState("");
|
||||
const [reason, setReason] = useState<Record<string, string>>({});
|
||||
const [accepted, setAccepted] = useState<Record<string, boolean>>({});
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
// The tally belongs to the person who took it, so it is read back under their own key — the
|
||||
// counting screen writes it under theirs. When it was taken matters as much as what it says:
|
||||
// a count resumed the next morning has had a night of issuing against it, and the screen should
|
||||
// say when it was last touched rather than present a stale tally as if it were fresh.
|
||||
const me = s.session.userId;
|
||||
useEffect(() => {
|
||||
const open = readCount(me, locationId);
|
||||
setCounted(open?.n ?? {});
|
||||
setSavedAt(open?.savedAt ?? "");
|
||||
}, [me, locationId]);
|
||||
|
||||
const gate = Math.max(1, s.settings.varianceReason);
|
||||
const off = useMemo(() => (counted ? lines.filter((l) => (counted[l.key] ?? 0) !== l.expected) : []), [counted, lines]);
|
||||
const totalCounted = counted ? lines.reduce((t, l) => t + (counted[l.key] ?? 0), 0) : 0;
|
||||
const totalExpected = lines.reduce((t, l) => t + l.expected, 0);
|
||||
const needsReason = off.filter((l) => Math.abs((counted?.[l.key] ?? 0) - l.expected) >= gate && !reason[l.key]);
|
||||
|
||||
// What the shelf will look like once this commits — not what the commit does. Committing a count
|
||||
// writes stock adjustments and the stocktake itself and nothing else; the reorder draft is a
|
||||
// separate, deliberate step on Reorder, which is where the quantities can still be changed
|
||||
// before anything goes to a supplier.
|
||||
const willReorder = useMemo(() => {
|
||||
if (!counted) return { lines: 0, units: 0 };
|
||||
let n = 0, units = 0;
|
||||
for (const l of lines) {
|
||||
const after = counted[l.key] ?? 0;
|
||||
const par = reorderAt(s, l.key);
|
||||
if (after <= par && l.expected > par) { n++; units += Math.max(0, par * 2 - after); }
|
||||
}
|
||||
return { lines: n, units };
|
||||
}, [counted, lines, s]);
|
||||
|
||||
const commit = useCallback(async () => {
|
||||
if (!counted) return;
|
||||
if (needsReason.length) { setErr(`A gap of ${gate} or more needs a reason — ${needsReason.length} line${needsReason.length === 1 ? "" : "s"} still to go.`); return; }
|
||||
const payload = lines.map((l) => ({ itemId: l.itemId, si: l.si, counted: counted[l.key] ?? 0, reason: reason[l.key] || "" }));
|
||||
const r = await mutate("stocktake.apply", { lines: payload, mode: "shelf", locationId: locationId === UNPLACED ? "" : locationId });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
clearCount(me, locationId);
|
||||
// A count that leaves lines below par hands straight over to Reorder. Nothing is drafted by
|
||||
// the commit itself, and a count that ends on the home screen is a count whose shortfall
|
||||
// nobody ever goes back for.
|
||||
router.push(willReorder.lines > 0 ? "/m/reorder" : "/m?counted=1");
|
||||
}, [counted, lines, reason, needsReason.length, gate, mutate, me, locationId, router, willReorder.lines]);
|
||||
|
||||
if (!counted) return (<><MTop title="Variance" back /><MRule /><MBody /></>);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Variance" back />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
|
||||
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 30, letterSpacing: "-0.03em", lineHeight: 1.05 }}>
|
||||
{off.length === 0 ? "Everything matches" : `${off.length} line${off.length === 1 ? "" : "s"} don’t match`}
|
||||
</h2>
|
||||
<p style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 8 }}>{locName} · counted {totalCounted} of {totalExpected} expected</p>
|
||||
{savedAt && (
|
||||
<p style={{ fontSize: 13, color: "var(--color-neutral-700)", marginTop: 4 }}>
|
||||
Tallied {formatInZone(savedAt, s.tz, { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" })}.
|
||||
{" "}Anything issued since then is already off the expected figure.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{off.length === 0 ? (
|
||||
<MEmpty title="No gaps to explain" sub="Every line came out at what the system expected. Commit the count to file it against this shelf." />
|
||||
) : off.map((l) => {
|
||||
const n = counted[l.key] ?? 0;
|
||||
const d = n - l.expected;
|
||||
const big = Math.abs(d) >= gate;
|
||||
return (
|
||||
<div key={l.key} style={{ padding: 16, background: "#fff", borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, letterSpacing: "-0.02em" }}>{variantName(byId[l.itemId], l.size)}</div>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 4 }}>{[l.code || (l.item.sku ? `SKU ${l.item.sku}` : "No barcode bound"), l.where].filter(Boolean).join(" · ")}</div>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em", color: "var(--color-accent-700)", fontVariantNumeric: "tabular-nums" }}>{d > 0 ? `+${d}` : `−${-d}`}</div>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 2 }}>{n} of {l.expected}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 14 }}>
|
||||
<button onClick={() => router.push(`/m/count/${locationId}`)}
|
||||
style={{ flex: 1, minHeight: 44, border: "2px solid " + INK, background: "transparent", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>Recount</button>
|
||||
<button onClick={() => setAccepted((a) => ({ ...a, [l.key]: !a[l.key] }))} aria-pressed={!!accepted[l.key]}
|
||||
style={{ flex: 1, minHeight: 44, border: "2px solid " + INK, background: accepted[l.key] ? INK : "transparent", color: accepted[l.key] ? "var(--color-bg)" : INK, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>
|
||||
{accepted[l.key] ? "Accepted" : "Accept"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{big && (
|
||||
<div style={{ marginTop: 14, padding: 14, background: "var(--color-bg)" }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-accent-700)" }}>
|
||||
A gap of {gate} or more needs a reason
|
||||
</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 10 }}>
|
||||
{REASONS.map((r) => {
|
||||
const on = reason[l.key] === r;
|
||||
return (
|
||||
<button key={r} onClick={() => setReason((x) => ({ ...x, [l.key]: on ? "" : r }))} aria-pressed={on}
|
||||
style={{ minHeight: 44, padding: "0 14px", border: "2px solid " + INK, background: on ? INK : "transparent", color: on ? "var(--color-bg)" : INK, fontSize: 13, fontWeight: 700, cursor: "pointer" }}>{r}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div style={{ padding: 16 }}>
|
||||
<MPanel kicker="After this count">
|
||||
<p style={{ fontSize: 14, lineHeight: 1.6, margin: 0 }}>
|
||||
{willReorder.lines === 0
|
||||
? "Nothing falls below par when this commits, so there is nothing to reorder."
|
||||
: `${willReorder.lines} line${willReorder.lines === 1 ? "" : "s"} will be below par once this count commits — about ${willReorder.units} item${willReorder.units === 1 ? "" : "s"} to order. Committing orders nothing on its own: it takes you to Reorder, where you raise the draft.`}
|
||||
</p>
|
||||
<p style={{ fontSize: 13, lineHeight: 1.6, marginTop: 10, color: "var(--color-neutral-400)" }}>Nothing is sent to a supplier without approval.</p>
|
||||
{willReorder.lines > 0 && <div style={{ marginTop: 14 }}><MInkLink label="Reorder" href="/m/reorder" /></div>}
|
||||
</MPanel>
|
||||
</div>
|
||||
</MBody>
|
||||
<MBar label={busy ? "Committing…" : "Commit count"} glyph="check" onClick={commit} disabled={busy || needsReason.length > 0}
|
||||
sub={needsReason.length ? `${needsReason.length} gap${needsReason.length === 1 ? " still needs" : "s still need"} a reason` : undefined} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
/* Stocktake — choose what you're counting. One row per location that actually holds garments,
|
||||
plus everything not yet placed, so nothing on the shelf is uncountable. */
|
||||
import { useMemo } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { UNPLACED, bcBound, daysBetween, locSubtree, locTree, onhand, touched } from "@/lib/compute";
|
||||
import { INK, MBody, MEmpty, MNav, MNote, MRow, MRule, MSection, MTop } from "@/components/m";
|
||||
|
||||
/* "Last counted 0 days ago" and "1 days ago" are how a shelf counted this morning used to read. */
|
||||
function lastCounted(last: string | undefined, today: string): string {
|
||||
if (!last) return "Never counted";
|
||||
const n = daysBetween(last, today);
|
||||
if (n <= 0) return "Counted today";
|
||||
if (n === 1) return "Counted yesterday";
|
||||
return `Last counted ${n} days ago`;
|
||||
}
|
||||
|
||||
export default function MCountStart() {
|
||||
const { s } = useSnap();
|
||||
const { L, variants } = useDerived();
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const lastAt: Record<string, string> = {};
|
||||
for (const t of s.stocktakes) if (t.mode !== "preloved" && t.locationId && !lastAt[t.locationId]) lastAt[t.locationId] = t.date;
|
||||
const out = locTree(s).map(({ loc, depth }) => {
|
||||
const sub = locSubtree(s, loc.id);
|
||||
// Placed on the shelf is enough to make a shelf countable. A location holding only sizes
|
||||
// that have never been stocked is exactly the shelf someone needs to count in.
|
||||
const mine = variants.filter((v) => sub.has(s.placed[v.key] || ""));
|
||||
return { id: loc.id, name: loc.name, kind: loc.kind, depth, lines: mine.length, units: mine.reduce((t, v) => t + onhand(s, L, v.key), 0), last: lastAt[loc.id] as string | undefined };
|
||||
}).filter((r) => r.lines > 0);
|
||||
// Only the unplaced bucket needs a test at all — without one it would list the whole
|
||||
// catalogue. A bound barcode counts as much as stock history does: somebody stood at the
|
||||
// counter with the garment in hand and scanned its label onto that size, which says the size
|
||||
// physically exists even when no stock figure does. The counting and variance screens filter
|
||||
// the unplaced bucket with exactly this expression and all three have to agree — a room whose
|
||||
// unplaced sizes are all barcode-bound and never yet stocked otherwise gets no "Not on a shelf
|
||||
// yet" row here, and the one screen that could count them in is unreachable from the menu.
|
||||
const loose = variants.filter((v) => !s.placed[v.key] && (touched(s, L, v.key) || !!bcBound(s, v.item, v.si)));
|
||||
if (loose.length) out.push({ id: UNPLACED, name: "Not on a shelf yet", kind: "", depth: 0, lines: loose.length, units: loose.reduce((t, v) => t + onhand(s, L, v.key), 0), last: undefined });
|
||||
return out;
|
||||
}, [s, L, variants]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Stocktake" right={`${rows.length} location${rows.length === 1 ? "" : "s"}`} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
|
||||
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 30, letterSpacing: "-0.03em", lineHeight: 1.05 }}>Where are you counting?</h2>
|
||||
<p style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 10, lineHeight: 1.6 }}>
|
||||
Scan every garment on the shelf. Each scan adds one to that line, and the expected figure stays on screen the whole way.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<MEmpty
|
||||
title="Nothing to count yet"
|
||||
sub="A location shows up here once garments are placed on it. Set your shelves up in Settings on the desktop, then place each size against one."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<MSection label="Locations" right="Lines · units" />
|
||||
{rows.map((r) => (
|
||||
<MRow key={r.id} href={`/m/count/${r.id}`} mark={r.id === UNPLACED ? "mute" : "ink"}
|
||||
title={<span style={{ paddingLeft: r.depth * 14 }}>{r.name}</span>}
|
||||
sub={<span style={{ paddingLeft: r.depth * 14 }}>{lastCounted(r.last, s.today)}</span>}
|
||||
right={<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, fontVariantNumeric: "tabular-nums" }}>{r.lines} · {r.units}</span>} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<MNote>A count stays open until you commit it, so you can put the phone down halfway along a shelf and pick it up again.</MNote>
|
||||
</MBody>
|
||||
<MNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
/* Issue — 1B, person first. Their sizes are already known, so the list is what they'd normally take;
|
||||
scanning adds anything else. What they may hold and the manager’s approval are both checked before
|
||||
the bag is handed over, not after. */
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { approvalRemaining, capCheck, garmentForGroup, garmentForStyle, genderLabel, groupBucket, groupsLabel, inBucket, initialRemaining, isNursing, isPantItem, isTopItem, label, money, onhand, sizeIndexOf, splitKey, variantName } from "@/lib/compute";
|
||||
import MScan from "@/components/MScan";
|
||||
import { INK, MBar, MBody, MEmpty, MError, MRow, MRule, MSection, MTop, MStepper } from "@/components/m";
|
||||
import { MEntitlement, MPersonHead, useHeld } from "@/components/MPerson";
|
||||
|
||||
type Line = { key: string; itemId: string; si: number; size: string; name: string; qty: number; cost: number; onHand: number };
|
||||
|
||||
export default function MIssue() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
const { L, byId } = useDerived();
|
||||
const router = useRouter();
|
||||
const id = String(useParams().staffId || "");
|
||||
const st = s.staff.find((x) => x.id === id);
|
||||
const held = useHeld(s, id);
|
||||
|
||||
const [cart, setCart] = useState<Line[]>([]);
|
||||
const [scan, setScan] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [override, setOverride] = useState(false);
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
|
||||
/* What this person would normally be handed: their group’s garments, in the cut they are offered,
|
||||
in their recorded size. Both questions are the server's own — a rule written again here would
|
||||
suggest a garment the counter then refuses. Blank and Either are offered every cut. */
|
||||
const suggested = useMemo(() => {
|
||||
if (!st) return [];
|
||||
const bucket = groupBucket(st.group);
|
||||
const out: Line[] = [];
|
||||
for (const it of s.catalog) {
|
||||
if (it.archived) continue;
|
||||
if (bucket && !inBucket(it, bucket)) continue;
|
||||
if (!garmentForStyle(it, st.uniformStyle)) continue;
|
||||
const want = isTopItem(it) ? st.top : isPantItem(it) ? st.pants : "";
|
||||
const si = want ? sizeIndexOf(it, want) : -1;
|
||||
if (si < 0) continue;
|
||||
const k = `${it.id}:${si}`;
|
||||
out.push({ key: k, itemId: it.id, si, size: String(it.sizes[si]), name: `${variantName(it, it.sizes[si])}`, qty: 1, cost: it.cost, onHand: onhand(s, L, k) });
|
||||
}
|
||||
return out;
|
||||
}, [s, st, L]);
|
||||
|
||||
const inCart = useCallback((k: string) => cart.find((c) => c.key === k), [cart]);
|
||||
const add = useCallback((l: Line) => {
|
||||
setErr("");
|
||||
setCart((c) => {
|
||||
const at = c.findIndex((x) => x.key === l.key);
|
||||
if (at < 0) return [...c, { ...l, qty: 1 }];
|
||||
const next = [...c]; next[at] = { ...next[at], qty: next[at].qty + 1 }; return next;
|
||||
});
|
||||
}, []);
|
||||
const setQty = useCallback((k: string, n: number) => setCart((c) => (n <= 0 ? c.filter((x) => x.key !== k) : c.map((x) => (x.key === k ? { ...x, qty: n } : x)))), []);
|
||||
|
||||
const onCode = useCallback((raw: string) => {
|
||||
const k = s.barcodes[raw.trim()];
|
||||
if (!k) { setErr(`${raw.trim()} isn’t a garment ThreadCount knows.`); return; }
|
||||
const { itemId, si } = splitKey(k);
|
||||
const it = byId[itemId];
|
||||
if (!it || it.archived) { setErr("That garment is discontinued."); return; }
|
||||
add({ key: k, itemId, si, size: String(it.sizes[si]), name: `${variantName(it, it.sizes[si])}`, qty: 1, cost: it.cost, onHand: onhand(s, L, k) });
|
||||
}, [s, byId, L, add]);
|
||||
|
||||
if (!st) return (<><MTop title="Issue" back /><MRule /><MBody><MEmpty title="No such staff member" /></MBody></>);
|
||||
|
||||
const cartQty = cart.reduce((t, c) => t + c.qty, 0);
|
||||
const heldQty = held.reduce((t, h) => t + h.qty, 0);
|
||||
const total = cart.reduce((t, c) => t + c.qty * c.cost, 0);
|
||||
const nursing = isNursing(s, st);
|
||||
/* The one question this screen asks: after this bag, is this person still inside the six sets one
|
||||
person holds? Six at any time, every group, nursing included — so the sum is what they have out
|
||||
now plus what is on the counter, and nothing in it starts again in July. It is the server's own
|
||||
function, so the warning here and the refusal there cannot drift apart; the last time this screen
|
||||
kept a private copy of the sum it demanded a tick the server never wanted. */
|
||||
const cap = capCheck(s, st, cart);
|
||||
const over = cap.over;
|
||||
/* Garments in the cart that are not for this person's staff group, and garments that are not the
|
||||
cut they are offered. The server refuses either without the coordinator override, and records
|
||||
them as outside the group or outside the style rather than as over the ceiling, so the same tick
|
||||
is offered for any of the three reasons. garmentForGroup() and garmentForStyle() are the
|
||||
server's own questions, asked here so the screen and the refusal cannot drift apart. */
|
||||
const cartItems = [...new Set(cart.map((c) => c.itemId))].map((iid) => byId[iid])
|
||||
.filter((it): it is NonNullable<typeof it> => !!it);
|
||||
const offGroup = cartItems.filter((it) => !garmentForGroup(it, st.group));
|
||||
const offStyle = cartItems.filter((it) => !garmentForStyle(it, st.uniformStyle));
|
||||
/* One refusal naming every reason that applies, composed as the server composes it: a clause per
|
||||
reason, the ceiling among them, and the sentence about the tick once at the end, because one
|
||||
tick answers all of them. A message that named the first and stopped would have the coordinator
|
||||
tick for that and wave the rest through without anybody having been told about them. The count
|
||||
is of distinct garments across both lists — one garment wrong on both counts is still "it". */
|
||||
const wrongCount = new Set([...offGroup, ...offStyle].map((it) => it.id)).size;
|
||||
const wrongNote = wrongCount
|
||||
? `${[
|
||||
offGroup.length ? `${offGroup.map((it) => `${it.item} is for ${groupsLabel(it.groups)}`).join("; ")} — ${(st.group || "").trim() ? `${st.first} ${st.last} is in ${st.group.trim()}` : `${st.first} ${st.last} has no staff group recorded`}` : "",
|
||||
offStyle.length ? `${offStyle.map((it) => `${it.item} is the ${genderLabel(it.gender)} cut`).join("; ")} — ${st.first} ${st.last} is set to ${st.uniformStyle}` : "",
|
||||
over ? `It would also take them past what one person holds: ${cap.note}` : "",
|
||||
].filter(Boolean).join(". ")}. Tick the coordinator override to issue ${wrongCount === 1 ? "it" : "them"} anyway.`
|
||||
: "";
|
||||
const overrideWhy = [offGroup.length ? "outside their staff group" : "", offStyle.length ? "outside their uniform style" : "", over ? "above what one person holds" : ""]
|
||||
.filter(Boolean).reduce((a, b, i, all) => (i === 0 ? b : i === all.length - 1 ? `${a} and ${b}` : `${a}, ${b}`), "");
|
||||
/* Garments of the starting kit this record still owes. What they are owed on starting, said on the
|
||||
shelf list below — never a term in whether this collection is allowed. A new starter holds
|
||||
nothing and takes three sets, and three is inside six, so the kit that used to need a coordinator
|
||||
override to hand over now goes through as the ordinary first issue it always was. */
|
||||
const kitLeft = initialRemaining(s, st) ?? 0;
|
||||
const sets = approvalRemaining(s, st.id);
|
||||
/* A manager’s approval is counted in SETS — one top and one pair of trousers — so a set is spent per top
|
||||
or per pair of trousers, whichever side of the pair is bigger, and never by anything else. A
|
||||
jacket, a vest or maternity wear is neither half of a set and costs the ward nothing off the
|
||||
approval. This must stay identical to the desktop Issue screen: counting garments instead of
|
||||
sets here quietly spent a whole approved set on a single fleece, and spent only half of what
|
||||
the manager signed for when someone took four tops. It is a separate control from the six sets
|
||||
anybody may hold: the approval is what pays for the garments, the ceiling is how much uniform one
|
||||
person walks around with, and a nurse has to satisfy both. */
|
||||
const cartTops = cart.reduce((t, c) => t + (isTopItem(byId[c.itemId]) ? c.qty : 0), 0);
|
||||
const cartPants = cart.reduce((t, c) => t + (isPantItem(byId[c.itemId]) ? c.qty : 0), 0);
|
||||
const short = cart.find((c) => c.qty > c.onHand);
|
||||
/* What they hold against the ceiling, said in the section headers that are already on the screen.
|
||||
Without it the counter can’t tell a new starter collecting the kit they’re owed from somebody
|
||||
drawing a seventh set. */
|
||||
const holdsRight = `${cap.sets}/${cap.cap} sets · ${heldQty} item${heldQty === 1 ? "" : "s"}`;
|
||||
const notYetLabel = kitLeft > 0 ? `Starting kit — ${kitLeft} still to issue`
|
||||
: "Their size, not yet issued";
|
||||
|
||||
const commit = async () => {
|
||||
if (!cart.length) return;
|
||||
if (short) { setErr(`Only ${short.onHand} of ${short.name} on the shelf.`); return; }
|
||||
// The reason comes from the same function the server refuses with, so nobody is told one thing
|
||||
// here and another when they press the button.
|
||||
if (wrongCount && !override) { setErr(wrongNote); return; }
|
||||
if (over && !override) { setErr(cap.note); return; }
|
||||
const r = await mutate<{ stock: number; apDeducted: number; apRemaining: number }>("issue.create", {
|
||||
// The tick and nothing else. An override is a record that somebody knowingly bent a rule, so
|
||||
// only somebody may set it: a new starter collecting the kit they are owed has bent nothing,
|
||||
// and it now goes through on its own merits.
|
||||
staffId: st.id, override, apDeduct: nursing ? Math.min(sets, Math.max(cartTops, cartPants)) : 0,
|
||||
lines: cart.map((c) => ({ itemId: c.itemId, si: c.si, qty: c.qty, src: "stock" })),
|
||||
});
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setDone(`${cartQty} item${cartQty === 1 ? "" : "s"} issued to ${st.first} ${st.last}.`);
|
||||
setCart([]);
|
||||
};
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Issued" />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<MEmpty title={done} sub="A replenishment draft has been topped up on Ordering. Nothing is sent to a supplier without approval." />
|
||||
</MBody>
|
||||
<MBar label="Back to the person" href={`/m/person/${st.id}`} glyph="arrow" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Issue" back right={cartQty ? `${cartQty} to issue` : undefined} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
<MPersonHead s={s} st={st} sub={<MEntitlement s={s} st={st} cart={cart} />} />
|
||||
|
||||
{cart.length > 0 && (
|
||||
<>
|
||||
<MSection label="Issuing now" right={money(total)} />
|
||||
{cart.map((c) => (
|
||||
<MRow key={c.key} mark="accent" attention title={c.name} sub={`${money(c.cost)} · ${c.onHand} on the shelf`}
|
||||
right={<MStepper n={c.qty} onChange={(n) => setQty(c.key, n)} max={Math.max(1, c.onHand)} />} />
|
||||
))}
|
||||
{(over || wrongCount > 0) && (
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 10, padding: "14px 16px", background: "#fff", borderBottom: "1px solid var(--color-divider)", fontSize: 14 }}>
|
||||
<input type="checkbox" checked={override} onChange={(e) => setOverride(e.target.checked)} style={{ width: 22, height: 22 }} />
|
||||
<span>Coordinator override — record this {overrideWhy}</span>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<MSection label="Currently holds" right={holdsRight} />
|
||||
{held.length === 0 && <div style={{ padding: "18px 16px", fontSize: 14, color: "var(--color-neutral-600)" }}>Nothing out at the moment.</div>}
|
||||
{held.map((h) => {
|
||||
const it = byId[h.itemId];
|
||||
const k = h.key;
|
||||
return (
|
||||
<MRow key={k} title={h.name} sub={`${h.qty} held`}
|
||||
right={<button onClick={() => add({ key: k, itemId: h.itemId, si: h.si, size: h.size, name: h.name, qty: 1, cost: it?.cost ?? 0, onHand: onhand(s, L, k) })}
|
||||
style={{ width: 56, height: 44, border: "2px solid " + INK, background: inCart(k) ? INK : "transparent", color: inCart(k) ? "var(--color-bg)" : INK, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, cursor: "pointer" }}>+ 1</button>} />
|
||||
);
|
||||
})}
|
||||
|
||||
{suggested.filter((l) => !held.some((h) => h.key === l.key)).length > 0 && (
|
||||
<>
|
||||
<MSection label={notYetLabel} />
|
||||
{suggested.filter((l) => !held.some((h) => h.key === l.key)).map((l) => (
|
||||
<MRow key={l.key} attention mark="accent" title={l.name}
|
||||
sub={<span style={{ color: l.onHand > 0 ? "var(--color-accent-700)" : "var(--color-neutral-600)" }}>{l.onHand > 0 ? "Not yet issued" : "None on the shelf"}</span>}
|
||||
right={<button onClick={() => add(l)} disabled={l.onHand <= 0}
|
||||
style={{ width: 56, height: 44, border: "2px solid " + INK, background: inCart(l.key) ? INK : "transparent", color: inCart(l.key) ? "var(--color-bg)" : INK, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, cursor: l.onHand > 0 ? "pointer" : "not-allowed", opacity: l.onHand > 0 ? 1 : 0.4 }}>+ 1</button>} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ padding: "18px 16px 24px", fontSize: 14, color: "var(--color-neutral-700)" }}>Scan to add anything not on this list.</div>
|
||||
</MBody>
|
||||
|
||||
{cart.length === 0
|
||||
? <MBar label="Scan to add" glyph="scan" onClick={() => setScan(true)} />
|
||||
: <MBar label={busy ? "Recording…" : `Issue ${cartQty} item${cartQty === 1 ? "" : "s"}`} glyph="check" onClick={commit} disabled={busy} sub={money(total)} />}
|
||||
|
||||
{scan && <MScan title="Scan a garment" onHit={(r) => { onCode(r); setScan(false); }} onClose={() => setScan(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
/* Issue starts with the person: their sizes, allowance and approvals all hang off the record,
|
||||
so choosing them first is what lets the app check an issue before the garments leave the shelf. */
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { ccOf, staffName } from "@/lib/compute";
|
||||
import { INK, MBody, MEmpty, MRow, MRule, MSection, MTop, inputStyle } from "@/components/m";
|
||||
|
||||
export default function MIssuePick() {
|
||||
const { s } = useSnap();
|
||||
const [q, setQ] = useState("");
|
||||
|
||||
const list = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
const active = s.staff.filter((x) => !x.inactive);
|
||||
if (!needle) {
|
||||
// No query: whoever was served most recently, so the usual faces are one tap away.
|
||||
const seen: Record<string, string> = {};
|
||||
for (const i of s.issues) seen[i.staffId] = i.date > (seen[i.staffId] || "") ? i.date : seen[i.staffId];
|
||||
return [...active].sort((a, b) => (seen[b.id] || "").localeCompare(seen[a.id] || "")).slice(0, 12);
|
||||
}
|
||||
return active.filter((x) => `${x.first} ${x.last} ${x.num} ${x.dept}`.toLowerCase().includes(needle)).slice(0, 40);
|
||||
}, [s, q]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Issue" back right={`${s.staff.filter((x) => !x.inactive).length} on the register`} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: 16, borderBottom: "2px solid " + INK }}>
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name or staff number" autoFocus
|
||||
aria-label="Search the staff register" style={inputStyle} />
|
||||
</div>
|
||||
<MSection label={q.trim() ? "Matches" : "Recently served"} />
|
||||
{list.length === 0
|
||||
? <MEmpty title="Nobody matches that" sub="Try a surname or a staff number. New starters are added on the desktop." />
|
||||
: list.map((st) => (
|
||||
<MRow key={st.id} href={`/m/issue/${st.id}`} mark="accent"
|
||||
title={staffName(st)}
|
||||
sub={[st.num, st.dept || st.group, ccOf(s, st) && `CC ${ccOf(s, st)}`].filter(Boolean).join(" · ")} />
|
||||
))}
|
||||
</MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
/* Reprint a label. Short by design: it exists because a garment nobody can scan silently vanishes
|
||||
from every count. Only sizes with a real supplier barcode can be reprinted — ThreadCount's
|
||||
internal fallback code appears nowhere on a garment, so printing it would help nobody. */
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { bcBound, label, variantName } from "@/lib/compute";
|
||||
import { isNative } from "@/lib/nativescan";
|
||||
import MScan from "@/components/MScan";
|
||||
import { INK, IconScan, MBar, MBody, MEmpty, MError, MNote, MRow, MRule, MSection, MStepper, MTop, inputStyle } from "@/components/m";
|
||||
|
||||
const REASONS = ["Worn off in the laundry", "Torn", "Never labelled", "Other"];
|
||||
|
||||
export default function MLabel() {
|
||||
const { s } = useSnap();
|
||||
const { byId, variants } = useDerived();
|
||||
const [q, setQ] = useState("");
|
||||
const [pick, setPick] = useState<string | null>(null);
|
||||
const [reason, setReason] = useState("");
|
||||
const [copies, setCopies] = useState(6);
|
||||
const [scan, setScan] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
/* 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 — the button looked like it worked and
|
||||
stranded the person on a page with nothing to do. Read after mount: the server render doesn't
|
||||
know which shell it is being sent to. */
|
||||
const [inApp, setInApp] = useState(false);
|
||||
useEffect(() => { setInApp(isNative()); }, []);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
return variants
|
||||
.map((v) => ({ ...v, code: bcBound(s, v.item, v.si), name: `${variantName(byId[v.itemId], v.size)}` }))
|
||||
.filter((r) => r.code)
|
||||
.filter((r) => !needle || `${r.name} ${r.code} ${r.item.sku}`.toLowerCase().includes(needle));
|
||||
}, [s, variants, byId, q]);
|
||||
|
||||
const chosen = rows.find((r) => r.key === pick);
|
||||
const printable = chosen && chosen.code;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Reprint label" back right={chosen ? undefined : `${rows.length} labelled size${rows.length === 1 ? "" : "s"}`} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
{!chosen ? (
|
||||
<>
|
||||
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
|
||||
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 28, letterSpacing: "-0.03em", lineHeight: 1.05 }}>Barcode gone</h2>
|
||||
<p style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 10, lineHeight: 1.6 }}>
|
||||
A garment nobody can scan drops out of every count. Find it by code or description and print a fresh label.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ padding: 16, borderBottom: "2px solid " + INK, display: "flex", gap: 8 }}>
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Code or description" autoFocus
|
||||
aria-label="Find a garment" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button onClick={() => setScan(true)} aria-label="Scan a working label"
|
||||
style={{ width: 56, minHeight: 48, border: "2px solid " + INK, background: "var(--color-accent)", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer" }}>
|
||||
<IconScan />
|
||||
</button>
|
||||
</div>
|
||||
<MSection label="Sizes with a supplier barcode" />
|
||||
{rows.length === 0
|
||||
? <MEmpty title="Nothing matches" sub="Only sizes with a supplier barcode bound to them can be reprinted. Bind one by scanning the size on the desktop." />
|
||||
: rows.slice(0, 40).map((r) => (
|
||||
<MRow key={r.key} onClick={() => setPick(r.key)} mark="ink" title={r.name} sub={`${r.code}${r.item.sku ? ` · ${r.item.sku}` : ""}`} />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ padding: "20px 16px", background: "#fff", borderBottom: "2px solid " + INK }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em" }}>{chosen.name}</div>
|
||||
<div style={{ fontSize: 14, color: "var(--color-neutral-600)", marginTop: 6 }}>{chosen.code}</div>
|
||||
<button onClick={() => { setPick(null); setReason(""); }} style={{ marginTop: 12, background: "none", border: 0, padding: 0, color: "var(--color-accent-700)", fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Choose a different garment</button>
|
||||
</div>
|
||||
|
||||
<MSection label="Why is it being reprinted?" />
|
||||
<div style={{ padding: 16, display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||
{REASONS.map((r) => {
|
||||
const on = reason === r;
|
||||
return (
|
||||
<button key={r} onClick={() => setReason(on ? "" : r)} aria-pressed={on}
|
||||
style={{ minHeight: 48, padding: "0 14px", border: "2px solid " + INK, background: on ? INK : "transparent", color: on ? "var(--color-bg)" : INK, fontSize: 14, fontWeight: 700, cursor: "pointer" }}>{r}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<MSection label="Copies" />
|
||||
<div style={{ padding: 16, display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<span style={{ flex: 1, fontSize: 14, color: "var(--color-neutral-700)" }}>Six to an A4 sheet.</span>
|
||||
<MStepper n={copies} onChange={setCopies} min={1} max={24} />
|
||||
</div>
|
||||
|
||||
<MNote>The label carries the same barcode the supplier printed, so it scans identically to the ones still on the shelf.</MNote>
|
||||
{inApp && (
|
||||
<MNote tone="warn">
|
||||
Printing is a desktop job — the app can’t open a label sheet. Open ThreadCount
|
||||
on the desktop site, find <b>{chosen.name}</b> under {chosen.code}, and print it
|
||||
from there.
|
||||
</MNote>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
|
||||
{chosen && (
|
||||
<MBar label={inApp ? "Print it on the desktop" : `Print ${copies} label${copies === 1 ? "" : "s"}`} glyph="printer"
|
||||
disabled={inApp}
|
||||
onClick={() => {
|
||||
if (!printable) { setErr("That size has no supplier barcode bound to it."); return; }
|
||||
const url = `/print/labels?code=${encodeURIComponent(chosen.code)}&copies=${copies}&reason=${encodeURIComponent(reason)}`;
|
||||
window.open(url, "_blank", "noopener");
|
||||
}} />
|
||||
)}
|
||||
|
||||
{scan && <MScan title="Scan a working label" onHit={(raw) => {
|
||||
const k = s.barcodes[raw.trim()];
|
||||
if (k) { setPick(k); setQ(""); } else setErr(`${raw.trim()} isn’t a garment ThreadCount knows.`);
|
||||
setScan(false);
|
||||
}} onClose={() => setScan(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { buildSnapshot } from "@/lib/snapshot";
|
||||
import { SnapshotProvider } from "@/lib/client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Everything that needs a signed-in coordinator. Sending them to /m/login rather than /auth keeps
|
||||
them in the app's own world: /auth is the website's two-pane sign-in, which is a jarring thing
|
||||
to meet on a phone halfway through opening an app. */
|
||||
export default async function MobileAppLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await currentUser();
|
||||
if (!user) redirect("/m/login");
|
||||
const snap = await buildSnapshot(user);
|
||||
return <SnapshotProvider snap={snap}>{children}</SnapshotProvider>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/* What a tap looks like before the server answers, for the counter app.
|
||||
*
|
||||
* The twin of app/my/(app)/loading.tsx, and here for the same reason: every screen under /m is
|
||||
* rendered from its own server query, App Router keeps the previous screen fully painted until that
|
||||
* query comes back, and on linen-room wifi that is seconds in which nothing acknowledges the tap.
|
||||
* People tap again — and on this app the second tap can land on a different row.
|
||||
*
|
||||
* It draws the app's own chrome (the 56px ink bar and the 4px accent rule, the shape MTop and MRule
|
||||
* make) so the change reads as "loading" rather than "gone", and deliberately not the tab bar: the
|
||||
* nav belongs to the four screens that draw it, and painting one here would flash it into existence
|
||||
* on the way to a detail screen that has none. The bar carries no screen title for the same reason
|
||||
* — this one fallback covers every route in the group, so any title would be wrong somewhere.
|
||||
*/
|
||||
const INK = "#201e1d";
|
||||
const GROUND = "#f3f2f2";
|
||||
|
||||
/** A grey block standing in for a line of text. Sized in the same 2px system as everything else. */
|
||||
function Bar({ w, h = 16 }: { w: string; h?: number }) {
|
||||
return <div style={{ width: w, height: h, background: "var(--color-neutral-200)" }} />;
|
||||
}
|
||||
|
||||
export default function CounterLoading() {
|
||||
return (
|
||||
<>
|
||||
<header className="tcx-topbar" style={{
|
||||
height: 56, flex: "0 0 56px", background: INK, color: GROUND, display: "flex", alignItems: "center",
|
||||
paddingLeft: 16, paddingRight: 16,
|
||||
backgroundImage: "linear-gradient(to bottom, rgba(243,242,242,0.16) 0 1px, transparent 1px)",
|
||||
backgroundPosition: "0 env(safe-area-inset-top, 0px)", backgroundRepeat: "no-repeat", backgroundSize: "100% 1px",
|
||||
}}>
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||
One moment
|
||||
</span>
|
||||
</header>
|
||||
<div style={{ height: 4, flex: "0 0 4px", background: "var(--color-accent)" }} />
|
||||
<div style={{ flex: 1, overflow: "hidden", background: GROUND }} aria-busy="true">
|
||||
{/* Announced once, quietly. The blocks below are decoration and say nothing. */}
|
||||
<div role="status" style={{ padding: "20px 16px 0", fontSize: 13, color: "var(--color-neutral-600)" }}>Loading…</div>
|
||||
<div style={{ padding: "16px 16px 0", display: "grid", gap: 10 }} aria-hidden="true">
|
||||
<Bar w="60%" h={22} />
|
||||
<Bar w="40%" />
|
||||
</div>
|
||||
<div style={{ marginTop: 24, display: "grid", gap: 2 }} aria-hidden="true">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} style={{ background: "#fff", padding: "18px 16px", display: "grid", gap: 8 }}>
|
||||
<Bar w="55%" h={18} />
|
||||
<Bar w="35%" h={12} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
/* Everything else the app does.
|
||||
*
|
||||
* There used to be an "On the desktop" section here listing five things the phone couldn't do —
|
||||
* greyed out, untappable, and so just a list of disappointments in the middle of a menu. A menu
|
||||
* should be things you can do. The catalogue moved onto the phone rather than staying on that
|
||||
* list; the rest are simply not advertised here any more. */
|
||||
import { useMemo } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { OPEN_STATUSES } from "@/lib/compute";
|
||||
import { MBody, MNav, MRow, MRule, MSection, MTop } from "@/components/m";
|
||||
|
||||
export default function MMore() {
|
||||
const { s } = useSnap();
|
||||
|
||||
const activeItems = useMemo(() => s.catalog.filter((i) => !i.archived).length, [s.catalog]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const waiting = s.pickups.filter((p) => !p.pickedUp).length;
|
||||
const incoming = s.orders.filter((o) => OPEN_STATUSES.includes(o.status) && o.status !== "Draft").length;
|
||||
const rounds = s.pickups.filter((p) => !p.pickedUp && p.deliveredTo).length;
|
||||
return { waiting, incoming, rounds };
|
||||
}, [s]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="More" />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<MSection label="Everything else" />
|
||||
<MRow href="/m/receive" mark="ink" title="Receive a delivery" sub={counts.incoming ? `${counts.incoming} order${counts.incoming === 1 ? "" : "s"} on their way` : "Nothing on order"} />
|
||||
<MRow href="/m/pickups" mark={counts.waiting ? "accent" : "ink"} attention={counts.waiting > 0} title="Pickup call list" sub={counts.waiting ? `${counts.waiting} waiting to be collected` : "Nobody waiting"} />
|
||||
<MRow href="/m/rounds" mark="ink" title="Delivery round" sub={counts.rounds ? `${counts.rounds} to drop off` : "Nothing loaded"} />
|
||||
<MRow href="/m/label" mark="ink" title="Reprint a label" sub="For a barcode that has worn off" />
|
||||
<MRow href="/m/variance" mark="ink" title="Variance over time" sub="What keeps going missing" />
|
||||
<MRow href="/m/catalogue" mark="ink" title="Catalogue" sub={`${activeItems} garment${activeItems === 1 ? "" : "s"}, sizes and pricing`} />
|
||||
<MRow href="/m/settings" mark="ink" title="Settings" sub={s.session.name} />
|
||||
|
||||
</MBody>
|
||||
<MNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
/* Home — today. Four figures, what’s just happened, and a way into a count. */
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { countsAsIssued, daysBetween, label, longLabel, onhand, touched, reorderAt, staffName, variantName } from "@/lib/compute";
|
||||
import { INK, IconRight, MBody, MNav, MRow, MRule, MSection, MTopBrand } from "@/components/m";
|
||||
|
||||
function Stat({ n, l, hot }: { n: string; l: string; hot?: boolean }) {
|
||||
return (
|
||||
<div style={{ padding: "18px 16px 16px", borderRight: "1px solid var(--color-divider)", borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 44, lineHeight: 1, letterSpacing: "-0.03em", fontVariantNumeric: "tabular-nums", color: hot ? "var(--color-accent-700)" : INK }}>{n}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-neutral-600)", marginTop: 8 }}>{l}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MHome() {
|
||||
const { s } = useSnap();
|
||||
const { L, byId, staffById, variants } = useDerived();
|
||||
|
||||
const d = useMemo(() => {
|
||||
const today = s.today;
|
||||
let issued = 0, returned = 0;
|
||||
for (const i of s.issues) {
|
||||
if (i.date === today) issued += i.qty;
|
||||
if (i.returned?.date === today) returned += i.qty;
|
||||
}
|
||||
const low = variants.filter((v) => touched(s, L, v.key) && onhand(s, L, v.key) <= reorderAt(s, v.key));
|
||||
const lastCount = s.stocktakes.find((t) => t.mode !== "preloved");
|
||||
const since = lastCount ? daysBetween(lastCount.date, today) : null;
|
||||
// Recent activity, newest first. One line per person per day per kind — four garments handed
|
||||
// to the same nurse in one go is one thing that happened, not four.
|
||||
const grouped: Record<string, { staffId: string; kind: "Issued" | "Returned"; at: string; qty: number }> = {};
|
||||
for (const i of s.issues.slice(-120)) {
|
||||
const add = (kind: "Issued" | "Returned", at: string) => {
|
||||
const k = `${i.staffId}|${kind}|${at}`;
|
||||
(grouped[k] ||= { staffId: i.staffId, kind, at, qty: 0 }).qty += i.qty;
|
||||
};
|
||||
add("Issued", i.date);
|
||||
if (i.returned) add("Returned", i.returned.date);
|
||||
}
|
||||
const recent = Object.values(grouped)
|
||||
.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0))
|
||||
.slice(0, 3)
|
||||
.map((g) => ({
|
||||
title: `${staffName(staffById[g.staffId], "Staff")} — ${g.qty} item${g.qty === 1 ? "" : "s"}`,
|
||||
sub: `${g.kind} · ${g.at === today ? "today" : g.at}`,
|
||||
mark: "ink" as const, href: `/m/person/${g.staffId}`, at: g.at,
|
||||
})) as { title: string; sub: string; mark: "ink" | "accent"; href?: string; at: string }[];
|
||||
// A line AT its reorder level is in `low` on purpose (reorder now, not once it's short), but
|
||||
// "Below par · 3 of 3" reads as a contradiction on the phone, so name the two states apart.
|
||||
for (const v of low.slice(0, 2)) {
|
||||
const oh = onhand(s, L, v.key), par = reorderAt(s, v.key);
|
||||
recent.push({ title: `${variantName(byId[v.itemId], v.size)}`, sub: `${oh < par ? "Below par" : "At par"} · ${oh} of ${par}`, mark: "accent", href: `/m/stock`, at: "" });
|
||||
}
|
||||
return { issued, returned, low: low.length, since, recent };
|
||||
}, [s, L, byId, staffById, variants]);
|
||||
|
||||
const fac = [s.settings.facility, s.settings.location].filter(Boolean).join(" · ");
|
||||
const dateLine = new Date(+s.today.slice(0, 4), +s.today.slice(5, 7) - 1, +s.today.slice(8, 10))
|
||||
.toLocaleDateString("en-AU", { weekday: "long", day: "numeric", month: "long" });
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTopBrand facility={fac} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: "20px 16px 24px", borderBottom: "2px solid " + INK }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>{dateLine}</div>
|
||||
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 44, letterSpacing: "-0.03em", lineHeight: 1, marginTop: 10 }}>Today</h2>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr" }}>
|
||||
<Stat n={String(d.issued)} l="Issued" />
|
||||
<Stat n={String(d.returned)} l="Returned" />
|
||||
<Stat n={String(d.low)} l="Below par" hot={d.low > 0} />
|
||||
<Stat n={d.since === null ? "—" : `${d.since}d`} l="Since count" hot={d.since !== null && d.since > 30} />
|
||||
</div>
|
||||
|
||||
<MSection label="Recent" />
|
||||
{d.recent.length === 0
|
||||
? <div style={{ padding: "28px 16px", fontSize: 14, color: "var(--color-neutral-600)" }}>Nothing has moved yet today.</div>
|
||||
: d.recent.map((r, i) => (
|
||||
<MRow key={i} mark={r.mark} attention={r.mark === "accent"} href={r.href}
|
||||
title={r.title}
|
||||
sub={<span style={{ color: r.mark === "accent" ? "var(--color-accent-700)" : undefined }}>{r.sub}</span>} />
|
||||
))}
|
||||
|
||||
<div style={{ padding: 16, display: "grid", gap: 12 }}>
|
||||
<Link href="/m/count" style={{ 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" }}>
|
||||
<span style={{ flex: 1 }}>Start a count</span><IconRight />
|
||||
</Link>
|
||||
<Link href="/m/issue" style={{ 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" }}>
|
||||
<span style={{ flex: 1 }}>Issue to someone</span><IconRight />
|
||||
</Link>
|
||||
<Link href="/m/more" style={{ display: "flex", alignItems: "center", gap: 12, minHeight: 52, padding: "0 20px", color: "var(--color-neutral-700)", textDecoration: "none", fontSize: 13, fontWeight: 600, letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||
<span style={{ flex: 1 }}>Deliveries, pickups, rounds and more</span><IconRight size={18} />
|
||||
</Link>
|
||||
</div>
|
||||
</MBody>
|
||||
<MNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
/* Size exchange — one movement, not a return followed by an issue. What comes back, what goes out,
|
||||
and the staff record updated so nobody hands them the wrong size again next month. */
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { isPantItem, isTopItem, key, label, onhand, staffName } from "@/lib/compute";
|
||||
import MScan from "@/components/MScan";
|
||||
import { GROUND, INK, MBar, MBody, MChips, MEmpty, MError, MRow, MRule, MSection, MTop } from "@/components/m";
|
||||
import { useHeld, type Held } from "@/components/MPerson";
|
||||
|
||||
export default function MExchange() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
const { L, byId } = useDerived();
|
||||
const router = useRouter();
|
||||
const id = String(useParams().id || "");
|
||||
const st = s.staff.find((x) => x.id === id);
|
||||
const held = useHeld(s, id);
|
||||
|
||||
const [pick, setPick] = useState<Held | null>(null);
|
||||
const [si, setSi] = useState(-1);
|
||||
const [scan, setScan] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const it = pick ? byId[pick.itemId] : undefined;
|
||||
const stock = useMemo(() => {
|
||||
if (!it) return [] as number[];
|
||||
return it.sizes.map((_, i) => onhand(s, L, key(it.id, i)));
|
||||
}, [it, s, L]);
|
||||
|
||||
const onCode = useCallback((raw: string) => {
|
||||
const k = s.barcodes[raw.trim()];
|
||||
const hit = held.find((h) => h.key === k);
|
||||
if (!hit) { setErr(`${raw.trim()} isn’t something ${st?.first ?? "they"} is holding.`); return; }
|
||||
setPick(hit); setSi(-1); setErr("");
|
||||
}, [s.barcodes, held, st]);
|
||||
|
||||
if (!st) return (<><MTop title="Exchange" back /><MRule /><MBody><MEmpty title="No such staff member" /></MBody></>);
|
||||
|
||||
const commit = async () => {
|
||||
if (!pick || si < 0) return;
|
||||
const r = await mutate<{ size: string }>("issue.exchange", { id: pick.issues[0].id, si, qty: 1 });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
router.push(`/m/person/${st.id}`);
|
||||
};
|
||||
|
||||
const willUpdate = it && (isTopItem(it) || isPantItem(it));
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Exchange" back right={staffName(st)} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
{!pick ? (
|
||||
<>
|
||||
<MSection label="What doesn’t fit?" right={`${held.reduce((t, h) => t + h.qty, 0)} out`} />
|
||||
{held.length === 0
|
||||
? <MEmpty title="Nothing to exchange" sub={`${staffName(st)} has no garments out at the moment.`} />
|
||||
: held.map((h) => <MRow key={h.key} onClick={() => { setPick(h); setSi(-1); }} mark="ink" title={h.name} sub={`${h.qty} held`} />)}
|
||||
{/* Same rule as the return screen: the scan bar is off when nothing is out, so the line
|
||||
offering a scan goes with it. */}
|
||||
{held.length > 0 && <div style={{ padding: "18px 16px", fontSize: 14, color: "var(--color-neutral-700)" }}>Or scan the garment they’ve brought back.</div>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<section style={{ background: INK, color: GROUND, padding: 16 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-accent-300)" }}>Taking back</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, marginTop: 8 }}>
|
||||
<span aria-hidden="true" style={{ width: 4, height: 34, background: "#fff" }} />
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 20, letterSpacing: "-0.02em" }}>{pick.name}</span>
|
||||
</div>
|
||||
<button onClick={() => setPick(null)} style={{ marginTop: 12, background: "none", border: 0, padding: 0, color: "#fff", fontSize: 13, fontWeight: 700, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Choose a different garment</button>
|
||||
</section>
|
||||
|
||||
<MSection label="Giving out" right={it ? label(it) : ""} />
|
||||
<div style={{ padding: 16 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<span aria-hidden="true" style={{ width: 4, height: 34, background: "var(--color-accent)" }} />
|
||||
<span style={{ fontSize: 14, color: "var(--color-neutral-700)" }}>Pick the size that fits. Greyed sizes are the one coming back, or have none on the shelf.</span>
|
||||
</div>
|
||||
{it && <MChips sizes={it.sizes.map(String)} value={si} onPick={(i) => setSi(i)} disabled={(i) => i === pick.si || stock[i] <= 0} />}
|
||||
{si >= 0 && it && (
|
||||
<p style={{ fontSize: 13.5, color: "var(--color-neutral-700)", marginTop: 14, lineHeight: 1.6 }}>
|
||||
{stock[si]} on the shelf in size {it.sizes[si]}. The old garment goes back to stock in the same movement
|
||||
{willUpdate ? `, and ${st.first}’s recorded size becomes ${it.sizes[si]}.` : "."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
|
||||
{pick
|
||||
? <MBar label={busy ? "Recording…" : si >= 0 && it ? `Exchange for size ${it.sizes[si]}` : "Pick a size"} glyph="check" onClick={commit} disabled={busy || si < 0} />
|
||||
: <MBar label="Scan the garment" glyph="scan" onClick={() => setScan(true)} disabled={held.length === 0} />}
|
||||
|
||||
{scan && <MScan title="Scan the garment" onHit={(r) => { onCode(r); setScan(false); }} onClose={() => setScan(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
/* Person record — who they are, what they're holding, what has happened, and the three things
|
||||
you can do about it. Issuing starts here: 1B, person first. */
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { fmtDate, itemMap, staffName, variantName } from "@/lib/compute";
|
||||
import { GROUND, INK, MBar, MBody, MEmpty, MError, MRow, MRule, MSection, MTop } from "@/components/m";
|
||||
import { MPersonHead, useHeld } from "@/components/MPerson";
|
||||
|
||||
export default function MPersonPage() {
|
||||
const { s, isAdmin, mutate } = useSnap();
|
||||
const id = String(useParams().id || "");
|
||||
const st = s.staff.find((x) => x.id === id);
|
||||
const held = useHeld(s, id);
|
||||
const byId = useMemo(() => itemMap(s), [s]);
|
||||
// Shown once, then gone: the code is a credential and is never in the snapshot.
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const origin = typeof window === "undefined" ? "" : window.location.host;
|
||||
|
||||
const history = useMemo(() => {
|
||||
if (!st) return [];
|
||||
const out: { text: string; date: string }[] = [];
|
||||
for (const i of s.issues) {
|
||||
if (i.staffId !== id) continue;
|
||||
const it = byId[i.itemId];
|
||||
const size = String(it?.sizes[i.si] ?? i.si);
|
||||
out.push({ text: `Issued ${i.qty} × ${variantName(it, size)}`, date: i.date });
|
||||
if (i.returned) out.push({ text: `${i.returned.cond} — ${variantName(it, size)}`, date: i.returned.date });
|
||||
if (i.handedIn) out.push({ text: `Handed in — ${variantName(it, size)}`, date: i.handedIn });
|
||||
}
|
||||
return out.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)).slice(0, 25);
|
||||
}, [s, id, st, byId]);
|
||||
|
||||
if (!st) return (<><MTop title="Person" back /><MRule /><MBody><MEmpty title="No such staff member" sub="They may have been removed from the register." /></MBody></>);
|
||||
|
||||
const total = held.reduce((t, h) => t + h.qty, 0);
|
||||
/* Issue, Exchange and Return are docked at the foot of the window with nothing underneath them,
|
||||
so Android draws the gesture handle across their bottom edge. The inset goes inside the bar the
|
||||
way the shared MBar and MAction take it — same custom property, so an ancestor that zeroes it
|
||||
for a bar sitting mid-screen would zero this one too — and the accent still runs to the bottom
|
||||
of the glass while the words stay above the handle. Without it the lower third of "Issue" is
|
||||
untappable, and this is the row a counter hand hits all day. */
|
||||
const SAFE_BOTTOM = "var(--tcx-safe-bottom, env(safe-area-inset-bottom, 0px))";
|
||||
const foot: React.CSSProperties = { flex: 1, minHeight: `calc(64px + ${SAFE_BOTTOM})`, display: "flex", alignItems: "center", padding: `0 16px ${SAFE_BOTTOM}`, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", textDecoration: "none" };
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Person" back />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<MPersonHead s={s} st={st} />
|
||||
<MSection label="Holding now" right={`${total} item${total === 1 ? "" : "s"}`} />
|
||||
{held.length === 0
|
||||
? <div style={{ padding: "22px 16px", fontSize: 14, color: "var(--color-neutral-600)" }}>Nothing out at the moment.</div>
|
||||
: held.map((h) => <MRow key={h.key} title={h.name} right={<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, fontVariantNumeric: "tabular-nums" }}>{h.qty}</span>} />)}
|
||||
|
||||
<MSection label="Their own record" />
|
||||
{code ? (
|
||||
<>
|
||||
<div style={{ padding: "16px" }}>
|
||||
<div style={{ fontFamily: "ui-monospace, Menlo, Consolas, monospace", fontSize: 26, fontWeight: 800, letterSpacing: "0.06em" }}>{code}</div>
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.55, color: "var(--color-neutral-700)", margin: "8px 0 0" }}>
|
||||
Read this out or write it down now — it can't be shown again. They go to{" "}
|
||||
<b>{origin}/my</b>, choose “I have a code”, and set an email and password.
|
||||
</p>
|
||||
</div>
|
||||
<MBar label="Done" tone="ink" glyph="none" onClick={() => setCode(null)} />
|
||||
</>
|
||||
) : st.selfEmail ? (
|
||||
<div style={{ padding: "16px", fontSize: 14, lineHeight: 1.55, color: "var(--color-neutral-600)" }}>
|
||||
Signed up as {st.selfEmail} — they can look up their own record instead of coming to the counter.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ padding: "16px" }}>
|
||||
<p style={{ fontSize: 14, lineHeight: 1.55, color: "var(--color-neutral-600)", margin: 0 }}>
|
||||
{st.selfCode
|
||||
? "A code is out but hasn’t been used. Make a new one if they’ve lost it — the old one stops working."
|
||||
: "Give them a code and they can check what they hold on their own phone. Read-only."}
|
||||
</p>
|
||||
</div>
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
{isAdmin && (
|
||||
<MBar label={busy ? "Generating…" : st.selfCode ? "New code" : "Generate a code"} tone="ink" glyph="none" disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true); setErr("");
|
||||
const r = await mutate<{ code: string }>("staff.selfCode", { id: st.id });
|
||||
setBusy(false);
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setCode(r.result.code);
|
||||
}} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<MSection label="History" />
|
||||
{history.length === 0
|
||||
? <div style={{ padding: "22px 16px", fontSize: 14, color: "var(--color-neutral-600)" }}>Nothing recorded for {staffName(st)} yet.</div>
|
||||
: history.map((h, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: 12, padding: "14px 16px", borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<span style={{ flex: 1, fontSize: 14.5 }}>{h.text}</span>
|
||||
<span style={{ fontSize: 13, color: "var(--color-neutral-600)", whiteSpace: "nowrap" }}>{fmtDate(h.date)}</span>
|
||||
</div>
|
||||
))}
|
||||
</MBody>
|
||||
<div style={{ display: "flex", flex: `0 0 calc(64px + ${SAFE_BOTTOM})`, borderTop: "2px solid " + INK }}>
|
||||
<Link href={`/m/issue/${st.id}`} style={{ ...foot, background: "var(--color-accent)", color: "#fff" }}>Issue</Link>
|
||||
<Link href={`/m/person/${st.id}/exchange`} style={{ ...foot, background: "var(--color-neutral-200)", color: INK, borderLeft: "1px solid " + GROUND }}>Exchange</Link>
|
||||
<Link href={`/m/person/${st.id}/return`} style={{ ...foot, background: "var(--color-neutral-200)", color: INK, borderLeft: "1px solid " + GROUND }}>Return</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
/* Return — scan the garment or pick it off what they're holding, say how many and what state
|
||||
they're in, confirm. The conditions are ThreadCount's real four: only "fit for use" puts a
|
||||
garment back on the shelf. */
|
||||
import { useCallback, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { fmtDate, staffName } from "@/lib/compute";
|
||||
import MScan from "@/components/MScan";
|
||||
import { INK, MBar, MBody, MEmpty, MError, MRow, MRule, MSection, MStepper, MTop } from "@/components/m";
|
||||
import { useHeld, type Held } from "@/components/MPerson";
|
||||
|
||||
const CONDITIONS: [string, string, string][] = [
|
||||
["Returned - Good", "Fit for use — back to shelf", "Counts back into stock the moment it’s confirmed."],
|
||||
["Returned - Damaged", "Damaged — needs repair", "Stays off the shelf and stays charged to the cost centre."],
|
||||
["Written Off", "Condemn — beyond repair", "Written off. Nothing comes back to stock."],
|
||||
["Lost", "Lost", "Never came back. Stays charged."],
|
||||
];
|
||||
|
||||
export default function MReturn() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
const router = useRouter();
|
||||
const id = String(useParams().id || "");
|
||||
const st = s.staff.find((x) => x.id === id);
|
||||
const held = useHeld(s, id);
|
||||
|
||||
const [pick, setPick] = useState<Held | null>(null);
|
||||
// How many of that garment are actually on the counter. Three of a size can be out on one issue
|
||||
// line, and one pair coming back is one pair — crediting the whole line put two garments that
|
||||
// are still on a ward back onto the shelf.
|
||||
const [qty, setQty] = useState(1);
|
||||
const [cond, setCond] = useState("Returned - Good");
|
||||
const [scan, setScan] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const choose = useCallback((h: Held) => { setPick(h); setQty(h.qty); setErr(""); }, []);
|
||||
|
||||
const onCode = useCallback((raw: string) => {
|
||||
const k = s.barcodes[raw.trim()];
|
||||
const hit = held.find((h) => h.key === k);
|
||||
if (!hit) { setErr(`${raw.trim()} isn’t something ${st?.first ?? "they"} is holding.`); return; }
|
||||
choose(hit);
|
||||
}, [s.barcodes, held, st, choose]);
|
||||
|
||||
if (!st) return (<><MTop title="Return" back /><MRule /><MBody><MEmpty title="No such staff member" /></MBody></>);
|
||||
|
||||
const confirm = async () => {
|
||||
if (!pick) return;
|
||||
// What they hold in this size can be spread over several issue lines, so returning four of
|
||||
// them is several movements. Oldest line first — the garment that has been out longest is the
|
||||
// one that came back — and the last line is split when it is only partly returned.
|
||||
const rows = [...pick.issues].sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
||||
let left = Math.min(qty, pick.qty);
|
||||
const asked = left;
|
||||
for (const i of rows) {
|
||||
if (left <= 0) break;
|
||||
const take = Math.min(i.qty, left);
|
||||
const r = await mutate("issue.return", { id: i.id, cond, qty: take });
|
||||
if (!r.ok) {
|
||||
// Some of them may already be back. Say so rather than leave the counter to guess, and
|
||||
// send them back to a fresh list rather than acting on what is now a stale row.
|
||||
setErr(asked - left > 0 ? `${asked - left} of ${asked} went back before this stopped — ${r.error}` : r.error);
|
||||
setPick(null);
|
||||
return;
|
||||
}
|
||||
left -= take;
|
||||
}
|
||||
router.push(`/m/person/${st.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Return" back right={staffName(st)} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
{pick ? (
|
||||
<>
|
||||
<div style={{ padding: "20px 16px", background: "#fff", borderBottom: "2px solid " + INK }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em" }}>{pick.name}</div>
|
||||
<div style={{ fontSize: 14, color: "var(--color-neutral-600)", marginTop: 6 }}>
|
||||
Issued to {staffName(st)}, {fmtDate(pick.issues[0].date)}
|
||||
</div>
|
||||
<button onClick={() => setPick(null)} style={{ marginTop: 12, background: "none", border: 0, padding: 0, color: "var(--color-accent-700)", fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Choose a different garment</button>
|
||||
</div>
|
||||
|
||||
<MSection label="How many are coming back?" right={`${pick.qty} out`} />
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16, padding: 16, borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<span style={{ flex: 1, fontSize: 14, color: "var(--color-neutral-700)", lineHeight: 1.55 }}>
|
||||
{pick.qty === 1
|
||||
? "One is out, so this is it."
|
||||
: `${pick.qty} are out. Count what is on the counter — the rest stays on ${st.first}’s record.`}
|
||||
</span>
|
||||
<MStepper n={qty} onChange={setQty} min={1} max={pick.qty} />
|
||||
</div>
|
||||
|
||||
<MSection label="Condition" />
|
||||
<div style={{ padding: 16, display: "grid", gap: 8 }}>
|
||||
{CONDITIONS.map(([value, title, note]) => {
|
||||
const on = cond === value;
|
||||
return (
|
||||
<button key={value} onClick={() => setCond(value)} aria-pressed={on}
|
||||
style={{ textAlign: "left", padding: "16px 18px", minHeight: 64, border: "2px solid " + INK, background: on ? INK : "transparent", color: on ? "var(--color-bg)" : INK, cursor: "pointer" }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 17, letterSpacing: "-0.01em" }}>{title}</div>
|
||||
<div style={{ fontSize: 13, marginTop: 4, color: on ? "var(--color-neutral-400)" : "var(--color-neutral-600)" }}>{note}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MSection label="What is coming back?" right={`${held.reduce((t, h) => t + h.qty, 0)} out`} />
|
||||
{held.length === 0
|
||||
? <MEmpty title="Nothing to return" sub={`${staffName(st)} has no garments out at the moment.`} />
|
||||
: held.map((h) => <MRow key={h.key} onClick={() => choose(h)} mark="ink" title={h.name} sub={`${h.qty} held · issued ${fmtDate(h.issues[0].date)}`} />)}
|
||||
{/* A return has to match a record they hold, which is why the scan bar below is off when
|
||||
nothing is out — so don't invite a scan the bar then refuses. */}
|
||||
{held.length > 0 && <div style={{ padding: "18px 16px", fontSize: 14, color: "var(--color-neutral-700)" }}>Or scan the garment.</div>}
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
|
||||
{pick
|
||||
? <MBar label={busy ? "Recording…" : qty === 1 ? "Confirm return" : `Confirm return of ${qty}`} glyph="check" onClick={confirm} disabled={busy} />
|
||||
: <MBar label="Scan the garment" glyph="scan" onClick={() => setScan(true)} disabled={held.length === 0} />}
|
||||
|
||||
{scan && <MScan title="Scan the garment" onHit={(r) => { onCode(r); setScan(false); }} onClose={() => setScan(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
/* The call list — longest wait first, because that’s the one somebody is annoyed about. */
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { daysBetween, itemMap, label, staffMap, staffName } from "@/lib/compute";
|
||||
import { INK, MBody, MEmpty, MError, MNav, MRow, MRule, MSection, MTop } from "@/components/m";
|
||||
|
||||
export default function MPickups() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const staffById = staffMap(s);
|
||||
const byId = itemMap(s);
|
||||
return s.pickups
|
||||
.filter((p) => !p.pickedUp)
|
||||
.map((p) => ({
|
||||
...p,
|
||||
who: staffById[p.staffId],
|
||||
phone: staffById[p.staffId]?.phone || "",
|
||||
days: daysBetween(p.received, s.today),
|
||||
what: p.lines.map((l) => `${label(byId[l.itemId])} · ${l.size}${l.qty > 1 ? ` ×${l.qty}` : ""}`).join(", "),
|
||||
}))
|
||||
.sort((a, b) => b.days - a.days);
|
||||
}, [s]);
|
||||
|
||||
const act = async (op: string, id: string) => {
|
||||
const r = await mutate(op, { id });
|
||||
if (!r.ok) setErr(r.error);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Pickups" back right={rows.length ? `${rows.length} waiting` : undefined} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
{rows.length === 0 ? (
|
||||
<MEmpty title="Nobody is waiting" sub="Everything that has come in has been collected." />
|
||||
) : (
|
||||
<>
|
||||
<MSection label="Waiting" right="Longest first" />
|
||||
{rows.map((p) => (
|
||||
<div key={p.id} style={{ padding: 16, background: p.days > 10 ? "#fff" : "var(--color-bg)", borderBottom: "1px solid var(--color-divider)", display: "flex", gap: 12 }}>
|
||||
<span aria-hidden="true" style={{ width: 4, flex: "0 0 4px", background: p.days > 10 ? "var(--color-accent)" : INK, alignSelf: "stretch" }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
|
||||
<span style={{ flex: 1, fontSize: 16.5, fontWeight: 700 }}>{staffName(p.who, "Staff member")}</span>
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, color: p.days > 10 ? "var(--color-accent-700)" : "var(--color-neutral-600)", fontVariantNumeric: "tabular-nums" }}>{p.days}d</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13.5, color: "var(--color-neutral-600)", marginTop: 3 }}>{p.what} · {p.orderCode}</div>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 12, flexWrap: "wrap" }}>
|
||||
{p.phone && (
|
||||
<a href={`tel:${p.phone.replace(/\s+/g, "")}`}
|
||||
style={{ minHeight: 44, display: "inline-flex", alignItems: "center", padding: "0 14px", border: "2px solid " + INK, color: INK, textDecoration: "none", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase" }}>Call</a>
|
||||
)}
|
||||
<button onClick={() => act("pickup.contacted", p.id)} disabled={busy || p.contacted}
|
||||
style={{ minHeight: 44, padding: "0 14px", border: "2px solid " + INK, background: p.contacted ? INK : "transparent", color: p.contacted ? "var(--color-bg)" : INK, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: p.contacted ? "default" : "pointer", opacity: busy ? 0.5 : 1 }}>
|
||||
{p.contacted ? "Contacted" : "Mark contacted"}
|
||||
</button>
|
||||
<button onClick={() => act("pickup.pickedUp", p.id)} disabled={busy}
|
||||
style={{ minHeight: 44, padding: "0 14px", border: "2px solid var(--color-accent)", background: "var(--color-accent)", color: "#fff", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer", opacity: busy ? 0.5 : 1 }}>
|
||||
Collected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
<MNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
/* Receive a delivery — tick lines against the invoice as you unpack. Receiving closes the order:
|
||||
anything short is raised as its own back order, so the shortfall is chased on a live order
|
||||
rather than left sitting on a closed one. */
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { fmtDate, itemMap, label, OPEN_STATUSES, sizeIndexOf, staffMap, staffName, variantName } from "@/lib/compute";
|
||||
import { INK, MBar, MBody, MEmpty, MError, MRow, MRule, MSection, MStepper, MTop } from "@/components/m";
|
||||
|
||||
export default function MReceive() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
const router = useRouter();
|
||||
const [pick, setPick] = useState<string | null>(null);
|
||||
const [got, setGot] = useState<Record<string, number>>({});
|
||||
const [invoice, setInvoice] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
/* The shortfall is banked here at the moment of receipt, not read off the order afterwards:
|
||||
receiving closes the order, so the refresh that follows drops it out of the open list and both
|
||||
`lines` and `short` fall empty. Reading them on this screen told a storeperson who had just
|
||||
stepped two tunics down to zero that everything on the order arrived, and the back order sat
|
||||
unchased on Ordering. */
|
||||
const [done, setDone] = useState<{ code: string; short: number } | null>(null);
|
||||
|
||||
const byId = useMemo(() => itemMap(s), [s]);
|
||||
const staffById = useMemo(() => staffMap(s), [s]);
|
||||
const open = useMemo(() => s.orders.filter((o) => OPEN_STATUSES.includes(o.status) && o.status !== "Draft"), [s]);
|
||||
const order = open.find((o) => o.id === pick);
|
||||
|
||||
// What's still outstanding on each line after any earlier partial receipt.
|
||||
const lines = useMemo(() => {
|
||||
if (!order) return [];
|
||||
return order.lines.map((l) => {
|
||||
const already = order.receipts.reduce((t, r) => t + r.lines.filter((x) => x.itemId === l.itemId && x.size === l.size).reduce((a, x) => a + x.qty, 0), 0);
|
||||
return { ...l, already, outstanding: Math.max(0, l.qty - already), name: `${variantName(byId[l.itemId], l.size)}`, ok: sizeIndexOf(byId[l.itemId], l.size) >= 0 };
|
||||
}).filter((l) => l.outstanding > 0);
|
||||
}, [order, byId]);
|
||||
|
||||
const q = (id: string, fallback: number) => got[id] ?? fallback;
|
||||
const arriving = lines.reduce((t, l) => t + q(l.id, l.outstanding), 0);
|
||||
const short = lines.filter((l) => q(l.id, l.outstanding) < l.outstanding);
|
||||
|
||||
const receive = async () => {
|
||||
if (!order) return;
|
||||
const r = await mutate("order.receive", {
|
||||
id: order.id, invoice: invoice.trim(),
|
||||
lines: lines.map((l) => ({ lineId: l.id, itemId: l.itemId, size: l.size, arrived: q(l.id, l.outstanding), dest: order.staffId ? "pickup" : "shelf" })),
|
||||
});
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setDone({ code: order.code, short: short.length });
|
||||
};
|
||||
|
||||
if (done) return (
|
||||
<>
|
||||
<MTop title="Received" />
|
||||
<MRule />
|
||||
<MBody><MEmpty title={`${done.code} received`} sub={done.short ? `${done.code} is closed as received, and a back order for the ${done.short} short line${done.short === 1 ? "" : "s"} has been raised automatically. It’s waiting on Ordering on the desktop.` : "Everything on the order arrived. Stock is updated."} /></MBody>
|
||||
<MBar label="Back" onClick={() => router.push("/m/more")} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Receive" back right={order ? order.code : `${open.length} on order`} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
{!order ? (
|
||||
<>
|
||||
<MSection label="On their way" />
|
||||
{open.length === 0
|
||||
? <MEmpty title="Nothing on order" sub="Orders show up here once they’re marked as ordered on the desktop." />
|
||||
: open.map((o) => (
|
||||
<MRow key={o.id} onClick={() => { setPick(o.id); setGot({}); setInvoice(o.invoice || ""); }} mark="ink"
|
||||
title={`${o.supplier || "Supplier"} · ${o.code}`}
|
||||
sub={[o.staffId ? `For ${staffName(staffById[o.staffId])}` : "For stock", o.expected ? `expected ${fmtDate(o.expected)}` : o.status].filter(Boolean).join(" · ")}
|
||||
right={<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 17, fontVariantNumeric: "tabular-nums" }}>{o.lines.reduce((t, l) => t + l.qty, 0)}</span>} />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ padding: "20px 16px", background: "#fff", borderBottom: "2px solid " + INK }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-accent-700)" }}>{order.supplier || "Supplier"}</div>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em", marginTop: 6 }}>{order.code}</div>
|
||||
<div style={{ fontSize: 13.5, color: "var(--color-neutral-600)", marginTop: 6 }}>
|
||||
Ordered {fmtDate(order.date)}{order.expected ? ` · expected ${fmtDate(order.expected)}` : ""}
|
||||
</div>
|
||||
<input value={invoice} onChange={(e) => setInvoice(e.target.value)} placeholder="Invoice number (optional)" aria-label="Invoice number"
|
||||
style={{ width: "100%", minHeight: 48, padding: "10px 12px", border: "2px solid " + INK, background: "var(--color-bg)", fontSize: 16, fontWeight: 600, marginTop: 14 }} />
|
||||
<button onClick={() => setPick(null)} style={{ marginTop: 12, background: "none", border: 0, padding: 0, color: "var(--color-accent-700)", fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Choose a different order</button>
|
||||
</div>
|
||||
|
||||
<MSection label="Tick each line as you unpack" right="Arrived / ordered" />
|
||||
{lines.length === 0
|
||||
? <MEmpty title="Nothing outstanding" sub="Every line on this order has already been receipted." />
|
||||
: lines.map((l) => (
|
||||
<div key={l.id} style={{ display: "flex", alignItems: "center", gap: 12, padding: 16, borderBottom: "1px solid var(--color-divider)", background: q(l.id, l.outstanding) < l.outstanding ? "#fff" : "var(--color-bg)" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700 }}>{l.name}</div>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 3 }}>
|
||||
{l.outstanding} outstanding{l.already ? ` · ${l.already} already received` : ""}
|
||||
{q(l.id, l.outstanding) < l.outstanding ? ` · ${l.outstanding - q(l.id, l.outstanding)} short` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<MStepper n={q(l.id, l.outstanding)} onChange={(n) => setGot((x) => ({ ...x, [l.id]: n }))} max={l.outstanding} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{lines.length > 0 && (
|
||||
<p style={{ padding: "18px 16px 26px", fontSize: 14, color: "var(--color-neutral-700)", lineHeight: 1.6 }}>
|
||||
{order.staffId ? "Goes onto the pickup list for the staff member it was ordered for." : "Goes onto the shelf."}
|
||||
{short.length > 0 && ` ${short.length} line${short.length === 1 ? "" : "s"} short — ${order.code} closes as received and the shortfall goes onto a new back order.`}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
{order && lines.length > 0 && <MBar label={busy ? "Receiving…" : `Receive ${arriving} item${arriving === 1 ? "" : "s"}`} glyph="check" onClick={receive} disabled={busy || arriving === 0} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
/* Reorder draft — what fell below par, at quantities that bring each line back up. Adjust and raise.
|
||||
This raises a draft on Ordering; nothing reaches a supplier until someone approves it there. */
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { flaggedNeeds, label, onhand, reorderAt, touched, variantName } from "@/lib/compute";
|
||||
import { INK, MBar, MBody, MEmpty, MError, MRule, MStepper, MTop } from "@/components/m";
|
||||
|
||||
export default function MReorder() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
const { L, byId, variants } = useDerived();
|
||||
const router = useRouter();
|
||||
const [qty, setQty] = useState<Record<string, number>>({});
|
||||
const [err, setErr] = useState("");
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
|
||||
const needs = useMemo(() => flaggedNeeds(s, L, byId).map((n) => {
|
||||
const k = `${n.itemId}:${n.si}`;
|
||||
return { ...n, key: k, name: `${variantName(byId[n.itemId], n.size)}`, oh: onhand(s, L, k), par: reorderAt(s, k) };
|
||||
}), [s, L, byId]);
|
||||
|
||||
// /m/stock's "N below par" counts every line at or under its reorder point. flaggedNeeds nets off
|
||||
// what is already on an open order and drops a line once that covers it, so the two figures
|
||||
// legitimately differ — and a counter who taps "Reorder 8 lines" and is told nothing needs
|
||||
// ordering stops believing the screen. Report both, and never call a short shelf healthy:
|
||||
// stock on order is not stock on the shelf until somebody receipts it.
|
||||
const belowPar = useMemo(
|
||||
() => variants.filter((v) => touched(s, L, v.key) && onhand(s, L, v.key) <= reorderAt(s, v.key)).length,
|
||||
[s, L, variants],
|
||||
);
|
||||
const covered = belowPar - needs.length;
|
||||
|
||||
const q = (k: string, fallback: number) => qty[k] ?? fallback;
|
||||
const total = needs.reduce((t, n) => t + q(n.key, n.qty), 0);
|
||||
const suppliers = [...new Set(needs.map((n) => n.supplier))];
|
||||
|
||||
const raise = async () => {
|
||||
const bySup: Record<string, typeof needs> = {};
|
||||
for (const n of needs) if (q(n.key, n.qty) > 0) (bySup[n.supplier] ||= []).push(n);
|
||||
const codes: string[] = [];
|
||||
for (const sup of Object.keys(bySup)) {
|
||||
const r = await mutate<{ code: string }>("order.create", {
|
||||
orderFor: "Stock", supplier: sup, replenish: false, notes: "Raised from a stocktake on the app",
|
||||
lines: bySup[sup].map((n) => ({ itemId: n.itemId, size: n.size, qty: q(n.key, n.qty) })),
|
||||
});
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
codes.push(r.result.code);
|
||||
}
|
||||
setDone(codes.join(" · "));
|
||||
};
|
||||
|
||||
if (done) return (
|
||||
<>
|
||||
<MTop title="Reorder" />
|
||||
<MRule />
|
||||
<MBody><MEmpty title={`Draft ${done} raised`} sub="It’s waiting on Ordering. Check the quantities and the supplier reference there, then send it." /></MBody>
|
||||
<MBar label="Back to stock" href="/m/stock" />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Reorder" back right={needs.length ? `${needs.length} line${needs.length === 1 ? "" : "s"}` : undefined} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-accent-700)" }}>Below par</div>
|
||||
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 30, letterSpacing: "-0.03em", lineHeight: 1.05, marginTop: 8 }}>Draft order</h2>
|
||||
<p style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 10, lineHeight: 1.6 }}>
|
||||
{needs.length === 0
|
||||
? belowPar === 0
|
||||
? "Every line is at or above its par level. Nothing needs ordering."
|
||||
: `${belowPar} line${belowPar === 1 ? "" : "s"} ${belowPar === 1 ? "is" : "are"} at or below par, and open orders already cover ${belowPar === 1 ? "it" : "them"}. There’s nothing more to raise — but the shelf stays short until the delivery is receipted.`
|
||||
: covered > 0
|
||||
? `${belowPar} lines are at or below par. Open orders cover ${covered}; the other ${needs.length} still need${needs.length === 1 ? "s" : ""} ordering, at quantities that bring ${needs.length === 1 ? "it" : "each one"} back up.`
|
||||
: `${needs.length} line${needs.length === 1 ? "" : "s"} ${needs.length === 1 ? "is" : "are"} at or below par. Quantities bring each one back up.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{needs.length === 0 ? (
|
||||
<MEmpty
|
||||
title={belowPar ? "Already on order" : "Nothing to reorder"}
|
||||
sub={belowPar
|
||||
? "Every short line is on an open order. Receipt it on Ordering when it lands — until then those shelves are still short."
|
||||
: "Come back after a count, or lower a par level on the desktop if a line should be carrying more."} />
|
||||
) : needs.map((n) => (
|
||||
<div key={n.key} style={{ display: "flex", alignItems: "center", gap: 12, padding: 16, background: "#fff", borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16.5, fontWeight: 700, letterSpacing: "-0.01em" }}>{n.name}</div>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 3 }}>{n.oh} on hand · par {n.par} · {n.supplier}</div>
|
||||
</div>
|
||||
<MStepper n={q(n.key, n.qty)} onChange={(v) => setQty((x) => ({ ...x, [n.key]: v }))} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{needs.length > 0 && (
|
||||
<p style={{ padding: "18px 16px 26px", fontSize: 14, color: "var(--color-neutral-700)", lineHeight: 1.6 }}>
|
||||
Goes to {suppliers.length === 1 ? suppliers[0] : `${suppliers.length} suppliers`} as a draft. Nothing is sent until you approve it on Ordering.
|
||||
</p>
|
||||
)}
|
||||
</MBody>
|
||||
{needs.length > 0 && <MBar label={busy ? "Raising…" : `Raise the draft — ${total} item${total === 1 ? "" : "s"}`} onClick={raise} disabled={busy || total === 0} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
/* Delivery round — everything waiting, grouped by ward, handed over on the floor with a signature.
|
||||
Reuses the same signature pad and photo upload as the desktop, so a handover looks identical
|
||||
in the record whichever screen recorded it. */
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { daysBetween, itemMap, label, staffMap, staffName, type PickupRec } from "@/lib/compute";
|
||||
import { uploadPhoto } from "@/lib/photo";
|
||||
import { SignaturePad } from "@/components/dialogs";
|
||||
import { INK, MBar, MBody, MEmpty, MError, MNav, MRow, MRule, MSection, MTop, inputStyle } from "@/components/m";
|
||||
|
||||
export default function MRounds() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
const [pick, setPick] = useState<PickupRec | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const pad = useRef<{ clear: () => void; dataUrl: () => string | null } | null>(null);
|
||||
|
||||
const byId = useMemo(() => itemMap(s), [s]);
|
||||
const staffById = useMemo(() => staffMap(s), [s]);
|
||||
|
||||
/* Grouped by ward — a round is walked ward by ward, not order by order. */
|
||||
const wards = useMemo(() => {
|
||||
const m: Record<string, PickupRec[]> = {};
|
||||
for (const p of s.pickups) {
|
||||
if (p.pickedUp) continue;
|
||||
const w = staffById[p.staffId]?.dept || "No ward recorded";
|
||||
(m[w] ||= []).push(p);
|
||||
}
|
||||
return Object.entries(m).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
}, [s, staffById]);
|
||||
|
||||
const items = (p: PickupRec) => p.lines.map((l) => `${label(byId[l.itemId])} ${l.size}${l.qty > 1 ? ` ×${l.qty}` : ""}`).join(", ");
|
||||
|
||||
const deliver = async () => {
|
||||
if (!pick || saving) return;
|
||||
setSaving(true); setErr("");
|
||||
let sigId: string | null = null;
|
||||
const png = pad.current?.dataUrl() || null;
|
||||
if (png) {
|
||||
const up = await uploadPhoto(mutate, "sig", png);
|
||||
if ("error" in up) { setSaving(false); setErr(up.error); return; }
|
||||
sigId = up.id;
|
||||
}
|
||||
const r = await mutate("pickup.deliver", { id: pick.id, deliveredTo: name.trim(), sigId });
|
||||
setSaving(false);
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setPick(null); setName("");
|
||||
};
|
||||
|
||||
const total = wards.reduce((t, [, ps]) => t + ps.length, 0);
|
||||
|
||||
if (pick) {
|
||||
const st = staffById[pick.staffId];
|
||||
return (
|
||||
<>
|
||||
<MTop title="Hand over" back onBack={() => setPick(null)} right={st?.dept || undefined} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
<section style={{ background: INK, color: "var(--color-bg)", padding: "18px 16px" }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-accent-300)" }}>{pick.orderCode}</div>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 26, letterSpacing: "-0.02em", marginTop: 8 }}>{staffName(st, "Staff member")}</div>
|
||||
<div style={{ fontSize: 13.5, color: "var(--color-neutral-400)", marginTop: 8 }}>{items(pick)}</div>
|
||||
</section>
|
||||
|
||||
<MSection label="Received by" />
|
||||
<div style={{ padding: 16 }}>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name of whoever signs, e.g. the manager" aria-label="Received by" style={inputStyle} />
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)", marginTop: 18, marginBottom: 8 }}>Signature</div>
|
||||
<SignaturePad onReady={(api) => { pad.current = api; }} />
|
||||
<button onClick={() => pad.current?.clear()} style={{ marginTop: 10, background: "none", border: 0, padding: 0, color: "var(--color-accent-700)", fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Clear the signature</button>
|
||||
<p style={{ fontSize: 13.5, color: "var(--color-neutral-700)", marginTop: 18, lineHeight: 1.6 }}>
|
||||
Handing over records the garments as collected — the same as a pickup at the counter — and keeps the name and signature with the record.
|
||||
</p>
|
||||
</div>
|
||||
</MBody>
|
||||
<MBar label={saving ? "Recording…" : "Delivered"} glyph="check" onClick={deliver} disabled={saving || busy} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Round" back right={total ? `${total} to drop off` : undefined} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
{total === 0 ? (
|
||||
<MEmpty title="Nothing to deliver" sub="Everything that has come in has been collected or handed over." />
|
||||
) : wards.map(([ward, ps]) => (
|
||||
<div key={ward}>
|
||||
<MSection label={ward} right={`${ps.length} order${ps.length === 1 ? "" : "s"}`} />
|
||||
{ps.map((p) => {
|
||||
const st = staffById[p.staffId];
|
||||
const days = daysBetween(p.received, s.today);
|
||||
return (
|
||||
<MRow key={p.id} onClick={() => { setPick(p); setName(""); }} mark={days > 10 ? "accent" : "ink"} attention={days > 10}
|
||||
title={staffName(st, "Staff member")}
|
||||
sub={`${items(p)} · waiting ${days}d`}
|
||||
right={<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase" }}>Sign</span>} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</MBody>
|
||||
<MNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
/* One field over people and stock together — at the counter you don't know in advance which one
|
||||
you're after. People take an accent marker, stock lines an ink one. */
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { bcBound, ccOf, label, locMap, locTrail, onhand, touched, reorderAt, staffName, variantName } from "@/lib/compute";
|
||||
import MScan from "@/components/MScan";
|
||||
import { INK, IconScan, MBody, MEmpty, MNav, MRow, MRule, MSection, MTop, inputStyle } from "@/components/m";
|
||||
|
||||
export default function MSearch() {
|
||||
const { s } = useSnap();
|
||||
const { L, byId, variants } = useDerived();
|
||||
const [q, setQ] = useState("");
|
||||
const [scan, setScan] = useState(false);
|
||||
const locs = useMemo(() => locMap(s), [s]);
|
||||
|
||||
const { people, lines } = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
const stockRows = variants.filter((v) => touched(s, L, v.key)).map((v) => ({
|
||||
// Only the bound supplier code: it is what someone reads off a label and types in here.
|
||||
...v, oh: onhand(s, L, v.key), par: reorderAt(s, v.key), code: bcBound(s, v.item, v.si),
|
||||
where: locTrail(locs, s.placed[v.key], 0), name: `${variantName(byId[v.itemId], v.size)}`,
|
||||
}));
|
||||
if (!needle) {
|
||||
const seen: Record<string, string> = {};
|
||||
for (const i of s.issues) seen[i.staffId] = i.date > (seen[i.staffId] || "") ? i.date : seen[i.staffId];
|
||||
return {
|
||||
people: s.staff.filter((x) => !x.inactive).sort((a, b) => (seen[b.id] || "").localeCompare(seen[a.id] || "")).slice(0, 5),
|
||||
lines: stockRows.filter((r) => r.oh <= r.par).slice(0, 5),
|
||||
};
|
||||
}
|
||||
return {
|
||||
people: s.staff.filter((x) => !x.inactive && `${x.first} ${x.last} ${x.num} ${x.dept}`.toLowerCase().includes(needle)).slice(0, 12),
|
||||
lines: stockRows.filter((r) => `${r.name} ${r.code} ${r.where}`.toLowerCase().includes(needle)).slice(0, 20),
|
||||
};
|
||||
}, [s, L, variants, byId, q, locs]);
|
||||
|
||||
const empty = q.trim() && people.length === 0 && lines.length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Search" right={q.trim() ? `${people.length + lines.length} result${people.length + lines.length === 1 ? "" : "s"}` : undefined} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: 16, borderBottom: "2px solid " + INK, display: "flex", gap: 8 }}>
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name, staff number, garment or code" autoFocus
|
||||
aria-label="Search people and stock" style={{ ...inputStyle, flex: 1 }} />
|
||||
<button onClick={() => setScan(true)} aria-label="Scan a barcode"
|
||||
style={{ width: 56, minHeight: 48, border: "2px solid " + INK, background: "var(--color-accent)", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer" }}>
|
||||
<IconScan />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{empty ? (
|
||||
<MEmpty title="Nothing matches that" sub="Try a surname, a staff number, or part of a garment name. Scanning a label finds it straight away." />
|
||||
) : (
|
||||
<>
|
||||
{people.length > 0 && (
|
||||
<>
|
||||
<MSection label={q.trim() ? "People" : "Recently served"} />
|
||||
{people.map((st) => (
|
||||
<MRow key={st.id} href={`/m/person/${st.id}`} mark="accent" title={staffName(st)}
|
||||
sub={[st.num, st.dept || st.group, ccOf(s, st) && `CC ${ccOf(s, st)}`].filter(Boolean).join(" · ")} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{lines.length > 0 && (
|
||||
<>
|
||||
<MSection label={q.trim() ? "Stock" : "At or below par"} right="On hand / par" />
|
||||
{lines.map((r) => (
|
||||
<MRow key={r.key} href="/m/stock" mark="ink" attention={r.oh <= r.par} title={r.name}
|
||||
sub={[r.code || "No barcode bound", r.where].filter(Boolean).join(" · ")}
|
||||
right={<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, fontVariantNumeric: "tabular-nums", color: r.oh <= r.par ? "var(--color-accent-700)" : INK }}>{r.oh}<span style={{ color: "var(--color-neutral-700)" }}>/{r.par}</span></span>} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
<MNav />
|
||||
{scan && <MScan title="Scan to find" onHit={(raw) => {
|
||||
const k = s.barcodes[raw.trim()];
|
||||
const v = k ? variants.find((x) => x.key === k) : undefined;
|
||||
setQ(v ? `${variantName(byId[v.itemId], v.size)}` : raw.trim());
|
||||
setScan(false);
|
||||
}} onClose={() => setScan(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
/* Settings — only what the app itself controls. Everything else about the facility lives on the
|
||||
desktop, so there is one place a setting can be wrong rather than two. */
|
||||
import { DELETE_ACCOUNT_URL, PRIVACY_URL, TERMS_URL } from "@/lib/links";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { locTree } from "@/lib/compute";
|
||||
import { clearAllCounts } from "@/lib/opencount";
|
||||
import { INK, MBody, MError, MRow, MRule, MSection, MStepper, MTop } from "@/components/m";
|
||||
|
||||
const BEEP_KEY = "tc.beep";
|
||||
|
||||
export default function MSettings() {
|
||||
const { s, isAdmin, mutate, busy } = useSnap();
|
||||
const router = useRouter();
|
||||
const [beep, setBeep] = useState(true);
|
||||
const [gate, setGate] = useState(s.settings.varianceReason);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
useEffect(() => { try { setBeep(localStorage.getItem(BEEP_KEY) !== "0"); } catch { /* blocked store */ } }, []);
|
||||
const toggleBeep = () => {
|
||||
const next = !beep;
|
||||
setBeep(next);
|
||||
try { localStorage.setItem(BEEP_KEY, next ? "1" : "0"); } catch { /* blocked store */ }
|
||||
};
|
||||
|
||||
const saveGate = async (n: number) => {
|
||||
setGate(n);
|
||||
const r = await mutate("settings.update", { varianceReason: n });
|
||||
if (!r.ok) { setErr(r.error); setGate(s.settings.varianceReason); }
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
// Their part-counted shelves go with them. The tally is keyed per person, so what is left
|
||||
// behind can never be read by the next signed-in user — but it is theirs, it is on a phone
|
||||
// that is passed around a linen room, and nothing would ever clear it again once they have
|
||||
// gone. Done before the logout POST so a failed request still leaves the device tidy.
|
||||
clearAllCounts(s.session.userId);
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
// /m/login, not /auth: /auth is the website's two-pane desktop sign-in, and landing on it
|
||||
// inside a phone app is how you make someone think the app is broken. A full navigation
|
||||
// rather than router.push, because the session cookie has just been cleared and every page
|
||||
// behind it is server-rendered.
|
||||
window.location.replace("/m/login");
|
||||
};
|
||||
|
||||
const locs = locTree(s).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Settings" back />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 26, letterSpacing: "-0.03em" }}>{s.session.name}</div>
|
||||
<div style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 6 }}>
|
||||
{[s.session.title || s.session.role, s.session.email].filter(Boolean).join(" · ")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MSection label="Site" />
|
||||
<MRow title={s.settings.facility} sub={s.settings.location} right={<span style={{ fontSize: 13, color: "var(--color-neutral-600)" }}>Desktop</span>} />
|
||||
<MRow title="Locations" sub={locs ? `${locs} ${locs === 1 ? "shelf" : "shelves"} and bays set up` : "None set up yet"} right={<span style={{ fontSize: 13, color: "var(--color-neutral-600)" }}>Desktop</span>} />
|
||||
|
||||
<MSection label="Counting" />
|
||||
<MRow title="Beep and buzz on a scan" sub="This device only"
|
||||
right={
|
||||
<button onClick={toggleBeep} role="switch" aria-checked={beep} aria-label="Beep and buzz on a scan"
|
||||
style={{ minWidth: 72, minHeight: 44, border: "2px solid " + INK, background: beep ? INK : "transparent", color: beep ? "var(--color-bg)" : INK, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>
|
||||
{beep ? "On" : "Off"}
|
||||
</button>
|
||||
} />
|
||||
<MRow title="Reason required at" sub={isAdmin ? "A count gap this big has to say why" : "Set by an administrator"}
|
||||
right={isAdmin
|
||||
? <MStepper n={gate} onChange={saveGate} min={1} max={99} />
|
||||
: <span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19 }}>{gate}</span>} />
|
||||
|
||||
{/* Deleting an account has to be reachable from inside the app, not only from a web page
|
||||
somebody has to know exists — this is the app that created the facility in the first
|
||||
place, and it is a Play requirement besides. These rows go to the site's own pages
|
||||
rather than a second deletion screen: there is one account-deletion flow, and it is
|
||||
the one on the website.
|
||||
|
||||
Absolute, and on the web they open in a new tab. On a phone they cannot: this shell
|
||||
registers no browser plugin, so nothing here is able to hand a URL to Chrome, and the
|
||||
page loads over the top of the counter with the site's own nav and no tab bar. The row
|
||||
says as much before the tap, and the hardware back button comes straight back. The other
|
||||
two ways out were worse: a row that does nothing when tapped, or no deletion route in
|
||||
the app at all. Somebody who signs in rather than signing up never passes the links on
|
||||
the create-account screen, so this is the only place the signed-in counter app names
|
||||
them at all. */}
|
||||
<MSection label="Your account and your data" />
|
||||
{DELETE_ACCOUNT_URL && <MRow href={DELETE_ACCOUNT_URL} external mark="ink" title="Delete your account" sub="How to do it, and exactly what goes with it" />}
|
||||
{PRIVACY_URL && <MRow href={PRIVACY_URL} external mark="ink" title="Privacy policy" sub="What ThreadCount stores, and what it never does" />}
|
||||
{TERMS_URL && <MRow href={TERMS_URL} external mark="ink" title="Terms of use" sub="What you and ThreadCount each agree to" />}
|
||||
|
||||
<div style={{ padding: 16 }}>
|
||||
<button onClick={signOut} disabled={busy}
|
||||
style={{ width: "100%", minHeight: 64, border: "2px solid var(--color-accent)", background: "transparent", color: "var(--color-accent-700)", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase", textAlign: "left", padding: "0 20px", cursor: "pointer" }}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ padding: "0 16px 26px", fontSize: 13.5, color: "var(--color-neutral-600)", lineHeight: 1.6 }}>
|
||||
Ordering, reports, the catalogue and the staff register are all on the desktop site.
|
||||
</p>
|
||||
</MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
/* Signed in — onboarding screen 05. A beat of confirmation before the app: which linen room you
|
||||
are now in, and the two figures that decide what the morning looks like. */
|
||||
import { Suspense, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { daysBetween, locTree, onhand, reorderAt, touched } from "@/lib/compute";
|
||||
|
||||
function SignedInInner() {
|
||||
const { s } = useSnap();
|
||||
const { L, variants } = useDerived();
|
||||
const isNew = useSearchParams().get("new") === "1";
|
||||
|
||||
const d = useMemo(() => {
|
||||
const counted = variants.filter((v) => touched(s, L, v.key));
|
||||
const low = counted.filter((v) => onhand(s, L, v.key) <= reorderAt(s, v.key)).length;
|
||||
const lastCount = s.stocktakes.find((t) => t.mode !== "preloved");
|
||||
return {
|
||||
lines: counted.length,
|
||||
low,
|
||||
since: lastCount ? daysBetween(lastCount.date, s.today) : null,
|
||||
locations: locTree(s).length,
|
||||
};
|
||||
}, [s, L, variants]);
|
||||
|
||||
const row: React.CSSProperties = { display: "flex", alignItems: "baseline", gap: 12, padding: "14px 0", borderBottom: "1px solid var(--color-divider)" };
|
||||
const lab: React.CSSProperties = { flex: 1, fontSize: 14.5, color: "var(--color-neutral-800)" };
|
||||
// Figures get the big numeral; "Never counted" is a sentence and was being set at the same size,
|
||||
// where it ran nearly the width of the row and read as the loudest thing on the screen.
|
||||
const val = (hot?: boolean, text?: boolean): React.CSSProperties => ({ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: text ? 14.5 : 19, fontVariantNumeric: "tabular-nums", color: hot ? "var(--color-accent-700)" : "var(--color-text)" });
|
||||
const overdue = d.since !== null && d.since > 30;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section style={{ background: "var(--color-accent)", color: "#fff", padding: "calc(34px + env(safe-area-inset-top, 0px)) 24px 34px" }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.14em", textTransform: "uppercase", color: "rgba(255,255,255,0.88)" }}>
|
||||
{isNew ? "Facility created" : "Signed in"}
|
||||
</div>
|
||||
<h1 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 36, lineHeight: 1.02, letterSpacing: "-0.03em", marginTop: 10 }}>
|
||||
{s.settings.facility}
|
||||
</h1>
|
||||
</section>
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "24px 24px 30px", background: "var(--color-bg)" }}>
|
||||
{isNew ? (
|
||||
<p style={{ fontSize: 15, lineHeight: 1.6, color: "var(--color-neutral-800)" }}>
|
||||
Your linen room is set up and you are its first administrator. There is nothing in it
|
||||
yet — the catalogue, staff register and cost centres come in from CSV on the desktop
|
||||
site, and take about an afternoon.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>
|
||||
{s.settings.location || "Linen Room"}
|
||||
</div>
|
||||
<div style={{ marginTop: 14, borderTop: "2px solid var(--color-text)" }}>
|
||||
<div style={row}><span style={lab}>Lines on the shelf</span><span style={val()}>{d.lines}</span></div>
|
||||
<div style={row}><span style={lab}>Below par</span><span style={val(d.low > 0)}>{d.low}</span></div>
|
||||
<div style={row}>
|
||||
<span style={lab}>Since the last count</span>
|
||||
<span style={val(overdue, d.since === null)}>{d.since === null ? "Never counted" : `${d.since} day${d.since === 1 ? "" : "s"}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
{d.locations === 0 && (
|
||||
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: "var(--color-neutral-800)", background: "#fff", borderLeft: "6px solid var(--color-accent)", padding: 14, marginTop: 20 }}>
|
||||
No shelves set up yet, so there is nothing to count against. Add them in Settings on
|
||||
the desktop, then place each size on one from Inventory.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link href="/m" replace
|
||||
style={{ flex: "0 0 auto", height: 66, background: "var(--color-text)", color: "var(--color-bg)", display: "flex", alignItems: "center", gap: 12, padding: "0 24px calc(0px + env(safe-area-inset-bottom, 0px))", textDecoration: "none", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.08em", textTransform: "uppercase" }}>
|
||||
<span style={{ flex: 1 }}>Start the day</span>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="square" aria-hidden="true"><path d="M5 12h14" /><path d="m13 6 6 6-6 6" /></svg>
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MSignedIn() {
|
||||
return <Suspense fallback={null}><SignedInInner /></Suspense>;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
/* Stock — on hand against par, worst first, with the three things you do about it underneath. */
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { bcBound, label, locMap, locTrail, onhand, touched, reorderAt, variantName } from "@/lib/compute";
|
||||
import { INK, IconRight, MBody, MEmpty, MNav, MRow, MRule, MSection, MTop, inputStyle } from "@/components/m";
|
||||
|
||||
export default function MStock() {
|
||||
const { s } = useSnap();
|
||||
const { L, byId, variants } = useDerived();
|
||||
const [q, setQ] = useState("");
|
||||
const locs = useMemo(() => locMap(s), [s]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
return variants
|
||||
.filter((v) => touched(s, L, v.key))
|
||||
.map((v) => {
|
||||
const oh = onhand(s, L, v.key), par = reorderAt(s, v.key);
|
||||
// The bound supplier code, not bcFor()'s generated stand-in: this line is read against a
|
||||
// label on a garment, and a number printed on nothing is worse than saying there isn't one.
|
||||
return { ...v, oh, par, low: oh <= par, code: bcBound(s, v.item, v.si), where: locTrail(locs, s.placed[v.key], 0), name: `${variantName(byId[v.itemId], v.size)}` };
|
||||
})
|
||||
.filter((r) => !needle || `${r.name} ${r.code} ${r.where}`.toLowerCase().includes(needle))
|
||||
// Short lines first, then furthest below par — the shelf you have to do something about.
|
||||
.sort((a, b) => Number(b.low) - Number(a.low) || (a.oh - a.par) - (b.oh - b.par) || a.name.localeCompare(b.name));
|
||||
}, [s, L, variants, byId, q, locs]);
|
||||
|
||||
const low = rows.filter((r) => r.low).length;
|
||||
const link: 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="Stock" right={low ? `${low} below par` : `${rows.length} line${rows.length === 1 ? "" : "s"}`} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: 16, borderBottom: "2px solid " + INK }}>
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Garment, code or shelf" aria-label="Filter stock" style={inputStyle} />
|
||||
</div>
|
||||
<MSection label="Line" right="On hand / par" />
|
||||
{rows.length === 0
|
||||
? <MEmpty title="Nothing in stock yet" sub="This list is what has moved. Add a garment to the catalogue and scan some in, and it appears here." />
|
||||
: rows.slice(0, 200).map((r) => (
|
||||
<MRow key={r.key} mark={r.low ? "accent" : "ink"} attention={r.low}
|
||||
title={r.name}
|
||||
sub={[r.code || "No barcode bound", `par ${r.par}`, r.where].filter(Boolean).join(" · ")}
|
||||
right={
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 21, fontVariantNumeric: "tabular-nums", color: r.low ? "var(--color-accent-700)" : INK }}>
|
||||
{r.oh}<span style={{ color: "var(--color-neutral-700)", fontSize: 17 }}>/{r.par}</span>
|
||||
</span>
|
||||
} />
|
||||
))}
|
||||
|
||||
<div style={{ padding: 16, display: "grid", gap: 12 }}>
|
||||
<Link href="/m/reorder" style={link}><span style={{ flex: 1 }}>{low ? `Reorder ${low} line${low === 1 ? "" : "s"}` : "Reorder draft"}</span><IconRight /></Link>
|
||||
<Link href="/m/variance" style={link}><span style={{ flex: 1 }}>Variance over time</span><IconRight /></Link>
|
||||
<Link href="/m/label" style={link}><span style={{ flex: 1 }}>Reprint a label</span><IconRight /></Link>
|
||||
{/* Stock only lists variants with history, so a garment added five minutes ago isn’t here
|
||||
yet. The catalogue is where it actually lives. */}
|
||||
<Link href="/m/catalogue" style={link}><span style={{ flex: 1 }}>Catalogue</span><IconRight /></Link>
|
||||
</div>
|
||||
</MBody>
|
||||
<MNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
/* Variance over time — the pattern, not the number. A line short at every count is a different
|
||||
problem from one short once, so the chart is the point and the latest gap is the caption. */
|
||||
import { useMemo } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { itemMap, monthLabel, variantName } from "@/lib/compute";
|
||||
import { INK, MBody, MEmpty, MNav, MRule, MTop } from "@/components/m";
|
||||
|
||||
const MAX_BAR = 56;
|
||||
|
||||
export default function MVarianceOverTime() {
|
||||
const { s } = useSnap();
|
||||
|
||||
const { rows, counts } = useMemo(() => {
|
||||
const byId = itemMap(s);
|
||||
// Oldest-first, shelf counts only, last six.
|
||||
const takes = s.stocktakes.filter((t) => t.mode !== "preloved").slice(0, 6).reverse();
|
||||
const seen: Record<string, { name: string; gaps: (number | null)[] }> = {};
|
||||
takes.forEach((t, col) => {
|
||||
for (const l of t.lines) {
|
||||
const k = `${l.itemId}:${l.si}`;
|
||||
const it = byId[l.itemId];
|
||||
if (!it) continue;
|
||||
(seen[k] ||= { name: variantName(it, it.sizes[l.si] ?? l.si), gaps: takes.map(() => null) });
|
||||
seen[k].gaps[col] = l.counted - l.sys;
|
||||
}
|
||||
});
|
||||
const out = Object.entries(seen).map(([k, v]) => {
|
||||
const known = v.gaps.filter((g): g is number => g !== null);
|
||||
const latest = [...v.gaps].reverse().find((g) => g !== null) ?? 0;
|
||||
const shortEvery = known.length >= 2 && known.every((g) => g < 0);
|
||||
const worsening = known.length >= 3 && known[known.length - 1] < known[0] && known[known.length - 1] < 0;
|
||||
const verdict = known.every((g) => g === 0) ? "Steady"
|
||||
: shortEvery ? `Short at every count since ${monthLabel(takes[v.gaps.findIndex((g) => g !== null)]?.date.slice(0, 7) || "", { month: "long" })}`
|
||||
: worsening ? "Drifting short"
|
||||
: latest === 0 ? "Back in line" : "Occasional gap";
|
||||
const persistent = shortEvery || worsening;
|
||||
return { key: k, name: v.name, gaps: v.gaps, latest, verdict, persistent };
|
||||
});
|
||||
// Worst pattern first: persistent problems, then biggest gap.
|
||||
out.sort((a, b) => Number(b.persistent) - Number(a.persistent) || a.latest - b.latest || a.name.localeCompare(b.name));
|
||||
return { rows: out, counts: takes };
|
||||
}, [s]);
|
||||
|
||||
const peak = Math.max(1, ...rows.flatMap((r) => r.gaps.map((g) => Math.abs(g ?? 0))));
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Variance" back right={`${counts.length} count${counts.length === 1 ? "" : "s"}`} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
|
||||
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 30, letterSpacing: "-0.03em", lineHeight: 1.05 }}>What keeps going missing</h2>
|
||||
<p style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 8 }}>
|
||||
{counts.length ? `Gap against expected at each count since ${monthLabel(counts[0].date.slice(0, 7), { month: "long" })}.` : "Nothing counted yet."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<MEmpty title="No counts to compare yet" sub="File two stocktakes and the pattern starts showing here." />
|
||||
) : rows.slice(0, 40).map((r) => (
|
||||
<div key={r.key} style={{ padding: "18px 16px 14px", background: r.persistent ? "#fff" : "var(--color-bg)", borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 17, fontWeight: 700, letterSpacing: "-0.01em" }}>{r.name}</div>
|
||||
<div style={{ fontSize: 13.5, color: "var(--color-neutral-600)", marginTop: 3 }}>{r.verdict}</div>
|
||||
</div>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: r.latest === 0 ? 21 : 24, letterSpacing: "-0.02em", color: r.latest === 0 ? INK : "var(--color-accent-700)", fontVariantNumeric: "tabular-nums" }}>
|
||||
{r.latest === 0 ? "Match" : r.latest > 0 ? `+${r.latest}` : `−${-r.latest}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tcx-chart" style={{ marginTop: 18 }} role="img"
|
||||
aria-label={`Gap at each count: ${r.gaps.map((g, i) => `${counts[i] ? monthLabel(counts[i].date.slice(0, 7), { month: "short" }) : ""} ${g === null ? "not counted" : g}`).join(", ")}`}>
|
||||
{r.gaps.map((g, i) => {
|
||||
const mag = Math.abs(g ?? 0);
|
||||
const h = g === null ? 4 : Math.max(4, Math.round((mag / peak) * MAX_BAR));
|
||||
const col = g === null ? "var(--color-neutral-300)" : mag === 0 ? "var(--color-divider)" : mag >= 3 ? "var(--color-accent)" : INK;
|
||||
return <i key={i} style={{ height: h, background: col }} />;
|
||||
})}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 5, marginTop: 6 }}>
|
||||
{counts.map((t, i) => (
|
||||
<span key={i} style={{ flex: 1, textAlign: "center", fontSize: 10.5, fontWeight: 600, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>
|
||||
{monthLabel(t.date.slice(0, 7), { month: "short" })}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</MBody>
|
||||
<MNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user