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 38e16eb on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-16 07:57:54 +10:00
commit 0910bc32c1
457 changed files with 55952 additions and 0 deletions
+466
View File
@@ -0,0 +1,466 @@
"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.
*
* `?bind=<code>` arrives from a scan that found nothing: each size offers to take that code. */
import { useEffect, useMemo, useState } from "react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { bcBound, formatInZone, key as vkey, label, money, onhand, reorderAt, splitKey, type CostRec, type Item } from "@/lib/compute";
import { printItemLabels } from "@/lib/nativeprint";
import MScan from "@/components/MScan";
import {
ACCENT, GROUND, INK, ON_DARK, MBar, MBody, MButton, MEmpty, MError, MField, MONO, MPill, MRule, MSection, MTop, MTopCount, inputStyle, useToast,
} 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. */
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: 13, fontWeight: 700, color: "var(--color-neutral-700)",
textAlign: "left", cursor: "pointer",
};
/* A freshly minted number exists in ThreadCount at once, but nothing is on the garment until it is
printed, so the confirmation carries the print with it. */
function MMade({ made, labels, onPrint }: { made: { size: string; code: string }[]; labels: number; onPrint: () => void }) {
if (!made.length) return null;
return (
<div style={{ margin: "10px 0 0", padding: 16, background: INK, color: GROUND, fontSize: 14 }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 16 }}>
{made.length === 1 ? `Size ${made[0].size} has a barcode` : `${made.length} sizes have a barcode`}
</div>
<div style={{ marginTop: 8, color: ON_DARK, fontSize: 13, fontFamily: MONO }}>
{made.map((m) => <div key={m.code}>{m.size} · {m.code}</div>)}
</div>
{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} label{labels === 1 ? "" : "s"}
</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 sp = useSearchParams();
const toast = useToast();
const bindRaw = (sp.get("bind") || "").trim().slice(0, 80);
const bindCode = isAdmin ? bindRaw : "";
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. -1 is the whole garment. */
const [genFor, setGenFor] = useState<number | null>(null);
const [made, setMade] = useState<{ si: number; size: string; code: string }[]>([]);
// Binding is admin-only; an Issuer who arrives with a code goes back to the catalogue.
useEffect(() => { if (bindRaw && !isAdmin) router.replace("/m/catalogue"); }, [bindRaw, isAdmin, router]);
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 pad><MEmpty title="No such garment" sub="It isnt in the catalogue any more." /></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 many labels a print run would produce —
// one per garment on the shelf in a size that carries a code.
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, and saving a stale form would put an
* old price back in someone else's name. */
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: when the code already sits on another
* garment, offer the move rather than printing the message and stopping there. A generated
* 93XXXXXXX code is refused with or without force and is shown as it came. */
async function bind(si: number, raw: string): Promise<boolean> {
const code = raw.trim();
if (!code) return false;
setErr(""); setMade([]);
const r = await mutate("barcode.bind", { code, itemId: it!.id, si });
if (r.ok) { setTypeFor(null); setTyped(""); return true; }
const at = boundElsewhere(code);
if (!at && !/is already on/.test(r.error)) { setErr(r.error); return false; }
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 false; }
const moved = await mutate("barcode.bind", { code, itemId: it!.id, si, force: true });
if (!moved.ok) { setErr(moved.error); return false; }
setTypeFor(null); setTyped("");
return true;
}
async function bindScanned(si: number) {
if (!(await bind(si, bindCode))) return;
toast(`${bindCode} bound to size ${it!.sizes[si]}`);
router.replace(`/m/catalogue/${encodeURIComponent(it!.id)}`);
}
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; removing shifts later sizes down a place, so anything
* held open against a position lets 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([]);
}
/* Our own barcode for stock that arrived without one. The server fills only the gaps and refuses
* when there is nothing to do, so the offer is made and whatever comes back is shown. */
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);
}
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. Android
printing in the app; a printable page in a browser. */
async function printLabels() {
const r = await printItemLabels({ itemId: it!.id });
if (!r.ok) setErr(r.error);
}
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");
}
const bigBtn = (tone: "line" | "accent" | "ink"): React.CSSProperties => ({
width: "100%", minHeight: 52, border: "2px solid " + (tone === "accent" ? ACCENT : INK),
background: tone === "accent" ? ACCENT : tone === "ink" ? INK : "transparent", color: tone === "line" ? INK : tone === "ink" ? GROUND : "#fff",
font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer",
});
return (
<>
<MTop title={it.archived ? "Archived" : "Garment"} right={<MTopCount>{it.sizes.length} size{it.sizes.length === 1 ? "" : "s"}</MTopCount>} back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
{(it.archived || bindCode) && (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 10 }}>
{it.archived && <MPill tone="accent">Archived</MPill>}
{bindCode && <MPill tone="accent" mono>Binding {bindCode}</MPill>}
</div>
)}
{/* ---- the description ---- */}
{!editing ? (
<>
<div style={{ paddingBottom: 14, borderBottom: "1px solid var(--color-divider)" }}>
<h2 style={{ fontWeight: 900, fontSize: 24, lineHeight: 1.1, margin: 0 }}>{name}</h2>
<div style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 6 }}>
{[it.type, it.group === "All" ? "Anyone" : it.group, it.supplier, it.sku].filter(Boolean).join(" · ") || "No details yet"}
</div>
<div style={{ fontSize: 14, fontFamily: MONO, marginTop: 4 }}>{it.cost ? `${money(it.cost)} each` : "No unit cost"}</div>
{it.notes && <div style={{ fontSize: 13, color: "var(--color-neutral-800)", marginTop: 8 }}>{it.notes}</div>}
</div>
{!readOnly && !bindCode && (
<div style={{ padding: "14px 0", borderBottom: "2px solid " + INK }}>
<button onClick={startEdit} style={bigBtn("line")}>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: "14px 0", display: "grid", gap: 10, borderBottom: "2px solid " + INK }}>
<button onClick={saveDetails} disabled={busy} style={bigBtn("accent")}>{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: 14, 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 fallback is printed on no garment.
const code = bcBound(s, it, si);
return (
<div key={si} style={{ padding: "14px 0", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{ fontFamily: MONO, fontWeight: 600, fontSize: 18, 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, fontFamily: MONO }}>{oh}</b> on hand
</div>
<div style={{ fontSize: 12, fontFamily: MONO, 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 aria-label={`Par for ${sz}: ${par}`} style={{ minWidth: 44, height: 44, background: INK, color: GROUND, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: MONO, 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 && bindCode && code !== bindCode && (
<MButton small tone="ink" label={`Bind to size ${sz}`} disabled={busy} onClick={() => bindScanned(si)} />
)}
{!readOnly && !bindCode && (
<div style={{ marginTop: 10 }}>
{typeFor === si ? (
<div style={{ display: "grid", gap: 8 }}>
{/* A numeric keypad hint only: whatever arrives is taken as typed. */}
<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>
{/* Offered only where nothing is bound: a supplier's printed code always 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} labels={labels} onPrint={printLabels} />}
</div>
)}
</div>
);
})}
{!readOnly && !bindCode && (
<div style={{ padding: "14px 0", borderBottom: "2px solid " + INK, display: "flex", gap: 10 }}>
<input value={newSize} onChange={(e) => setNewSize(e.target.value)} placeholder="Add a size" aria-label="New 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 && !bindCode && (
<>
<MSection label="Barcodes" right={unlabelled ? `${unlabelled} without` : "All labelled"} />
{made.length > 1 && <MMade made={made} labels={labels} onPrint={printLabels} />}
<div style={{ padding: "14px 0", display: "grid", gap: 10, borderBottom: "2px solid " + INK }}>
<button onClick={generateAll} disabled={busy} style={{ ...bigBtn("line"), opacity: busy ? 0.5 : 1 }}>
{genFor === -1 ? "Generating…" : "Generate for every unlabelled size"}
</button>
<button onClick={printLabels} disabled={labels === 0}
style={{ ...bigBtn("accent"), cursor: labels === 0 ? "not-allowed" : "pointer", opacity: labels === 0 ? 0.4 : 1 }}>
{labels ? `Print ${labels} label${labels === 1 ? "" : "s"}` : "Nothing to print"}
</button>
</div>
</>
)}
{!readOnly && !bindCode && (
<div style={{ padding: "14px 0" }}>
<button onClick={archive} style={{ ...quietAction, color: it.archived ? "var(--color-accent-700)" : "var(--color-neutral-700)" }}>
{it.archived ? "Put this garment back in the catalogue" : "Archive this garment"}
</button>
</div>
)}
{/* What we used to pay: a price rise would otherwise erase the old figure. */}
{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 0", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ fontFamily: MONO, fontWeight: 600, fontSize: 15, minWidth: 78 }}>{money(c.cost)}</div>
<div style={{ flex: 1, minWidth: 0, fontSize: 13, color: "var(--color-neutral-700)" }}>
{c.previous === null ? "Opening price" : `${c.previous > c.cost ? "Down" : "Up"} from ${money(c.previous)}`}
{" · "}
{/* The facility's zone, not the device's, so server and browser render the same day. */}
{formatInZone(c.at, s.tz)}
</div>
<div style={{ fontSize: 12, color: "var(--color-neutral-600)", whiteSpace: "nowrap" }}>{c.byName}</div>
</div>
))}
</>
)}
</MBody>
{!readOnly && !it.archived && !bindCode && <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); }}
/>
)}
</>
);
}
+136
View File
@@ -0,0 +1,136 @@
"use client";
/* Create a garment from the counter.
*
* 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. Sizes are entered as a
* run rather than one at a time, because that is how they arrive. */
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, MEmpty, MError, MField, 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; groups already in use on the register or
* the catalogue are folded in so none that predates the configured list vanishes. */
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 pad><MEmpty title="Admins only" sub="An admin adds garments to the catalogue." /></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 />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
<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 role="group" aria-label="Common size runs" style={{ paddingBottom: 12, display: "flex", flexWrap: "wrap", gap: 8 }}>
{COMMON_RUNS.map(([run, pretty]) => (
<button key={run} type="button" aria-pressed={sizeText === run} onClick={() => { setSizeText(run); setErr(""); }}
style={{ minHeight: 44, border: "2px solid " + INK, background: sizeText === run ? INK : "transparent", color: sizeText === run ? "var(--color-bg)" : INK, padding: "0 12px", fontFamily: "inherit", fontSize: 13, fontWeight: 700, cursor: "pointer" }}>
{pretty}
</button>
))}
</div>
{sizes.length > 0 && (
<div style={{ paddingBottom: 12, 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>
</MBody>
<MBar label={busy ? "Saving…" : "Create garment"} onClick={save} disabled={busy} />
</>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
/* The whole catalogue on the phone, not just what's on the shelf: Stock lists only sizes with
* history, so a garment created five minutes ago lives here. `?bind=<code>` arrives from a scan that
* found nothing; picking a garment carries the code to its product card (admins only). */
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { label, money, type Item } from "@/lib/compute";
import { MBody, MButton, MEmpty, MPill, MRow, MRule, MSearch, MSection, MTop, MTopCount } from "@/components/m";
const PAGE = 300;
export default function MCatalogue() {
const { s, isAdmin } = useSnap();
const { byId } = useDerived();
const router = useRouter();
const sp = useSearchParams();
const bindRaw = (sp.get("bind") || "").trim().slice(0, 80);
const bind = isAdmin ? bindRaw : "";
const [q, setQ] = useState("");
const [showArchived, setShowArchived] = useState(false);
const [shown, setShown] = useState(PAGE);
// Binding is an admin job; an Issuer arriving with a code just gets the catalogue.
useEffect(() => { if (bindRaw && !isAdmin) router.replace("/m/catalogue"); }, [bindRaw, isAdmin, router]);
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", i.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 cardHref = (id: string) => `/m/catalogue/${encodeURIComponent(id)}${bind ? `?bind=${encodeURIComponent(bind)}` : ""}`;
return (
<>
<MTop title="Catalogue" back right={<MTopCount>{rows.length}</MTopCount>} />
<MRule />
<MBody pad>
<MSearch value={q} onChange={(v) => { setQ(v); setShown(PAGE); }} placeholder="Garment, code, supplier" label="Filter the catalogue" />
{bind && <div style={{ marginTop: 10 }}><MPill tone="accent" mono>Binding {bind}</MPill></div>}
{isAdmin && !bind && <MButton icon="plus" label="New garment" href="/m/catalogue/new" />}
<MSection label={showArchived ? "Archived" : "Garments"} right={rows.length} />
{rows.length === 0
? <MEmpty title={q ? "Nothing matches" : showArchived ? "Nothing archived" : "No garments yet"}
sub={q ? "Try a shorter search." : undefined} />
: rows.slice(0, shown).map((i) => (
<MRow key={i.id} href={isAdmin ? cardHref(i.id) : undefined} chev={isAdmin}
mark={i.archived ? "mute" : "ink"} title={i.name} sub={i.sub}
right={i.cost ? money(i.cost) : undefined} />
))}
{rows.length > shown && (
<>
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 10 }}>Showing {shown} of {rows.length}</div>
<MButton small label="Show more" onClick={() => setShown((n) => n + PAGE)} />
</>
)}
{archivedCount > 0 && (
<MButton small label={showArchived ? "Current catalogue" : `Show ${archivedCount} archived`}
onClick={() => { setShowArchived(!showArchived); setShown(PAGE); }} />
)}
</MBody>
</>
);
}
+210
View File
@@ -0,0 +1,210 @@
"use client";
/* Counting: scan a garment and its own line goes up by one and becomes the line being counted.
Expected figures stay on screen (a sighted count, as the phone has always been). The tally lives
in localStorage (lib/opencount.ts), so backgrounding the app mid-shelf loses nothing.
Hands-free keeps the camera reading continuously. Inside the Android shell MLKit runs with its
preview behind this opaque screen, so the panel and the list stay in view and each garment is a
beep, a buzz and the figures moving (handsfree.png). In a browser the camera needs a visible,
playing <video>, so hands-free opens the live camera overlay with the same panel and switch. */
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSnap } from "@/lib/client";
import { UNPLACED } from "@/lib/compute";
import MScan from "@/components/MScan";
import { isNative, startLive } from "@/lib/nativescan";
import { scanReject, scanTick } from "@/lib/feedback";
import { track } from "@/lib/analytics";
import { useKeepAwake } from "@/lib/wakelock";
import { SHELF_LABEL_PREFIX } from "@/lib/scanroute";
import { readCount, writeCount } from "@/lib/opencount";
import { GROUND, INK, MAction, MBody, MButton, MEmpty, MError, MRow, MRule, MSection, MSplit, MTop, MTopAction } from "@/components/m";
import { CountFigures, CountPanel, HandsFree, TypeCount } from "@/components/m/count/CountPanel";
import { lineTitle, useCountLines } from "@/components/m/count/lines";
const DEBOUNCE_MS = 900;
export default function MCounting() {
const { s } = useSnap();
const router = useRouter();
const locationId = String(useParams().id || "");
const { lines, locName, locs } = useCountLines(locationId);
const [counted, setCounted] = useState<Record<string, number>>({});
// Held by variant key, never by position: the list is rebuilt on every live refresh.
const [activeKey, setActiveKey] = useState("");
const [single, setSingle] = useState(false);
const [hands, setHands] = useState(false);
// "inline": MLKit behind this screen. "overlay": the live camera overlay (browser, or a shell without MLKit).
const [handsMode, setHandsMode] = useState<"inline" | "overlay">("inline");
const [typing, setTyping] = useState(false);
const [log, setLog] = useState<string[]>([]);
const [err, setErr] = useState("");
const [loaded, setLoaded] = useState(false);
// Restore this person's open count of this shelf, once per shelf. The key includes the user: the
// phone is shared. It deliberately does not re-run on `lines`, so a size moved off the shelf from
// the desktop mid-count keeps what was already counted against it.
const me = s.session.userId;
useEffect(() => {
setCounted(readCount(me, locationId)?.n ?? {});
// Recount from Check the gaps lands here with ?line=<key>.
try {
const line = new URLSearchParams(window.location.search).get("line");
if (line) setActiveKey(line);
} catch { /* no query to read */ }
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 curName = cur ? `${lineTitle(cur)} · ${cur.size}` : "";
const curN = cur ? counted[cur.key] ?? 0 : 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();
if (!code) return;
const hit = s.barcodes[code];
const line = hit ? lines.find((l) => l.key === hit) : lines.find((l) => l.code === code);
if (!line) {
scanReject();
const known = Object.prototype.hasOwnProperty.call(s.barcodes, code);
track("scan_miss", { kind: known ? "wrong_shelf" : "unknown" });
const shelf = code.startsWith(SHELF_LABEL_PREFIX) ? locs[code.slice(SHELF_LABEL_PREFIX.length)] : undefined;
const placedAt = hit ? locs[s.placed[hit] || ""]?.name : "";
setErr(shelf ? `Thats the label for ${shelf.name}`
: !known ? `${code} isnt a garment ThreadCount knows`
: placedAt ? `${code} is on ${placedAt}, not this shelf`
: locationId === UNPLACED ? `${code} isnt in this count` : `${code} isnt on a shelf yet`);
setLog((g) => [`${code}: not on this shelf`, ...g].slice(0, 8));
return;
}
setActiveKey(line.key);
setTyping(false);
bump(line.key, 1);
setErr("");
setLog((g) => [`${lineTitle(line)} ${line.size}`, ...g].slice(0, 8));
}, [s.barcodes, s.placed, lines, locs, locationId, bump]);
const onCodeRef = useRef(onCode); onCodeRef.current = onCode;
// Hands-free inside the Android shell: MLKit reads continuously behind this screen. This screen's
// root carries `tcx-scanui` while it runs, so globals.css leaves it visible and opaque.
const [inlineLive, setInlineLive] = useState(false);
useEffect(() => {
if (!hands || handsMode !== "inline") return;
if (!isNative()) { setHandsMode("overlay"); return; }
let session: { stop: () => Promise<void> } | null = null;
let cancelled = false;
let lastRaw = "", lastT = 0;
setInlineLive(true);
(async () => {
const r = await startLive((raw) => {
if (raw === lastRaw && Date.now() - lastT < DEBOUNCE_MS) return;
lastRaw = raw; lastT = Date.now();
scanTick();
onCodeRef.current(raw);
});
if (cancelled) { await r.stop(); return; }
if (r.error === "native-unavailable") { setInlineLive(false); setHandsMode("overlay"); return; }
if (r.error) { setInlineLive(false); setHands(false); setErr(r.error); return; }
session = r;
})();
track("scan_opened", { mode: "live", engine: "mlkit-inline" });
return () => { cancelled = true; setInlineLive(false); if (session) session.stop(); };
}, [hands, handsMode]);
const toggleHands = () => {
setTyping(false);
setSingle(false);
setHands((h) => !h);
};
if (loaded && !lines.length) {
return (
<>
<MTop title={locName} back />
<MRule />
<MBody pad><MEmpty title="Nothing on this shelf" sub="Place sizes on it in the portal" /></MBody>
</>
);
}
const panelInner = cur ? <CountFigures name={curName} counted={curN} expected={cur.expected} /> : null;
return (
// display: contents keeps every piece a direct flex child of .tcx-app as before; the class is what
// spares this screen while MLKit's preview runs behind the WebView.
<div className={inlineLive ? "tcx-scanui" : undefined} style={{ display: "contents" }}>
<MTop title={locName} back right={<MTopAction label="Finish" onClick={() => { setHands(false); router.push(`/m/count/${locationId}/variance`); }} />} />
<MRule n={total} of={expectedAll} />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
{cur && (
<CountPanel>
{panelInner}
<HandsFree on={hands} onToggle={toggleHands} />
{typing && (
<TypeCount key={cur.key} name={curName} value={curN}
onSet={(n) => { setCounted((c) => ({ ...c, [cur.key]: n })); setTyping(false); }} />
)}
</CountPanel>
)}
<MSection label="Lines" right={`${total} of ${expectedAll}`} />
{lines.map((l) => {
const n = counted[l.key] ?? 0;
const on = !!cur && l.key === cur.key;
return (
<MRow key={l.key} active={on} onClick={() => { setActiveKey(l.key); setTyping(false); }}
mark={n === l.expected ? "ok" : n > 0 ? "ink" : "mute"}
title={`${lineTitle(l)} ${l.size}`}
sub={l.where || undefined}
right={`${n}/${l.expected}`} />
);
})}
<div style={{ marginTop: 14 }}>
<MButton label="Type a count" onClick={() => { setHands(false); setTyping((t) => !t); }} disabled={!cur} />
</div>
</MBody>
<MSplit>
<MAction label="Undo" flex={1} tone="ink" onClick={() => cur && bump(cur.key, -1)} disabled={!cur || curN <= 0} />
<MAction label="Scan" flex={2} glyph="scan" glyphAt="right" onClick={() => { setHands(false); setTyping(false); setSingle(true); }} />
</MSplit>
{single && (
<MScan title="Scan a garment" onHit={(raw) => { onCode(raw); setSingle(false); }} onClose={() => setSingle(false)} />
)}
{hands && handsMode === "overlay" && (
<MScan
title="Hands-free"
live
running
log={log}
debounceMs={DEBOUNCE_MS}
onHit={onCode}
onClose={() => setHands(false)}
figure={cur ? <div style={{ background: INK, color: GROUND, padding: "12px 16px 0" }}>{panelInner}</div> : undefined}
control={<div style={{ background: INK, padding: "0 16px calc(12px + env(safe-area-inset-bottom, 0px))" }}><HandsFree on onToggle={() => setHands(false)} /></div>}
/>
)}
</div>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
/* Check the gaps: the lines that don't match, each with a reason, then the commit.
A gap at or over the facility's threshold (settings.varianceReason, enforced again by
stocktake.apply) must carry a reason before the bar will commit. The reason chosen is stored
on the stocktake line. */
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useSnap } from "@/lib/client";
import { UNPLACED } from "@/lib/compute";
import { clearCount, readCount, writeCount } from "@/lib/opencount";
import { INK, MBar, MBody, MEmpty, MError, MKick, MLine, MPill, MReasonChips, MRow, MRule, MSection, MTop } from "@/components/m";
import { OVER_REASONS, SHORT_REASONS, lineTitle, signed, useCountLines } from "@/components/m/count/lines";
const chip: React.CSSProperties = {
minHeight: 44, minWidth: 48, padding: "0 12px", border: "2px solid " + INK, background: "transparent", color: INK,
fontFamily: "inherit", fontSize: 14, fontWeight: 700, cursor: "pointer", borderRadius: 0, flex: "none",
};
export default function MVariance() {
const { s, mutate, busy } = useSnap();
const router = useRouter();
const locationId = String(useParams().id || "");
const { lines, locName } = useCountLines(locationId);
const [counted, setCounted] = useState<Record<string, number> | null>(null);
const [reason, setReason] = useState<Record<string, string>>({});
const [err, setErr] = useState("");
const me = s.session.userId;
useEffect(() => {
setCounted(readCount(me, locationId)?.n ?? {});
}, [me, locationId]);
const gate = Math.max(1, s.settings.varianceReason);
const gaps = useMemo(() => (counted ? lines.filter((l) => (counted[l.key] ?? 0) !== l.expected) : []), [counted, lines]);
const matches = useMemo(() => (counted ? lines.filter((l) => (counted[l.key] ?? 0) === l.expected) : []), [counted, lines]);
const missing = gaps.filter((l) => Math.abs((counted?.[l.key] ?? 0) - l.expected) >= gate && !reason[l.key]);
const ready = missing.length === 0;
const recount = useCallback((key: string) => {
if (!counted) return;
writeCount(me, locationId, { ...counted, [key]: 0 });
setReason((r) => { const n = { ...r }; delete n[key]; return n; });
router.push(`/m/count/${locationId}?line=${encodeURIComponent(key)}`);
}, [counted, me, locationId, router]);
const commit = useCallback(async () => {
if (!counted || !ready || busy) return;
const payload = lines.map((l) => {
const n = counted[l.key] ?? 0;
return { itemId: l.itemId, si: l.si, counted: n, reason: n !== l.expected ? 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);
router.replace(`/m?flash=counted&loc=${encodeURIComponent(locName)}&gaps=${gaps.length}`);
}, [counted, ready, busy, lines, reason, mutate, locationId, me, router, locName, gaps.length]);
if (!counted) return (<><MTop title="Check the gaps" back /><MRule /><MBody pad /></>);
return (
<>
<MTop title="Check the gaps" back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
<MKick>{locName}</MKick>
<h2 style={{ fontSize: 26, fontWeight: 900, margin: "2px 0 0", lineHeight: 1.15 }}>
{gaps.length === 0 ? "Everything matches" : `${gaps.length} gap${gaps.length === 1 ? "" : "s"}`}
</h2>
{lines.length === 0 && <MEmpty title="Nothing on this shelf" />}
{gaps.map((l) => {
const n = counted[l.key] ?? 0;
const d = n - l.expected;
const name = `${lineTitle(l)} ${l.size}`;
return (
<div key={l.key} style={{ marginTop: 10 }}>
<MLine title={name} flag={`${n} counted, ${l.expected} expected`}
right={
<>
<MPill tone="accent" mono>{signed(d)}</MPill>
<button type="button" style={chip} onClick={() => recount(l.key)} aria-label={`Recount ${name}`}>Recount</button>
</>
}>
<MReasonChips reasons={d > 0 ? OVER_REASONS : SHORT_REASONS} value={reason[l.key] || null} label={`Reason for ${name}`}
onPick={(r) => setReason((x) => { const o = { ...x }; if (r) o[l.key] = r; else delete o[l.key]; return o; })} />
</MLine>
</div>
);
})}
{matches.length > 0 && (
<div style={{ marginTop: 22 }}>
<MSection label="Match" right={matches.length} />
{matches.map((l) => (
<MRow key={l.key} dense mark="ok" title={`${lineTitle(l)} ${l.size}`} right={counted[l.key] ?? 0} />
))}
</div>
)}
</MBody>
<MBar label={busy ? "Committing…" : "Commit count"}
small={ready ? `${lines.length} line${lines.length === 1 ? "" : "s"}` : "reason each gap"}
onClick={commit} disabled={!ready || busy || lines.length === 0}
offReason={!ready ? "Pick a reason for each gap" : undefined} />
</>
);
}
+1
View File
@@ -0,0 +1 @@
export { default } from "@/components/m/stock/CountList";
+27
View File
@@ -0,0 +1,27 @@
"use client";
/* The Done screen as its own history entry. A finished hand-over or delivery router.replace()s its
form here (components/SignFlow showDone), so Back skips the spent form, a router refresh re-renders
this screen rather than the form's parent, and a reload still shows what was just recorded. */
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { DoneScreen, readDone, type DoneProps } from "@/components/SignFlow";
import { MBody, MKick, MRule, MTop } from "@/components/m";
export default function DonePage() {
const router = useRouter();
const [done, setDone] = useState<DoneProps | null>(null);
useEffect(() => {
const d = readDone();
if (d) setDone(d);
else router.replace("/m");
}, [router]);
if (done) return <DoneScreen {...done} />;
return (
<>
<MTop title="Done" />
<MRule />
<MBody pad><MKick>Loading</MKick></MBody>
</>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
/* The person's record opens on its Issue segment. */
export default async function IssueTo({ params }: { params: Promise<{ staffId: string }> }) {
const { staffId } = await params;
redirect(`/m/person/${encodeURIComponent(staffId)}`);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
/* Issuing starts from a person now: the People tab, or a scanned badge. */
export default function IssuePicker() {
redirect("/m/people");
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
/* Reprinting a label is Print a label on the stock line's own page. */
export default function Label() {
redirect("/m/stock?seg=all");
}
+28
View File
@@ -0,0 +1,28 @@
import { redirect } from "next/navigation";
import { currentUser } from "@/lib/session";
import { buildSnapshot } from "@/lib/snapshot";
import { SnapshotProvider } from "@/lib/client";
import { MToastProvider } from "@/components/m";
import { MBasketProvider } from "@/components/MBasket";
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.
The toast and the in-progress basket sit inside the snapshot, and neither renders a wrapper
element, so every screen stays a direct child of .tcx-app (the native scan transparency relies
on that). */
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}>
<MToastProvider>
<MBasketProvider>{children}</MBasketProvider>
</MToastProvider>
</SnapshotProvider>
);
}
+98
View File
@@ -0,0 +1,98 @@
"use client";
/* One stock line: a garment in one size. On hand against par, where it lives, what is coming, and the
three things done about it: print a label, count its shelf, put it on the draft order. */
import { useCallback, useState } from "react";
import { useParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { bcBound, key, label, locMap, locTrail, money, onOrderText, onhand, reorderAt } from "@/lib/compute";
import { INK, MBody, MButton, MEmpty, MKick, MONO, MPill, MRow, MRule, MTop, useToast } from "@/components/m";
import PrintSheet from "@/components/m/stock/PrintSheet";
import { heldByStaff, lastCountedText } from "@/components/m/stock/stockdata";
const dt: React.CSSProperties = { margin: 0, padding: "11px 14px 11px 0", borderBottom: "1px solid var(--color-divider)", fontSize: 12, fontWeight: 800, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--color-neutral-600)" };
const dd: React.CSSProperties = { margin: 0, padding: "11px 0", borderBottom: "1px solid var(--color-divider)", textAlign: "right", fontWeight: 700 };
export default function MLinePage() {
const params = useParams<{ itemId: string; si: string }>();
const { s, isAdmin, mutate, busy } = useSnap();
const { L } = useDerived();
const toast = useToast();
const [printing, setPrinting] = useState(false);
// Stable, because MSheet re-runs its focus handling whenever onClose changes.
const closePrint = useCallback(() => setPrinting(false), []);
const itemId = decodeURIComponent(String(params.itemId || ""));
const si = /^\d+$/.test(String(params.si || "")) ? Number(params.si) : -1;
const it = s.catalog.find((x) => x.id === itemId);
if (!it || it.archived || si < 0 || si >= it.sizes.length) {
return (
<>
<MTop title="Stock line" back />
<MRule />
<MBody pad><MEmpty title="No such line" sub="It may have been archived." /></MBody>
</>
);
}
const k = key(it.id, si);
const size = String(it.sizes[si]);
const oh = onhand(s, L, k), par = reorderAt(s, k);
const code = bcBound(s, it, si);
const locs = locMap(s);
const placedId = s.placed[k];
const placed = placedId ? locs[placedId] : undefined;
const shelf = placed ? (locTrail(locs, placed.id, 0) || placed.name) : "";
const onOrder = onOrderText(s, it.id, si);
const short = Math.max(0, par - oh);
const addToDraft = async () => {
const r = await mutate<{ added: number; qty: number }>("stock.orderLine", { itemId: it.id, si });
if (!r.ok) { toast(r.error); return; }
toast(r.result.added > 0 ? `Added ${r.result.added} to the draft order` : "Already on the draft order");
};
return (
<>
<MTop title={`${it.item} ${size}`} back />
<MRule />
<MBody pad>
<MKick mono>{code || (it.sku ? `SKU ${it.sku}` : "No barcode")}</MKick>
<h2 style={{ fontSize: 24, fontWeight: 900, margin: "2px 0 0", lineHeight: 1.1 }}>{label(it)} · {size}</h2>
<div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", gap: 12, marginTop: 14, borderBottom: "2px solid " + INK, paddingBottom: 12 }}>
<div style={{ fontSize: 64, fontWeight: 900, lineHeight: 0.95, letterSpacing: "-0.03em", fontVariantNumeric: "tabular-nums" }}>
{oh}<small style={{ fontSize: 22, fontWeight: 700, color: "var(--color-neutral-600)", letterSpacing: 0 }}> / {par} par</small>
</div>
{short > 0
? <MPill tone={oh <= 0 ? "accent" : "ink"}>{short} short</MPill>
: <MPill tone="ok">At par</MPill>}
</div>
<dl style={{ display: "grid", gridTemplateColumns: "auto 1fr", margin: "8px 0 0" }}>
<dt style={dt}>Shelf</dt><dd style={dd}>{shelf || "Not on a shelf"}</dd>
<dt style={dt}>On order</dt><dd style={dd}>{onOrder || "None"}</dd>
<dt style={dt}>Last counted</dt><dd style={dd}>{lastCountedText(s, it.id, si)}</dd>
<dt style={dt}>Held by staff</dt><dd style={{ ...dd, fontFamily: MONO }}>{heldByStaff(s, it.id, si)}</dd>
<dt style={dt}>Unit cost</dt><dd style={{ ...dd, fontFamily: MONO }}>{money(it.cost)}</dd>
</dl>
<div style={{ marginTop: 4 }}>
<MButton tone="ink" icon="print" label="Print a label" disabled={!code} onClick={() => setPrinting(true)} />
{!code && <div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 6 }}>No barcode bound</div>}
{!code && isAdmin && <MButton label="Bind a barcode" href={`/m/catalogue/${encodeURIComponent(it.id)}`} />}
{placed && <MButton label={`Count ${shelf}`} href={`/m/count/${encodeURIComponent(placed.id)}?line=${encodeURIComponent(k)}`} />}
<MButton label={busy ? "Adding…" : "Add to the draft order"} onClick={addToDraft} disabled={busy} />
</div>
{isAdmin && (
<div style={{ marginTop: 14 }}>
<MRow chev href={`/m/catalogue/${encodeURIComponent(it.id)}`} title="Product card" />
</div>
)}
</MBody>
{code && <PrintSheet open={printing} onClose={closePrint} code={code} title={`${label(it)} ${size}`} />}
</>
);
}
+54
View File
@@ -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>
</>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
/* More's rows moved: Settings is the gear on Today, the rest live in Stock and Work. */
export default function More() {
redirect("/m");
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
/* Today: what is waiting, what this counter has done today, and what just happened. */
import { Suspense } from "react";
import { MBody, MDay, MEmpty, MKick, MRow, MRule, MSection, MTabs, MTodo, MTop, MTopAction } from "@/components/m";
import TodayBanner from "@/components/m/today/TodayBanner";
import { useToday } from "@/components/m/today/useToday";
export default function MToday() {
const t = useToday();
return (
<>
<MTop title="Today" right={<MTopAction icon="gear" label="Settings" ariaLabel="Settings" href="/m/settings" />} />
<MRule />
<MBody pad>
<Suspense fallback={null}><TodayBanner /></Suspense>
<MKick>{t.kicker}</MKick>
<MSection label="To do" />
{t.todo.length === 0
? <MEmpty title="Nothing waiting" />
: t.todo.map((r) => <MTodo key={r.key} n={r.n} accent={r.accent} title={r.title} sub={r.sub} href={r.href} />)}
<MSection label="Your day" />
<MDay figs={[{ n: t.day.issued, label: "Issued" }, { n: t.day.back, label: "Handed back" }, { n: t.day.counted, label: "Shelves counted" }]} />
<MSection label="Recent" />
{t.recent.length === 0
? <MEmpty title="Nothing yet today" />
: t.recent.map((r) => <MRow key={r.key} mark="mute" title={r.title} sub={r.sub} right={r.right} href={r.href} />)}
</MBody>
<MTabs active="today" />
</>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
/* The People tab: the staff register, served-recently first, each row showing how near they are to
the sets one person holds (capCheck, the same check the counter uses). */
import { useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useSnap } from "@/lib/client";
import { capCheck, staffName } from "@/lib/compute";
import { MBody, MEmpty, MRow, MRule, MSearch, MSection, MTabs, MTop, MTopCount } from "@/components/m";
export default function People() {
const { s } = useSnap();
const sp = useSearchParams();
const [q, setQ] = useState(() => (sp.get("q") || "").slice(0, 80));
const active = useMemo(() => s.staff.filter((x) => !x.inactive), [s]);
const list = useMemo(() => {
const needle = q.trim().toLowerCase();
if (!needle) {
const seen: Record<string, string> = {};
for (const i of s.issues) if (i.date > (seen[i.staffId] || "")) seen[i.staffId] = i.date;
return [...active].sort((a, b) => (seen[b.id] || "").localeCompare(seen[a.id] || "") || staffName(a).localeCompare(staffName(b))).slice(0, 12);
}
return s.staff
.filter((x) => `${x.first} ${x.last} ${x.num} ${x.dept} ${x.group}`.toLowerCase().includes(needle))
.sort((a, b) => Number(a.inactive) - Number(b.inactive) || staffName(a).localeCompare(staffName(b)))
.slice(0, 40);
}, [s, q, active]);
return (
<>
<MTop title="People" right={<MTopCount>{active.length}</MTopCount>} />
<MRule />
<MBody pad>
<MSearch value={q} onChange={setQ} placeholder="Name or staff number" label="Search the staff register" scanHref="/m/scan" />
{s.staff.length === 0 ? (
<MEmpty title="No staff yet" sub="Staff are added in the portal." />
) : (
<>
<MSection label={q.trim() ? "Matches" : "Served recently"} right={list.length} />
{list.map((st) => {
const cap = capCheck(s, st);
const most = Math.max(cap.tops, cap.pants);
return (
<MRow key={st.id} href={`/m/person/${st.id}`} title={staffName(st)}
sub={[st.group, st.num, st.dept].filter((x) => x && x.trim()).join(" · ")}
right={`${most}/${cap.cap}`} mark={st.inactive ? "mute" : most >= cap.cap ? "accent" : "ink"} />
);
})}
{q.trim() && list.length === 0 && <MEmpty title={`No one matches “${q.trim()}`} sub="Check the spelling, or scan their badge." />}
</>
)}
</MBody>
<MTabs active="people" />
</>
);
}
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
/* A size exchange is Swap size on a Hand back line. */
export default async function ExchangeFor({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
redirect(`/m/person/${encodeURIComponent(id)}?tab=back`);
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
/* The person screen is the issue screen: who they are, how near the six sets they hold, and the
* Issue | Hand back | History segments. Each segment draws its own docked bar through setBar; a
* finished hand back swaps the whole screen for the Done screen in place. */
import { useCallback, useRef, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import { useSnap } from "@/lib/client";
import { approvalRemaining, capCheck, isNursing, staffName } from "@/lib/compute";
import { MBody, MEmpty, MError, MHead, MHeadRow, MMeterPair, MPill, MRule, MSeg, MTop } from "@/components/m";
import { useBasket } from "@/components/MBasket";
import { DoneScreen, type DoneProps } from "@/components/SignFlow";
import HandBackTab from "@/components/person/HandBackTab";
import IssueTab from "@/components/m/issue/IssueTab";
import HistoryTab from "@/components/m/issue/HistoryTab";
import { personMeta, plural } from "@/components/m/issue/meta";
type Tab = "issue" | "back" | "history";
const TABS: { key: Tab; label: string }[] = [
{ key: "issue", label: "Issue" },
{ key: "back", label: "Hand back" },
{ key: "history", label: "History" },
];
const asTab = (v: string | null): Tab => (v === "back" || v === "history" ? v : "issue");
export default function MPersonPage() {
const { s } = useSnap();
const id = String(useParams().id || "");
const sp = useSearchParams();
const basket = useBasket();
const st = s.staff.find((x) => x.id === id);
const [tab, setTab] = useState<Tab>(() => asTab(sp.get("tab")));
const [bar, setBarNode] = useState<React.ReactNode>(null);
const [err, setErr] = useState("");
const [done, setDone] = useState<DoneProps | null>(null);
const top = useRef<HTMLSpanElement | null>(null);
const setBar = useCallback((b: React.ReactNode) => setBarNode(b), []);
const onError = useCallback((m: string) => setErr(m), []);
const onDone = useCallback((d: DoneProps) => { setErr(""); setDone(d); }, []);
if (done) return <DoneScreen {...done} />;
if (!st) {
return (
<>
<MTop title="Person" back />
<MRule />
<MBody pad><MEmpty title="No such staff member" /></MBody>
</>
);
}
const pickTab = (k: Tab) => {
if (k === tab) return;
setErr("");
setTab(k);
try { window.history.replaceState(null, "", `/m/person/${encodeURIComponent(id)}${k === "issue" ? "" : `?tab=${k}`}`); } catch { /* not fatal */ }
const body = top.current?.parentElement;
if (body) body.scrollTop = 0;
};
const lines = basket.issue(id);
const cap = capCheck(s, st, lines);
const left = approvalRemaining(s, st.id);
const props = { staffId: st.id, setBar, onDone, onError };
return (
<>
<MTop title={staffName(st)} back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
<span ref={top} aria-hidden="true" style={{ display: "block", height: 0 }} />
<MHead name={staffName(st)} meta={personMeta(s, st)}>
{tab === "issue" && (
<>
<MMeterPair items={[
{ label: "Tops held", held: cap.tops, adding: cap.addTops, cap: cap.cap },
{ label: "Pants held", held: cap.pants, adding: cap.addPants, cap: cap.cap },
]} />
{isNursing(s, st) && <MHeadRow label="Manager approval" value={`${plural(left, "set")} left`} />}
</>
)}
</MHead>
{st.inactive && <div style={{ margin: "-4px 0 10px" }}><MPill tone="accent">Inactive</MPill></div>}
<MSeg label="Record" value={tab} options={TABS} onPick={pickTab} />
{tab === "issue" ? <IssueTab {...props} /> : tab === "back" ? <HandBackTab {...props} /> : <HistoryTab {...props} />}
</MBody>
{bar}
</>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
/* Returns are the Hand back segment of the person's record. */
export default async function ReturnFrom({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
redirect(`/m/person/${encodeURIComponent(id)}?tab=back`);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
/* Sign (issue): what is being handed over, the manager approval deduction, their signature and the
* slip switch. issue.create records the lines, the per-line reasons, the approval deduction and the
* signed slip in one locked write; SignFlow then shows the Done screen in place. */
import { useState } from "react";
import { useParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { approvalRemaining, isNursing, isPantItem, isTopItem, issueLineFlags, label, staffName } from "@/lib/compute";
import { MBody, MButton, MEmpty, MHead, MRow, MRule, MSection, MStepper, MTop } from "@/components/m";
import { useBasket, type IssueLine } from "@/components/MBasket";
import SignFlow from "@/components/SignFlow";
import { personMeta, plural } from "@/components/m/issue/meta";
export default function SignIssue() {
const { s, mutate } = useSnap();
const { byId } = useDerived();
const id = String(useParams().id || "");
const basket = useBasket();
const st = s.staff.find((x) => x.id === id);
// Once issued the basket is cleared; the lines are kept here so the screen does not fall back to
// "nothing to issue" in the moment before the Done screen replaces it.
const [frozen, setFrozen] = useState<IssueLine[] | null>(null);
const lines = frozen ?? basket.issue(id);
if (!st || !lines.length) {
return (
<>
<MTop title="Sign" back />
<MRule />
<MBody pad>
<MEmpty title={st ? "Nothing to issue" : "No such staff member"} />
{st && <MButton label="Back to their record" href={`/m/person/${encodeURIComponent(st.id)}`} />}
</MBody>
</>
);
}
const n = lines.reduce((t, l) => t + l.qty, 0);
const tops = lines.reduce((t, l) => t + (isTopItem(byId[l.itemId]) ? l.qty : 0), 0);
const pants = lines.reduce((t, l) => t + (isPantItem(byId[l.itemId]) ? l.qty : 0), 0);
const sets = Math.max(tops, pants);
const left = approvalRemaining(s, st.id);
const approval = isNursing(s, st) && left > 0;
const most = Math.min(left, sets);
const chosen = basket.deduct(id);
const deduct = Math.max(0, Math.min(most, chosen ?? most));
const extra = approval ? (
<>
<MSection label="Manager approval" right={`${plural(left, "set")} left`} />
<MRow title="Take off the approval" sub={`This issue is ${plural(sets, "set")}`}
right={<MStepper label="sets off the approval" n={deduct} min={0} max={most} onChange={(v) => basket.setDeduct(id, v)} />} />
</>
) : undefined;
const commit = async ({ sigId, slip }: { sigId: string; slip: boolean }) => {
const flags = issueLineFlags(s, st, lines);
const r = await mutate<{ slipId: string | null }>("issue.create", {
staffId: st.id,
lines: lines.map((l, i) => ({ itemId: l.itemId, si: l.si, qty: l.qty, src: "stock", reason: flags[i] ? l.reason || "" : "" })),
override: flags.some(Boolean),
lineReasons: true,
apDeduct: approval ? deduct : 0,
sigId,
slip,
});
if (!r.ok) return { ok: false as const, error: r.error };
const sent = lines;
setFrozen(sent);
basket.clear("issue", st.id);
return {
ok: true as const,
done: {
head: `${plural(n, "item")} issued`,
sub: `${staffName(st)} · ${slip ? "slip sent to their staff app" : "signed"}`,
shelfKeys: sent.map((l) => l.key),
next: "scan" as const,
},
};
};
return (
<SignFlow
kind="issue"
head={<MHead name={staffName(st)} meta={personMeta(s, st)} />}
lines={lines.map((l) => ({ key: l.key, name: `${label(byId[l.itemId])} ${String(byId[l.itemId]?.sizes[l.si] ?? l.si)}`, qty: l.qty }))}
signerName={staffName(st)}
extra={extra}
slip={{ available: !!st.selfEmail }}
barLabel={`Issue ${plural(n, "item")}`}
commit={commit}
/>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
/* The pickup call list is Work Pickups. */
export default function Pickups() {
redirect("/m/work?seg=pickups");
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
/* Receive a delivery: tick each line as it is unpacked, step a line down when less came. Receiving
closes the order; anything short goes onto a back order raised by order.receive. */
import { useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { key, staffName } from "@/lib/compute";
import { plural } from "@/lib/today";
import { DoneScreen, useShowDone, type DoneProps } from "@/components/SignFlow";
import { MBar, MBody, MButton, MEmpty, MError, MField, MKick, MPickRow, MRule, MSection, MStepper, MTop, inputStyle } from "@/components/m";
import { RECEIVABLE, garmentSize, locMap, orderWhen, outstandingLines, segment, shelfOf, type OutLine } from "@/components/m/work/util";
export default function ReceiveOrder() {
const id = segment(useParams<{ id: string }>().id);
const { s, mutate } = useSnap();
const { byId, staffById } = useDerived();
const order = s.orders.find((o) => o.id === id);
const lines = useMemo(() => (order && RECEIVABLE.includes(order.status) ? outstandingLines(order, byId) : []), [order, byId]);
const locs = useMemo(() => locMap(s), [s]);
const [tick, setTick] = useState<Record<string, boolean>>({});
const [qty, setQty] = useState<Record<string, number>>({});
const [invoice, setInvoice] = useState(() => order?.invoice || "");
const [err, setErr] = useState("");
const [saving, setSaving] = useState(false);
const [done, setDone] = useState<DoneProps | null>(null);
const showDone = useShowDone();
if (done) return <DoneScreen {...done} />;
const q = (l: OutLine) => qty[l.id] ?? l.outstanding;
const ticked = lines.filter((l) => tick[l.id]);
const n = ticked.reduce((t, l) => t + q(l), 0);
const all = lines.length > 0 && ticked.length === lines.length;
const receive = async () => {
if (!order || saving) return;
setSaving(true);
setErr("");
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), dest: order.staffId ? "pickup" : "shelf" })),
});
setSaving(false);
if (!r.ok) { setErr(r.error); return; }
const short = lines.some((l) => q(l) < l.outstanding);
const d: DoneProps = {
head: `${plural(n, "item")} received`,
sub: `${order.code} · ${order.supplier || "Supplier"} · order closed${short ? " · back order raised" : ""}`,
shelfKeys: order.staffId ? [] : lines.filter((l) => q(l) > 0 && l.si >= 0).map((l) => key(l.itemId, l.si)),
next: "work",
};
setDone(d);
showDone(d);
};
const forName = order?.staffId ? staffName(staffById[order.staffId]) : "";
return (
<>
<MTop title={order?.code || "Receive"} back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
{!order || !lines.length ? (
<>
<MEmpty title="Nothing outstanding" />
<MButton label="Back to Work" href="/m/work?seg=in" />
</>
) : (
<>
<MKick>{`${order.supplier || "Supplier"} · ${orderWhen(s, order)}`}</MKick>
<MSection label="Tick what came in" right={`${ticked.length} of ${lines.length}`} />
{lines.map((l) => {
const name = garmentSize(byId[l.itemId], "Garment", l.size);
const sub = forName ? `for ${forName}` : l.si >= 0 ? shelfOf(s, locs, l.itemId, l.si) : "";
return (
<MPickRow key={l.id} done={!!tick[l.id]} onToggle={() => setTick((x) => ({ ...x, [l.id]: !x[l.id] }))} title={name} sub={sub || undefined} tickLabel={`Came in ${name}`}>
<MStepper n={q(l)} min={0} max={l.outstanding} label={name} onChange={(v) => setQty((x) => ({ ...x, [l.id]: v }))} />
</MPickRow>
);
})}
<MButton label="Tick all" onClick={() => setTick(Object.fromEntries(lines.map((l) => [l.id, true])))} />
<MField label="Invoice number">
<input value={invoice} onChange={(e) => setInvoice(e.target.value)} autoComplete="off" maxLength={80} style={inputStyle} />
</MField>
</>
)}
</MBody>
{order && lines.length > 0 && (
<MBar label={saving ? "Recording…" : `Receive ${plural(n, "item")}`} disabled={saving || !all || n === 0}
offReason={saving ? undefined : !all ? "Tick each line" : "Nothing arrived"} onClick={receive} />
)}
</>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
/* Deliveries to receive are Work In; each opens /m/receive/[id]. */
export default function Receive() {
redirect("/m/work?seg=in");
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
/* Draft order: what fell below par, at quantities that bring each line back up. Raising it makes a
draft on Ordering; nothing reaches a supplier until someone approves it there. */
import { useMemo, useState } from "react";
import { useDerived, useSnap } from "@/lib/client";
import { flaggedNeeds, label, onhand, reorderAt, touched } from "@/lib/compute";
import { MBar, MBody, MButton, MDone, MEmpty, MError, MRow, MRule, MSection, MStepper, MTop, MTopCount } from "@/components/m";
export default function MReorder() {
const { s, mutate, busy } = useSnap();
const { L, byId, variants } = useDerived();
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: `${label(byId[n.itemId])} ${n.size}`, oh: onhand(s, L, k), par: reorderAt(s, k) };
}), [s, L, byId]);
// Stock's "short" counts every line at or under par; flaggedNeeds drops the ones open orders cover.
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 = Math.max(0, 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="Done" />
<MRule />
<MBody pad>
<MDone head={`Draft ${done} raised`} sub="Waiting on Ordering" />
<MButton label="Back to Stock" href="/m/stock" />
</MBody>
</>
);
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
return (
<>
<MTop title="Draft order" back right={<MTopCount>{plural(needs.length, "line")}</MTopCount>} />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
{needs.length === 0 ? (
<MEmpty title={belowPar ? "Already on order" : "Nothing to reorder"}
sub={belowPar ? `${plural(belowPar, "line")} short, all on open orders` : "Every line is above par"} />
) : (
<>
<MSection label="To order" right={suppliers.length === 1 ? suppliers[0] : plural(suppliers.length, "supplier")} />
{needs.map((n) => (
<MRow key={n.key} mark={n.oh <= 0 ? "accent" : "ink"} title={n.name}
sub={`${n.oh}/${n.par} · ${n.supplier}`}
right={<MStepper n={q(n.key, n.qty)} onChange={(v) => setQty((x) => ({ ...x, [n.key]: v }))} label={n.name} />} />
))}
</>
)}
{covered > 0 && needs.length > 0 && (
<>
<MSection label="Already on order" right={covered} />
<MRow mark="mute" chev href="/m/stock?seg=order" title="On order" right={covered} />
</>
)}
</MBody>
{needs.length > 0 && (
<MBar label={busy ? "Raising…" : "Raise the draft"} small={plural(total, "item")} onClick={raise}
disabled={busy || total === 0} offReason={total === 0 ? "Set a quantity on at least one line" : undefined} />
)}
</>
);
}
+135
View File
@@ -0,0 +1,135 @@
"use client";
/* Pick an approved staff request: tick or scan each garment in the bag, then hand it over. The first
pick moves the request to "picking" through request.pick, so the desktop queue shows it as being
picked. Ticks live in the basket (this phone only) until the hand-over is signed. */
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { wasSpent } from "@/components/SignFlow";
import { useDerived, useSnap } from "@/lib/client";
import { bcParse, key, onhand } from "@/lib/compute";
import { scanReject } from "@/lib/feedback";
import { relativeDay } from "@/lib/today";
import { PICK_STATUSES } from "@/lib/workcount";
import { useRequests } from "@/components/requests/RequestList";
import { useBasket } from "@/components/MBasket";
import MScan from "@/components/MScan";
import {
MBar, MBody, MButton, MEmpty, MError, MHead, MKick, MPickRow, MRow, MRule, MSection, MTop, useScanFlash, useToast,
} from "@/components/m";
import { garmentSize, locMap, personMeta, segment, shelfOf } from "@/components/m/work/util";
export default function PickRequest() {
const id = segment(useParams<{ id: string }>().id);
const { s, mutate } = useSnap();
const { L, byId, staffById } = useDerived();
const { data, error, reload } = useRequests();
const basket = useBasket();
const toast = useToast();
const flash = useScanFlash();
const [err, setErr] = useState("");
const [scanning, setScanning] = useState(false);
const pickFired = useRef(false);
const locs = useMemo(() => locMap(s), [s]);
const r = data?.requests.find((x) => x.id === id);
const lines = useMemo(() => r?.bag ?? [], [r]);
const stored = basket.picked(id);
const router = useRouter();
const gone = !!data && (!r || !PICK_STATUSES.has(r.status) || !lines.length);
const spent = gone && wasSpent(`/m/request/${id}`);
// Back from this request's own Done screen: the list is spent, so carry on to the queue.
useEffect(() => { if (spent) router.replace("/m/work?seg=picks"); }, [spent, router]);
// A ready bag was packed at the counter already: every line starts ticked.
const got = useCallback((lineId: string, q: number) => {
const v = stored[lineId];
if (v === undefined) return r?.status === "ready" ? q : 0;
return Math.max(0, Math.min(q, v));
}, [stored, r]);
const total = lines.reduce((t, l) => t + l.qty, 0);
const picked = lines.reduce((t, l) => t + got(l.id, l.qty), 0);
const setLine = (lineId: string, n: number) => {
const prev = stored;
basket.setPicked(id, { ...prev, [lineId]: n });
return () => basket.setPicked(id, prev);
};
/** The first garment picked off an approved request tells the queue it is being picked. */
const firstPick = async (undo: () => void) => {
if (!r || r.status !== "accepted" || pickFired.current) return;
pickFired.current = true;
const res = await mutate("request.pick", { id: r.id });
if (!res.ok) { pickFired.current = false; undo(); setErr(res.error); void reload(); return; }
void reload();
};
const toggle = (lineId: string, q: number) => {
const cur = got(lineId, q);
const undo = setLine(lineId, cur >= q ? 0 : q);
if (cur < q) void firstPick(undo);
};
const onScan = (raw: string) => {
setScanning(false);
const hit = bcParse(s, raw);
if (!hit) { scanReject(); toast("That code isnt a garment"); return; }
const it = byId[hit.itemId];
const name = garmentSize(it, "Garment", it?.sizes[hit.si] ?? hit.si);
const match = lines.filter((l) => l.itemId === hit.itemId && l.si === hit.si);
if (!match.length) { scanReject(); toast(`${name} isnt on this request`); return; }
const next = match.find((l) => got(l.id, l.qty) < l.qty);
if (!next) { toast("Everything is picked"); return; }
const n = got(next.id, next.qty) + 1;
flash("Garment", name, () => { const undo = setLine(next.id, n); void firstPick(undo); });
};
const shell = (body: React.ReactNode, bar?: React.ReactNode) => (
<>
<MTop title="Pick request" back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>{body}</MBody>
{bar}
</>
);
if (!data) {
return shell(error
? <><MError msg="Requests couldnt be loaded." /><MButton small label="Try again" onClick={() => void reload()} /></>
: <MKick>Loading</MKick>);
}
if (!r || !PICK_STATUSES.has(r.status) || !lines.length) {
if (spent) return shell(<MKick>Loading</MKick>);
return shell(<><MEmpty title="That request has moved on" /><MButton label="Back to Work" href="/m/work?seg=picks" /></>);
}
const st = staffById[r.staffId];
const when = relativeDay(r.decidedAt, s);
return (
<>
{shell(
<>
<MHead name={r.staffName} meta={personMeta(s, st, r.ward)} />
<MButton tone="ink" icon="scan" label="Scan to pick"
onClick={() => (picked >= total ? toast("Everything is picked") : setScanning(true))} />
<MSection label="Pick list" right={`${picked} of ${total}`} />
{lines.map((l, i) => {
const n = got(l.id, l.qty);
const onShelf = Math.max(0, onhand(s, L, key(l.itemId, l.si)));
const sub = [shelfOf(s, locs, l.itemId, l.si), `${onShelf} on shelf`, r.status === "ready" && i === 0 && r.collectCode ? `code ${r.collectCode}` : ""]
.filter(Boolean).join(" · ");
return (
<MPickRow key={l.id} done={n >= l.qty} onToggle={() => toggle(l.id, l.qty)}
title={garmentSize(byId[l.itemId], l.item, l.size)} sub={sub} right={`${n}/${l.qty}`} />
);
})}
<MSection label="Approved" />
<MRow mark="ok" title={r.managerName || "Manager"} sub={when ? `Approved ${when}` : "Approved"} />
</>,
<MBar label="Hand over" small={`${picked} of ${total} picked`} disabled={picked < total}
offReason="Pick every line first" href={`/m/request/${r.id}/sign`} />,
)}
{scanning && <MScan title="Scan to pick" onHit={onScan} onClose={() => setScanning(false)} />}
</>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
/* Sign for a picked request, then Done. A request still "accepted" is moved to "picking" first
(the queue only lets a picked request be collected); request.collected writes the issue rows, the
signature and, when asked, the slip to the wearer's staff app. */
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { key } from "@/lib/compute";
import { plural } from "@/lib/today";
import { PICK_STATUSES } from "@/lib/workcount";
import { useRequests, type RequestRow } from "@/components/requests/RequestList";
import { useBasket } from "@/components/MBasket";
import SignFlow from "@/components/SignFlow";
import { MBody, MButton, MEmpty, MError, MHead, MKick, MRule, MTop } from "@/components/m";
import { garmentSize, personMeta, segment } from "@/components/m/work/util";
export default function RequestSign() {
const id = segment(useParams<{ id: string }>().id);
const { s, mutate } = useSnap();
const { byId, staffById } = useDerived();
const { data, error, reload } = useRequests();
const basket = useBasket();
// Held once loaded: the hand-over refreshes the queue, and the Done screen must not turn into
// "moved on" when this request leaves it.
const [held, setHeld] = useState<{ r: RequestRow; ready: boolean } | null>(null);
const [seen, setSeen] = useState(false);
const stored = basket.picked(id);
useEffect(() => {
if (held || !data) return;
const r = data.requests.find((x) => x.id === id);
if (r && PICK_STATUSES.has(r.status) && r.bag.length) {
const ready = r.status === "ready" || r.bag.every((l) => (stored[l.id] ?? 0) >= l.qty);
setHeld({ r, ready });
}
setSeen(true);
}, [data, id, held, stored]);
const shell = (body: React.ReactNode) => (
<>
<MTop title="Sign" back />
<MRule />
<MBody pad>{body}</MBody>
</>
);
if (!held) {
if (!data && error) return shell(<><MError msg="Requests couldnt be loaded." /><MButton small label="Try again" onClick={() => void reload()} /></>);
if (!data || !seen) return shell(<MKick>Loading</MKick>);
return shell(<><MEmpty title="That request has moved on" /><MButton label="Back to Work" href="/m/work?seg=picks" /></>);
}
const { r } = held;
if (!held.ready) {
return shell(<><MEmpty title="Pick every line first" /><MButton label="Back to the pick list" href={`/m/request/${r.id}`} /></>);
}
const st = staffById[r.staffId];
const total = r.bag.reduce((t, l) => t + l.qty, 0);
return (
<SignFlow
kind="request"
head={<MHead name={r.staffName} meta={personMeta(s, st, r.ward)} />}
lines={r.bag.map((l) => ({ key: l.id, name: garmentSize(byId[l.itemId], l.item, l.size), qty: l.qty }))}
signerName={r.staffName}
slip={{ available: !!st?.selfEmail }}
barLabel={`Hand over ${plural(total, "item")}`}
spentHref={`/m/request/${r.id}`}
commit={async ({ sigId, slip }) => {
// Accepted cannot go straight to collected. If the pick screen already moved it, this
// refusal is expected and collected below answers for the real state.
if (r.status === "accepted") await mutate("request.pick", { id: r.id });
const c = await mutate("request.collected", { id: r.id, sigId, slip });
if (!c.ok) { void reload(); return { ok: false, error: c.error }; }
basket.clear("picked", r.id);
void reload();
return {
ok: true,
done: {
head: "Request handed over",
sub: `${r.staffName} · ${plural(total, "item")}${slip ? " · slip sent" : ""}`,
shelfKeys: r.bag.map((l) => key(l.itemId, l.si)),
next: "work",
},
};
}}
/>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
/* Sign a ward round: every bag waiting for that ward (lib/today roundSheet, the same set the Rounds
segment counted), one signature for the lot through pickup.deliverWard, then Done. */
import { useMemo, useRef, useState } from "react";
import { useParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { plural, roundSheet } from "@/lib/today";
import { takePhoto, uploadPhoto } from "@/lib/photo";
import SignFlow from "@/components/SignFlow";
import { MBody, MButton, MEmpty, MField, MHead, MRule, MTop, inputStyle, useToast } from "@/components/m";
import { roundLines, segment } from "@/components/m/work/util";
const BATCH = 40; // pickup.deliverWard takes at most 40 bags a call
export default function RoundSign() {
const ward = segment(useParams<{ ward: string }>().ward);
const { s, mutate } = useSnap();
const { byId, staffById } = useDerived();
const toast = useToast();
// Held from the first render: delivering removes the ward from the sheet, and the Done screen
// must not turn into an empty round.
const [rows] = useState(() => roundSheet(s, byId, staffById).find((w) => w.ward === ward)?.rows ?? []);
const lines = useMemo(() => roundLines(rows, byId), [rows, byId]);
const [name, setName] = useState("");
const [proofId, setProofId] = useState<string | null>(null);
const [photoBusy, setPhotoBusy] = useState(false);
const delivered = useRef(new Set<string>());
if (!rows.length) {
return (
<>
<MTop title="Sign" back />
<MRule />
<MBody pad>
<MEmpty title="Nothing to deliver" />
<MButton label="Back to Work" href="/m/work?seg=rounds" />
</MBody>
</>
);
}
const items = lines.reduce((t, l) => t + l.qty, 0);
const people = new Set(rows.map((r) => r.p.staffId)).size;
const who = name.trim();
const takeProof = async () => {
if (photoBusy) return;
const data = await takePhoto();
if (!data) return;
setPhotoBusy(true);
const up = await uploadPhoto(mutate, "proof", data);
setPhotoBusy(false);
if ("error" in up) { toast(up.error); return; }
setProofId(up.id);
};
return (
<SignFlow
kind="round"
head={<MHead name={`${ward} round`} meta={`${plural(people, "person", "people")} · ${plural(items, "item")}`} />}
lines={lines.map((l) => ({ key: l.key, name: l.name, qty: l.qty }))}
signerName={who || `${ward}, nurse in charge`}
extra={
<>
<MField label="Received by">
<input value={name} onChange={(e) => setName(e.target.value)} autoComplete="off" maxLength={120} style={inputStyle} />
</MField>
<MButton small label={photoBusy ? "Saving photo…" : proofId ? "Photo attached · retake" : "Add handover photo"} onClick={() => void takeProof()} disabled={photoBusy} />
</>
}
barLabel="Delivered"
commit={async ({ sigId }) => {
// A ward with more bags than one call takes goes in batches; a retry skips what already went.
const ids = rows.map((r) => r.p.id).filter((x) => !delivered.current.has(x));
for (let i = 0; i < ids.length; i += BATCH) {
const part = ids.slice(i, i + BATCH);
const r = await mutate("pickup.deliverWard", { ids: part, deliveredTo: who, sigId, proofId });
if (!r.ok) return { ok: false, error: delivered.current.size ? `${delivered.current.size} of ${rows.length} bags recorded. ${r.error}` : r.error };
part.forEach((x) => delivered.current.add(x));
}
return {
ok: true,
done: { head: `${ward} delivered`, sub: `${plural(items, "item")} · signed by ${who || "the nurse in charge"}`, shelfKeys: [], next: "work" },
};
}}
/>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
/* Delivery rounds are Work Rounds; each ward signs at /m/round/[ward]. */
export default function Rounds() {
redirect("/m/work?seg=rounds");
}
+171
View File
@@ -0,0 +1,171 @@
"use client";
/* The Scan tab: one camera for a staff badge, a garment or a shelf label, routed by lib/scanroute.
Under the Android shell MLKit's preview shows through the transparent viewfinder; in a browser a
<video> fills it through lib/webscan. */
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useSnap } from "@/lib/client";
import { isNative, startLive } from "@/lib/nativescan";
import { startWebLive } from "@/lib/webscan";
import { scanReject, scanTick } from "@/lib/feedback";
import { track } from "@/lib/analytics";
import { useKeepAwake } from "@/lib/wakelock";
import { SCAN_KIND_LABEL, resolveScan, type ScanHit } from "@/lib/scanroute";
import { AC3, GROUND, INK, MBody, MButton, MONO, MPill, MRule, MSection, MTabs, MTop, useScanFlash } from "@/components/m";
type Recent = { kind: string; label: string; code: string; href: string };
/* What this session resolved, newest first. Module memory: it survives moving between tabs and is
gone when the app is closed. */
let RECENT: Recent[] = [];
const OFF_GREY = "#b5b1af";
const EDGE = "#57534f";
const DEBOUNCE = 1500;
export default function ScanTab() {
const { s, isAdmin } = useSnap();
const router = useRouter();
const flash = useScanFlash();
const vid = useRef<HTMLVideoElement | null>(null);
const [armed, setArmed] = useState(true);
const [miss, setMiss] = useState<Extract<ScanHit, { kind: "unknown" | "inactive" }> | null>(null);
const [caption, setCaption] = useState<{ msg: string; denied: boolean } | null>(null);
const [recent, setRecent] = useState<Recent[]>([]);
const [typed, setTyped] = useState("");
const native = isNative();
const [nativeOk, setNativeOk] = useState(native);
const [nativeLive, setNativeLive] = useState(false);
useKeepAwake(true);
useEffect(() => { setRecent(RECENT); }, []);
const handle = useCallback((raw: string) => {
const hit = resolveScan(s, raw);
if (hit.kind === "unknown" || hit.kind === "inactive") {
scanReject();
if (hit.kind === "unknown") track("scan_miss", { kind: "unknown" });
setArmed(false);
setMiss(hit);
return;
}
setArmed(false);
const row: Recent = { kind: SCAN_KIND_LABEL[hit.kind], label: hit.label, code: raw.trim(), href: hit.href };
RECENT = [row, ...RECENT.filter((r) => r.href !== row.href)].slice(0, 4);
setRecent(RECENT);
flash(row.kind, row.label, () => router.push(hit.href));
}, [s, flash, router]);
const handleRef = useRef(handle); handleRef.current = handle;
// Native: MLKit live session behind the WebView while armed.
useEffect(() => {
if (!native || !nativeOk || !armed) return;
let session: { stop: () => Promise<void> } | null = null;
let cancelled = false;
let lastRaw = "", lastT = 0;
(async () => {
const r = await startLive((raw) => {
if (raw === lastRaw && Date.now() - lastT < DEBOUNCE) return;
lastRaw = raw; lastT = Date.now();
scanTick();
handleRef.current(raw);
});
if (cancelled) { await r.stop(); return; }
if (r.error === "native-unavailable") { setNativeOk(false); return; }
if (r.error) { setCaption({ msg: r.error, denied: true }); return; }
session = r;
setNativeLive(true);
})();
return () => { cancelled = true; setNativeLive(false); if (session) void session.stop(); };
}, [native, nativeOk, armed]);
// Browser: the shared BarcodeDetector loop into the viewfinder's video.
useEffect(() => {
if (nativeOk || !armed || !vid.current) return;
const session = startWebLive(vid.current, (raw) => handleRef.current(raw), DEBOUNCE, {
onStatus: (st) => setCaption(st.state === "ready" ? null : { msg: st.msg, denied: st.state === "denied" }),
});
return () => session.stop();
}, [nativeOk, armed]);
useEffect(() => {
track("scan_opened", { mode: "tab", engine: nativeOk ? "mlkit" : native ? "mlkit-missing" : "browser" });
}, [native, nativeOk]);
const again = () => { setMiss(null); setCaption(null); setArmed(true); };
const find = () => { const c = typed.trim(); if (!c) return; setTyped(""); handle(c); };
return (
<div className="tcx-scanui" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<MTop title="Scan" />
<MRule />
<MBody dark pad className="tcx-scanbody">
<div style={{ position: "relative", height: 300, margin: "-16px -16px 0", overflow: "hidden", background: nativeLive ? "transparent" : "radial-gradient(ellipse at 50% 45%, #3a3735 0, #151413 70%)" }} className="tcx-camwin">
{!nativeOk && <video ref={vid} autoPlay playsInline muted aria-hidden="true" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" }} />}
<div aria-hidden="true" style={{ position: "absolute", left: 16, top: 14, display: "flex", gap: 8, alignItems: "center", color: "#fff", fontSize: 12, fontWeight: 800, letterSpacing: "0.1em" }}>
<i style={{ width: 10, height: 10, background: armed ? "var(--color-accent)" : EDGE }} />LIVE
</div>
<div aria-hidden="true" style={{ position: "absolute", left: "14%", right: "14%", top: "22%", bottom: "26%" }}>
{([["left", "top"], ["right", "top"], ["left", "bottom"], ["right", "bottom"]] as const).map(([x, y]) => (
<i key={x + y} style={{ position: "absolute", width: 34, height: 34, [x]: 0, [y]: 0, borderStyle: "solid", borderColor: "#fff", borderWidth: 0, [`border${y === "top" ? "Top" : "Bottom"}Width`]: 4, [`border${x === "left" ? "Left" : "Right"}Width`]: 4 }} />
))}
</div>
{armed && !caption?.denied && <div aria-hidden="true" className="tcx-vf-laser" style={{ position: "absolute", left: "8%", right: "8%", height: 2, background: "var(--color-accent)", boxShadow: "0 0 12px var(--color-accent)" }} />}
<div role={caption?.denied ? "alert" : undefined} style={{ position: "absolute", left: 12, right: 12, bottom: 16, textAlign: "center", color: caption ? AC3 : "#fff", fontWeight: 800, fontSize: 14, letterSpacing: caption ? 0 : "0.06em", textTransform: caption ? "none" : "uppercase" }}>
{caption ? caption.msg : "Badge, garment or shelf label"}
</div>
</div>
<div style={{ background: INK, margin: "0 -16px -16px", padding: "0 16px 16px", minHeight: "calc(100% - 268px)" }}>
<div style={{ height: 1 }} />
{miss ? (
<div style={{ paddingTop: 18 }}>
<MPill tone="accent">{miss.kind === "unknown" ? "Not found" : "Inactive"}</MPill>
<div style={{ fontFamily: miss.kind === "unknown" ? MONO : undefined, fontSize: 22, fontWeight: miss.kind === "unknown" ? 500 : 800, marginTop: 10, wordBreak: "break-all" }}>
{miss.kind === "unknown" ? miss.code || "—" : miss.label}
</div>
<div style={{ display: "grid", marginTop: 4 }}>
{miss.kind === "unknown" && (isAdmin
? <DarkButton label="Bind to a garment" href={`/m/catalogue?bind=${encodeURIComponent(miss.code)}`} />
: <div style={{ fontSize: 13, color: OFF_GREY, marginTop: 10 }}>Only an admin can bind a code</div>)}
{miss.kind === "inactive" && <DarkButton label="Open their record" href={`/m/person/${miss.staffId}`} />}
<DarkButton label="Scan again" onClick={again} />
</div>
</div>
) : recent.length > 0 && (
<>
<MSection label="Recent scans" right={recent.length} />
<div style={{ display: "grid", gap: 8, marginTop: 10 }}>
{recent.map((r) => (
<button key={r.href} type="button" onClick={() => router.push(r.href)}
style={{ display: "flex", alignItems: "center", gap: 12, minHeight: 56, border: "2px solid " + EDGE, background: "transparent", color: GROUND, padding: "0 12px", textAlign: "left", fontFamily: "inherit", fontSize: 15, fontWeight: 700, cursor: "pointer" }}>
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: OFF_GREY, width: 62, flex: "none" }}>{r.kind}</span>
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span>
<span style={{ fontFamily: MONO, fontSize: 12, color: OFF_GREY, fontWeight: 500 }}>{r.code}</span>
</button>
))}
</div>
</>
)}
<form onSubmit={(e) => { e.preventDefault(); find(); }} style={{ marginTop: 22 }}>
<label htmlFor="scan-typed" style={{ display: "block", fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: OFF_GREY, marginBottom: 6 }}>Type a code</label>
<div style={{ display: "flex", gap: 8 }}>
<input id="scan-typed" value={typed} onChange={(e) => setTyped(e.target.value)} autoComplete="off" autoCapitalize="none" autoCorrect="off" spellCheck={false}
style={{ flex: 1, minWidth: 0, height: 52, border: "2px solid " + EDGE, background: "transparent", color: GROUND, fontFamily: MONO, fontSize: 16, padding: "0 14px", borderRadius: 0 }} />
<button type="submit" style={{ minWidth: 72, minHeight: 52, border: "2px solid " + GROUND, background: GROUND, color: INK, fontFamily: "inherit", fontSize: 14, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer" }}>Find</button>
</div>
</form>
</div>
</MBody>
<MTabs active="scan" />
</div>
);
}
/** MButton's outline, drawn in ground on the ink body. */
function DarkButton({ label, href, onClick }: { label: string; href?: string; onClick?: () => void }) {
return (
<div style={{ "--color-text": "var(--color-bg)" } as React.CSSProperties}>
<MButton label={label} href={href} onClick={onClick} />
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { redirect } from "next/navigation";
/* Search became the People tab (stock search is on the Stock tab). The query comes along. */
export default async function Search({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
const q = await searchParams;
const term = typeof q.q === "string" ? q.q.trim().slice(0, 80) : "";
redirect(term ? `/m/people?q=${encodeURIComponent(term)}` : "/m/people");
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
/* Settings, behind the gear on Today: this phone, the account, help and signing out. Everything else
about the facility lives on the desktop, so there is one place a setting can be wrong. */
import { DELETE_ACCOUNT_URL, PRIVACY_URL, TERMS_URL } from "@/lib/links";
import { useEffect, useState } from "react";
import { useSnap } from "@/lib/client";
import { clearAllCounts } from "@/lib/opencount";
import { printState, type PrintState } from "@/lib/nativeprint";
import { clearBasket } from "@/components/MBasket";
import { MBody, MButton, MError, MONO, MPill, MRow, MRule, MSection, MStepper, MSwitchRow, MTop } from "@/components/m";
const BEEP_KEY = "tc.beep";
export default function MSettings() {
const { s, isAdmin, mutate, busy } = useSnap();
const [beep, setBeep] = useState(true);
const [gate, setGate] = useState(s.settings.varianceReason);
const [err, setErr] = useState("");
const [printer, setPrinter] = useState<PrintState | null>(null);
const [host, setHost] = useState("");
const [leaving, setLeaving] = useState(false);
useEffect(() => {
try { setBeep(localStorage.getItem(BEEP_KEY) !== "0"); } catch { /* blocked store */ }
setHost(window.location.host);
let live = true;
printState().then((p) => { if (live) setPrinter(p); }).catch(() => { /* stays blank */ });
return () => { live = false; };
}, []);
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 () => {
// Part-counted shelves and the half-built basket are this person's, on a phone that is passed
// around a linen room. Cleared before the logout POST so a failed request still leaves the
// device tidy. A full navigation afterwards: the session cookie is gone and every page behind
// it is server-rendered. /m/login, not /auth, keeps them inside the app.
setLeaving(true);
clearAllCounts(s.session.userId);
clearBasket(s.session.userId);
try { await fetch("/api/auth/logout", { method: "POST" }); } catch { /* the cookie check on /m/login decides */ }
window.location.replace("/m/login");
};
const roleWord = s.session.role === "Admin" ? "Admin" : "Issuer";
const tone = printer?.state === "ready" ? "ok" : printer?.state === "unavailable" ? "accent" : "mute";
return (
<>
<MTop title="Settings" back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
<MSection label="This phone" />
<MSwitchRow title="Beep and buzz on scan" on={beep} onToggle={toggleBeep} />
<MRow title="Shelf printer" sub={printer?.sub ?? ""} right={printer ? <MPill tone={tone}>{printer.label}</MPill> : null} />
<MRow title="Server" sub={host} />
<MSection label="Account" />
<MRow title={s.settings.facility} sub={s.session.title || roleWord} />
<MRow title="Count gap needing a reason"
right={isAdmin
? <MStepper n={gate} onChange={saveGate} min={1} max={99} label="gap" />
: <span style={{ fontFamily: MONO, fontWeight: 600, fontSize: 14 }}>{gate}</span>} />
<MRow title="Help" chev href="/app/help/apps/counter-app" />
{/* Deleting an account has to be reachable from inside the app (a Play requirement), and this
is the only place the signed-in counter app names the policies. They go to the site's own
pages: there is one account-deletion flow, and it is the one on the website. */}
{(DELETE_ACCOUNT_URL || PRIVACY_URL || TERMS_URL) && <MSection label="Your account and your data" />}
{DELETE_ACCOUNT_URL && <MRow href={DELETE_ACCOUNT_URL} external title="Delete your account" />}
{PRIVACY_URL && <MRow href={PRIVACY_URL} external title="Privacy policy" />}
{TERMS_URL && <MRow href={TERMS_URL} external title="Terms of use" />}
<div style={{ marginTop: 4 }}>
<MButton label="Sign out" onClick={signOut} disabled={busy || leaving} />
</div>
</MBody>
</>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
/* The old signed-in welcome. Today is the first screen now; a brand-new facility gets its banner. */
export default async function SignedIn({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
const q = await searchParams;
redirect(q.new === "1" ? "/m?flash=created" : "/m");
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
/* The Stock tab: on hand against par, worst first, in three segments, and the stock jobs underneath. */
import { useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { atReorderVariants } from "@/lib/portalcounts";
import { bcBound, flaggedNeeds, label, locMap, locTrail, onhand, reorderAt, touched } from "@/lib/compute";
import { MBody, MButton, MEmpty, MRow, MRule, MSearch, MSection, MSeg, MTabs, MTop, MTopCount } from "@/components/m";
import { PAGE, byShortfall, countRows, placedOnOrder } from "@/components/m/stock/stockdata";
type Seg = "below" | "order" | "all";
const SEGS: Seg[] = ["below", "order", "all"];
export default function MStock() {
const { s } = useSnap();
const { L, byId, variants } = useDerived();
const router = useRouter();
const sp = useSearchParams();
const [seg, setSeg] = useState<Seg>(() => {
const v = sp.get("seg") as Seg | null;
return v && SEGS.includes(v) ? v : "below";
});
const [q, setQ] = useState("");
const [shown, setShown] = useState(PAGE);
const data = useMemo(() => {
const locs = locMap(s);
const onOrd = placedOnOrder(s, byId);
const below = new Set(atReorderVariants(s, L).map((v) => v.key));
const all = variants.filter((v) => touched(s, L, v.key)).map((v) => ({
key: v.key, itemId: v.itemId, si: v.si, size: v.size,
name: label(v.item), oh: onhand(s, L, v.key), par: reorderAt(s, v.key),
code: bcBound(s, v.item, v.si), shelf: locTrail(locs, s.placed[v.key], 0),
below: below.has(v.key), onOrder: (onOrd[v.key] || 0) > 0,
}));
const order = variants.filter((v) => (onOrd[v.key] || 0) > 0).length;
return { all, below: below.size, order, drafts: flaggedNeeds(s, L, byId).length };
}, [s, L, byId, variants]);
const shelves = useMemo(() => countRows(s, L, variants).length, [s, L, variants]);
const rows = useMemo(() => {
const needle = q.trim().toLowerCase();
return data.all
.filter((r) => seg === "all" || (seg === "below" ? r.below : r.onOrder))
.filter((r) => !needle || `${r.name} ${r.size} ${r.code} ${r.shelf}`.toLowerCase().includes(needle))
.sort(byShortfall);
}, [data, seg, q]);
const pick = (k: Seg) => {
setSeg(k); setShown(PAGE);
router.replace(`/m/stock?seg=${k}`, { scroll: false });
};
return (
<>
<MTop title="Stock" right={<MTopCount>{data.below} short</MTopCount>} />
<MRule />
<MBody pad>
<MSearch value={q} onChange={(v) => { setQ(v); setShown(PAGE); }} placeholder="Garment or size" label="Filter stock" scanHref="/m/scan" scanLabel="Scan a garment" />
<div style={{ marginTop: 10 }}>
<MSeg label="Stock lines" value={seg} onPick={pick} options={[
{ key: "below", label: "Below par", n: data.below },
{ key: "order", label: "On order", n: data.order },
{ key: "all", label: "All", n: data.all.length },
]} />
</div>
{rows.length === 0
? <MEmpty title="Nothing here" sub="Try All, or clear the search." />
: rows.slice(0, shown).map((r) => (
<MRow key={r.key} href={`/m/line/${encodeURIComponent(r.itemId)}/${r.si}`}
mark={r.oh <= 0 ? "accent" : r.oh <= r.par ? "ink" : "mute"}
title={`${r.name} ${r.size}`} right={`${r.oh}/${r.par}`} />
))}
{rows.length > shown && (
<>
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 10 }}>Showing {shown} of {rows.length}</div>
<MButton small label="Show more" onClick={() => setShown((n) => n + PAGE)} />
</>
)}
<MSection label="Also in Stock" />
<MRow mark="mute" chev href="/m/reorder" title={`Draft order · ${data.drafts} line${data.drafts === 1 ? "" : "s"}`} />
<MRow mark="mute" chev href="/m/variance" title="Variance over time" />
<MRow mark="mute" chev href="/m/catalogue" title="Catalogue" />
<MRow mark="mute" chev href="/m/count" title={`Count a shelf · ${shelves} shel${shelves === 1 ? "f" : "ves"}`} />
</MBody>
<MTabs active="stock" />
</>
);
}
+101
View File
@@ -0,0 +1,101 @@
"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 figure. */
import { useMemo, useState } from "react";
import { useSnap } from "@/lib/client";
import { itemMap, label, monthLabel } from "@/lib/compute";
import { INK, MBody, MButton, MEmpty, MKick, MONO, MRule, MSection, MTop, MTopCount } from "@/components/m";
const MAX_BAR = 56;
const PAGE = 40;
export default function MVarianceOverTime() {
const { s } = useSnap();
const [shown, setShown] = useState(PAGE);
const { rows, counts } = useMemo(() => {
const byId = itemMap(s);
// Oldest first, shelf counts only, the 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: `${label(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";
return { key: k, name: v.name, gaps: v.gaps, latest, verdict, persistent: shortEvery || worsening };
});
// Worst pattern first: persistent problems, then the 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))));
const month = (d: string, m: "short" | "long") => monthLabel(d.slice(0, 7), { month: m });
return (
<>
<MTop title="Variance over time" back right={<MTopCount>{counts.length} count{counts.length === 1 ? "" : "s"}</MTopCount>} />
<MRule />
<MBody pad>
{counts.length > 0 && <MKick>Gap at each count since {month(counts[0].date, "long")}</MKick>}
{rows.length === 0 ? (
<MEmpty title="No counts to compare yet" sub="Commit two shelf counts to see a pattern." />
) : (
<>
<MSection label="Lines" right={rows.length} />
{rows.slice(0, shown).map((r) => (
<div key={r.key} style={{ padding: "12px 0", borderBottom: "1px solid var(--color-divider)", boxShadow: r.persistent ? "inset 4px 0 0 var(--color-accent)" : undefined, paddingLeft: r.persistent ? 12 : 0 }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 700 }}>{r.name}</div>
<div style={{ fontSize: 13, color: r.persistent ? "var(--color-accent-700)" : "var(--color-neutral-600)", fontWeight: r.persistent ? 800 : 400, marginTop: 1 }}>{r.verdict}</div>
</div>
<div style={{ fontFamily: MONO, fontWeight: 600, fontSize: 14, color: r.latest === 0 ? INK : "var(--color-accent-700)", whiteSpace: "nowrap" }}>
{r.latest === 0 ? "Match" : r.latest > 0 ? `+${r.latest}` : `${-r.latest}`}
</div>
</div>
<div className="tcx-chart" style={{ marginTop: 12 }} role="img"
aria-label={`Gap at each count: ${r.gaps.map((g, i) => `${counts[i] ? month(counts[i].date, "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 aria-hidden="true" style={{ display: "flex", gap: 5, marginTop: 6 }}>
{counts.map((t, i) => (
<span key={i} style={{ flex: 1, textAlign: "center", fontSize: 12, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>
{month(t.date, "short")}
</span>
))}
</div>
</div>
))}
{rows.length > shown && (
<>
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 10 }}>Showing {shown} of {rows.length}</div>
<MButton small label="Show more" onClick={() => setShown((n) => n + PAGE)} />
</>
)}
</>
)}
</MBody>
</>
);
}
+184
View File
@@ -0,0 +1,184 @@
"use client";
/* The Work tab: the queue at the window. Approved requests to pick, deliveries to receive, pickups to
call and ward rounds, counted by the same useWorkCount() as the tab badge and Today. */
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { collectRows, plural, receiveRows, relativeDay, roundSheet } from "@/lib/today";
import { PICK_STATUSES, useWorkCount } from "@/lib/workcount";
import { useRequests } from "@/components/requests/RequestList";
import {
MBody, MButton, MCard, MCardChip, MEmpty, MError, MKick, MPill, MRow, MRule, MSection, MSeg, MTabs, MTop, MTopCount, useToast,
} from "@/components/m";
import { openReceivable, orderWhen, outstandingTotal } from "@/components/m/work/util";
type Seg = "picks" | "in" | "pickups" | "rounds";
const SEGS: readonly Seg[] = ["picks", "in", "pickups", "rounds"];
const asSeg = (v: string | null): Seg => (SEGS.includes(v as Seg) ? (v as Seg) : "picks");
export default function Work() {
const sp = useSearchParams();
const [seg, setSeg] = useState<Seg>(() => asSeg(sp.get("seg")));
const w = useWorkCount();
const [err, setErr] = useState("");
// A link from Today (?seg=in) lands on its segment even when Work is already mounted.
useEffect(() => { setSeg(asSeg(sp.get("seg"))); }, [sp]);
const pick = (k: Seg) => {
setSeg(k);
setErr("");
// Shallow: the segment is only a view of the snapshot already on the phone. null state, so Next's
// router takes the new URL as canonical and a later refresh doesn't put the old segment back.
try { window.history.replaceState(null, "", `/m/work?seg=${k}`); } catch { /* not fatal */ }
};
return (
<>
<MTop title="Work" right={<MTopCount>{w.total} open</MTopCount>} />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody pad>
<MSeg label="Work" value={seg} onPick={pick} options={[
{ key: "picks", label: "Picks", n: w.picks },
{ key: "in", label: "In", n: w.in },
{ key: "pickups", label: "Pickups", n: w.pickups },
{ key: "rounds", label: "Rounds", n: w.rounds },
]} />
{seg === "picks" && <Picks />}
{seg === "in" && <Inbound />}
{seg === "pickups" && <Pickups onError={setErr} />}
{seg === "rounds" && <Rounds />}
</MBody>
<MTabs active="work" workBadge={w.total} />
</>
);
}
const line = (text: string) => <span style={{ display: "block" }}>{text}</span>;
function Picks() {
const { s } = useSnap();
const { staffById } = useDerived();
const { data, error, reload } = useRequests();
const rows = useMemo(() => (data?.requests ?? [])
.filter((r) => PICK_STATUSES.has(r.status))
.sort((a, b) => (a.decidedAt || a.createdAt).localeCompare(b.decidedAt || b.createdAt)), [data]);
if (!data) {
if (error) return (
<div style={{ marginTop: 14 }}>
<MError msg="Requests couldnt be loaded." />
<MButton small label="Try again" onClick={() => void reload()} />
</div>
);
return <div style={{ marginTop: 14 }}><MKick>Loading</MKick></div>;
}
if (!rows.length) return <MEmpty title="Nothing to pick" sub="Approved requests land here." />;
return (
<>
{rows.map((r) => {
const st = staffById[r.staffId];
const what = r.bag.map((l) => `${l.item.toLowerCase()} ${l.size}`).join(", ");
const when = relativeDay(r.decidedAt, s);
const approved = [when ? `Approved ${when}` : "Approved", r.managerName].filter(Boolean).join(" · ")
+ (r.status === "ready" && r.collectCode ? ` · ready, code ${r.collectCode}` : "");
return (
<MRow key={r.id} mark="ink" chev href={`/m/request/${r.id}`} title={r.staffName}
sub={<>{line([st?.group || r.ward, what].filter(Boolean).join(" · "))}{line(approved)}</>}
right={r.garments} />
);
})}
</>
);
}
function Inbound() {
const { s } = useSnap();
const { byId, staffById } = useDerived();
const soon = useMemo(() => receiveRows(s, staffById), [s, staffById]);
const later = useMemo(() => {
const ids = new Set(soon.map((r) => r.o.id));
return openReceivable(s, byId).filter((o) => !ids.has(o.id));
}, [s, byId, soon]);
const row = (o: (typeof later)[number], forName: string) => (
<MRow key={o.id} mark="ink" chev href={`/m/receive/${o.id}`} title={`${o.code} · ${o.supplier || "Supplier"}`}
sub={[orderWhen(s, o), plural(o.lines.length, "line"), forName ? `for ${forName}` : ""].filter(Boolean).join(" · ")}
right={outstandingTotal(o, byId)} />
);
if (!soon.length && !later.length) return <MEmpty title="Nothing to receive" sub="Open orders show here when they arrive." />;
return (
<>
{soon.map((r) => row(r.o, r.forName))}
{later.length > 0 && (
<>
<MSection label="Later" right={later.length} />
{later.map((o) => row(o, o.staffId ? (staffById[o.staffId] ? `${staffById[o.staffId].first} ${staffById[o.staffId].last}`.trim() : "") : ""))}
</>
)}
</>
);
}
function Pickups({ onError }: { onError: (msg: string) => void }) {
const { s, mutate } = useSnap();
const { byId, staffById } = useDerived();
const toast = useToast();
const rows = useMemo(() => collectRows(s, byId, staffById, { includeRound: true }), [s, byId, staffById]);
const [busy, setBusy] = useState<string | null>(null);
const run = async (id: string, op: string, payload: Record<string, unknown>, then?: () => void) => {
if (busy) return;
setBusy(id);
onError("");
const r = await mutate(op, payload);
setBusy(null);
if (!r.ok) { onError(r.error); return; }
then?.();
};
if (!rows.length) return <MEmpty title="No one waiting" sub="Orders in for a person show here when they arrive." />;
return (
<>
{rows.map((r) => {
const items = r.lines.map((l) => `${l.garment} ${l.size} ×${l.qty}`).join(", ");
const id = r.p.id;
return (
<MCard key={id} title={r.name} sub={[r.st?.dept, items].filter(Boolean).join(" · ")}
pill={<MPill tone={r.late ? "accent" : "mute"}>{r.days}d</MPill>}
actions={
<>
<MCardChip label="Call" href={r.tel || undefined} disabled={!r.tel} />
<MCardChip label={r.p.contacted ? "Contacted" : "Contacted?"} on={r.p.contacted} disabled={busy === id}
onClick={() => void run(id, "pickup.contacted", { id, contacted: !r.p.contacted })} />
<MCardChip label="Collected" disabled={busy === id}
onClick={() => void run(id, "pickup.pickedUp", { id }, () => toast(`${r.name} collected ${items}`))} />
</>
} />
);
})}
</>
);
}
function Rounds() {
const { s } = useSnap();
const { byId, staffById } = useDerived();
const wards = useMemo(() => roundSheet(s, byId, staffById), [s, byId, staffById]);
if (!wards.length) return <MEmpty title="No rounds today" sub="Delivered rounds are in History on the desktop." />;
return (
<>
{wards.map((w) => {
const people = new Set(w.rows.map((r) => r.p.staffId)).size;
return (
<MRow key={w.ward} mark="ink" chev href={`/m/round/${encodeURIComponent(w.ward)}`} title={w.ward}
sub={`${plural(people, "person", "people")} · ${plural(w.rows.length, "bag")}`} right={w.garments} />
);
})}
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import Analytics from "@/components/Analytics";
export const dynamic = "force-dynamic";
/* `title` has to be absolute here. As a plain string it went through the root layout's
"%s — ThreadCount" template and every screen of the phone app, sign-in included, was titled
"ThreadCount — ThreadCount" in the tab, in history and in a bookmark. The template is re-declared
for the screens below that name themselves, and the canonical points at the app rather than
inheriting the marketing homepage's. */
export const metadata: Metadata = {
title: { absolute: "ThreadCount — the linen room counter", template: "%s — ThreadCount" },
alternates: { canonical: "/m" },
robots: { index: false, follow: false },
};
/* The app is a fixed-height column: bars don't scroll, only the body does.
*
* No maximumScale. Pinning the zoom kept the layout tidy and took pinch-to-zoom away from everyone
* on every screen — WCAG 1.4.4, and it matters more than tidiness on a ward phone held at arm's
* length in bad light. The bundled shell's own index.html had the same line removed. */
export const viewport = { width: "device-width", initialScale: 1, viewportFit: "cover" as const, themeColor: "#201e1d" };
/* Only the shell. Sign in and create account live under /m but must be reachable without a
session — they are how you get one — so the session check sits in (app) with everything else. */
export default function MobileLayout({ children }: { children: React.ReactNode }) {
return (
<div className="tcx-app" role="main">
{/* The shell itself is the main landmark. No skip link on the phone surfaces: navigation is
the bar at the bottom, after the content, so there is no repeated block in front to bypass.
role on the existing box rather than a <main> wrapper — the shell is a fixed-height flex
column, and the display:contents that an extra element would need has a long history of
dropping the landmark out of the accessibility tree. */}
{children}
{/* Mounted on /m rather than inside (app): sign in and create account are app screens too,
and they are where the Android shell lands first. */}
<Analytics site="app" />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { Metadata } from "next";
/* Sign in is a client component and can't export metadata of its own, so it gets this. Without it
the page inherited the app shell's title and a browser tab, a history entry and a bookmark for
the sign-in screen all read as if they were the site's front page. */
export const metadata: Metadata = {
title: "Sign in",
alternates: { canonical: "/m/login" },
};
export default function MLoginLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
+205
View File
@@ -0,0 +1,205 @@
"use client";
/* Sign in — onboarding screen 03. Public: this is the one /m route someone reaches without a
session, because it is how they get one. */
import Link from "next/link";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense } from "react";
import Turnstile, { awaitTurnstile, resetTurnstile, turnstileOn } from "@/components/Turnstile";
import { track } from "@/lib/analytics";
import { MAuthError, MAuthFooter, MAuthHeader, MField, MShowHide, authInput, authLink } from "@/components/MAuth";
function LoginInner() {
const router = useRouter();
const sp = useSearchParams();
const [email, setEmail] = useState("");
const [pw, setPw] = useState("");
const [show, setShow] = useState(false);
const [cfToken, setCfToken] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState("");
const [reveal, setReveal] = useState(false);
const [forgot, setForgot] = useState(false);
const [sentReset, setSentReset] = useState(false);
const [ticket, setTicket] = useState("");
const [code, setCode] = useState("");
const next = (() => {
const n = sp.get("next") || "";
return n.startsWith("/m") && !n.startsWith("//") ? n : "/m";
})();
async function sendReset() {
setErr("");
if (!email.trim()) { setErr("Put your work email in the box above first."); return; }
setBusy(true);
const token = cfToken || (turnstileOn() ? await awaitTurnstile() : "");
await fetch("/api/auth/forgot", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ email, cfToken: token }),
}).catch(() => {});
setBusy(false);
setCfToken(""); resetTurnstile();
// Shown whatever the server said: the answer must not reveal whether the address has an account.
setSentReset(true);
}
async function submitCode() {
setErr("");
if (!code.trim()) { setErr("Enter the six-digit code from your authenticator app."); return; }
setBusy(true);
const r = await fetch("/api/auth/2fa", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ ticket, code }),
}).catch(() => null);
const j = r ? await r.json().catch(() => ({})) : {};
setBusy(false);
// A dropped connection is not a wrong code, and it used to leave the button spinning for ever
// with nothing said. Nothing is signed in either way, so the advice is simply to try again.
if (!r) {
track("signin_failed", { reason: "network" });
setErr("Couldnt reach the server. Check the connection and try again.");
return;
}
if (!r.ok) {
track("signin_failed", { reason: "second_factor" });
setErr(j.error || "That code isnt right.");
if (r.status === 400) setTicket(""); // the ticket expired — start again
return;
}
track("signin");
window.location.replace(next);
}
async function submit() {
if (!email.trim() || !pw) { setErr("Enter your email and password."); return; }
setBusy(true);
// The widget draws nothing in quiet mode, so nobody can see that it hasn't finished. Wait for
// the token rather than posting an empty one and blaming the person for it.
const token = cfToken || (turnstileOn() ? await awaitTurnstile() : "");
if (turnstileOn() && !token) {
// Eight seconds and no token. Either the check needs an interaction we've asked Turnstile
// not to draw, or it couldn't reach Cloudflare at all. Show the widget rather than send an
// empty token and let the server answer with a check the person was never shown.
setBusy(false);
setReveal(true);
// How often the invisible check has to show itself. If this climbs, the quiet widget is
// costing people sign-ins and should come back out.
track("security_check_shown", { screen: "signin" });
setErr("Finish the security check below, then try again.");
return;
}
const r = await fetch("/api/auth/login", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ email, password: pw, cfToken: token }),
}).catch(() => null);
const j = r ? await r.json().catch(() => ({})) : {};
setBusy(false);
/* The request never arrived. Ward wifi drops, and without this the promise rejected, `busy`
never cleared and the sign-in button sat disabled on its spinner until the app was killed —
which reads as ThreadCount refusing to let you in. Nothing was signed in, so the token is
spent and the widget reset like any other failed attempt. */
if (!r) {
track("signin_failed", { reason: "network" });
setErr("Couldnt reach the server. Check the connection and try again.");
setCfToken(""); resetTurnstile();
return;
}
if (!r.ok) {
// Whether it was the password or the security check — no email, no message.
track("signin_failed", { reason: r.status === 429 ? "throttled" : r.status === 400 ? "security_check" : "credentials" });
setErr(j.error || "That email and password dont match.");
setCfToken(""); resetTurnstile();
return;
}
// The password was right but the account has a second factor; nothing is signed in yet.
if (j.need2fa) { setTicket(j.ticket); track("signin_2fa_required"); return; }
track("signin");
// A full navigation, not a router push: the session cookie has just changed and every /m page
// is server-rendered from it. replace(), not assign(), so the hardware back button doesn't
// land a signed-in person back on the sign-in screen.
window.location.replace(next);
}
return (
<>
<MAuthHeader kicker="Linen room access" title="Sign in" />
<div style={{ height: 4, flex: "0 0 4px", background: "var(--color-accent)" }} />
<div style={{ flex: 1, overflowY: "auto", padding: "26px 24px 30px", display: "grid", gap: 26, alignContent: "start" }}>
<p style={{ fontSize: 14, lineHeight: 1.5, color: "var(--color-neutral-800)", maxWidth: 290 }}>
{ticket ? "Your password was right. Now the code from your authenticator app." : "Use the account your linen services manager set up."}
</p>
<MAuthError msg={err} />
{ticket ? (
<>
<MField n="01" label="Six-digit code">
{(c) => (
<input {...c} style={authInput} inputMode="numeric" autoComplete="one-time-code" autoFocus
autoCapitalize="none" autoCorrect="off" spellCheck={false} enterKeyHint="go"
placeholder="000000" value={code}
onChange={(e) => { setCode(e.target.value); setErr(""); }}
onKeyDown={(e) => { if (e.key === "Enter") submitCode(); }} />
)}
</MField>
<p style={{ fontSize: 12.5, lineHeight: 1.6, color: "var(--color-neutral-700)" }}>
Lost your phone? A recovery code works here instead.
</p>
</>
) : (
<>
<MField n="01" label="Work email">
{(c) => (
<input {...c} style={authInput} type="email" inputMode="email" autoCapitalize="none" autoCorrect="off"
spellCheck={false} autoComplete="email" enterKeyHint="next" placeholder="you@yourfacility.org"
value={email} onChange={(e) => { setEmail(e.target.value); setErr(""); }} />
)}
</MField>
<MField n="02" label="Password" right={<MShowHide on={show} onToggle={() => setShow(!show)} />}>
{(c) => (
<input {...c} style={authInput} type={show ? "text" : "password"} autoComplete="current-password"
enterKeyHint="go" value={pw}
onChange={(e) => { setPw(e.target.value); setErr(""); }}
onKeyDown={(e) => { if (e.key === "Enter") submit(); }} />
)}
</MField>
</>
)}
{/* This used to explain that there was no reset and to go and find an admin — which was a
dead end for the admin themselves, and deleting the last admin deletes the facility.
Hidden during the code step, where it would be answering a question nobody asked. */}
{ticket ? null : sentReset ? (
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: "var(--color-neutral-800)", background: "#fff", borderLeft: "6px solid var(--color-accent)", padding: 14 }}>
If that address has an account, a reset link is on its way. It works once and expires in
an hour.
</p>
) : forgot ? (
<div style={{ background: "#fff", borderLeft: "6px solid var(--color-text)", padding: 14 }}>
<p style={{ fontSize: 13.5, lineHeight: 1.6, color: "var(--color-neutral-800)", margin: 0 }}>
Put your work email in the box above and we&rsquo;ll send a link to set a new password.
</p>
<button onClick={sendReset} disabled={busy}
style={{ marginTop: 12, background: "none", border: 0, padding: 0, fontSize: 13.5, fontWeight: 800, color: "var(--color-accent-700)", cursor: busy ? "wait" : "pointer" }}>
{busy ? "Sending…" : "Send me a reset link"}
</button>
</div>
) : (
<button onClick={() => setForgot(true)}
style={{ justifySelf: "start", background: "none", border: 0, padding: 0, fontSize: 13, fontWeight: 600, color: "var(--color-accent-700)", cursor: "pointer" }}>
Forgot password
</button>
)}
{turnstileOn() && <Turnstile onToken={setCfToken} action="login" quiet={!reveal} />}
</div>
<MAuthFooter
secondary={ticket
? <button onClick={() => { setTicket(""); setCode(""); setErr(""); }} style={{ background: "none", border: 0, padding: 0, font: "inherit", fontSize: 13, fontWeight: 700, color: "var(--color-accent-700)", cursor: "pointer" }}>Start again</button>
: <>No account yet? <Link href="/m/signup" style={authLink}>Sign up</Link></>}
label={ticket ? "Verify" : "Sign in"} onSubmit={ticket ? submitCode : submit} busy={busy} />
</>
);
}
export default function MLogin() {
return <Suspense fallback={null}><LoginInner /></Suspense>;
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next";
/* Same reason as the sign-in screen next door: a client page can't name itself, and "create an
account" is not the site's front page. */
export const metadata: Metadata = {
title: "Create an account",
alternates: { canonical: "/m/signup" },
};
export default function MSignupLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
+11
View File
@@ -0,0 +1,11 @@
import { switches } from "@/lib/switches";
import MSignup from "@/components/MSignup";
/* Create account — onboarding screen 04. The screen itself is components/MSignup.tsx; this wrapper
exists to tell it whether plans are live, which only the server knows. */
export const dynamic = "force-dynamic";
export default async function MSignupPage() {
const sw = await switches();
return <MSignup plansLive={sw.plansLive} />;
}