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