ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 38e16eb on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
"use client";
|
||||
/* The product card, on the phone.
|
||||
*
|
||||
* Two halves, because they are two different jobs. The top is the garment's description, which is
|
||||
* typed once and rarely changed. The bottom is per size — par level, barcode, what's on hand —
|
||||
* which is what someone standing at a shelf actually came here to adjust.
|
||||
*
|
||||
* The size index — a position in that list — is what every issue, order line and barcode points at,
|
||||
* so the order of the list is never offered for editing: shuffling it would silently repoint years
|
||||
* of records. One size can be taken off, though, and the server does the deciding: it shifts every
|
||||
* later size down across the ten tables that store a position, in one transaction, and refuses
|
||||
* outright when the size being removed has anything recorded against it. So this screen offers the
|
||||
* removal on every size and shows whatever comes back.
|
||||
*
|
||||
* `?bind=<code>` arrives from a scan that found nothing: each size offers to take that code. */
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { bcBound, formatInZone, key as vkey, label, money, onhand, reorderAt, splitKey, type CostRec, type Item } from "@/lib/compute";
|
||||
import { printItemLabels } from "@/lib/nativeprint";
|
||||
import MScan from "@/components/MScan";
|
||||
import {
|
||||
ACCENT, GROUND, INK, ON_DARK, MBar, MBody, MButton, MEmpty, MError, MField, MONO, MPill, MRule, MSection, MTop, MTopCount, inputStyle, useToast,
|
||||
} from "@/components/m";
|
||||
|
||||
/* The two ways to get a code onto a size, side by side. Scanning stays the primary act, ink-filled:
|
||||
it is the fastest when the camera cooperates. Typing sits beside it rather than behind it — a
|
||||
label in your hand beats a camera that won't focus. */
|
||||
const codeBtn: React.CSSProperties = {
|
||||
flex: 1, minHeight: 48, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800,
|
||||
fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer",
|
||||
};
|
||||
/* Undoing rather than doing: the quieter kind of action on a size row. No border, so it reads as a
|
||||
link; 44px tall, so it is still a target you can hit with gloves on. */
|
||||
const quietAction: React.CSSProperties = {
|
||||
display: "flex", alignItems: "center", width: "100%", minHeight: 44, background: "none", border: 0,
|
||||
padding: 0, font: "inherit", fontSize: 13, fontWeight: 700, color: "var(--color-neutral-700)",
|
||||
textAlign: "left", cursor: "pointer",
|
||||
};
|
||||
|
||||
/* A freshly minted number exists in ThreadCount at once, but nothing is on the garment until it is
|
||||
printed, so the confirmation carries the print with it. */
|
||||
function MMade({ made, labels, onPrint }: { made: { size: string; code: string }[]; labels: number; onPrint: () => void }) {
|
||||
if (!made.length) return null;
|
||||
return (
|
||||
<div style={{ margin: "10px 0 0", padding: 16, background: INK, color: GROUND, fontSize: 14 }}>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 16 }}>
|
||||
{made.length === 1 ? `Size ${made[0].size} has a barcode` : `${made.length} sizes have a barcode`}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, color: ON_DARK, fontSize: 13, fontFamily: MONO }}>
|
||||
{made.map((m) => <div key={m.code}>{m.size} · {m.code}</div>)}
|
||||
</div>
|
||||
{labels > 0 && (
|
||||
<button onClick={onPrint}
|
||||
style={{ width: "100%", minHeight: 48, marginTop: 12, border: "2px solid " + GROUND, background: GROUND, color: INK, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer" }}>
|
||||
Print {labels} label{labels === 1 ? "" : "s"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MProductCard() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { s, isAdmin, mutate, busy } = useSnap();
|
||||
const { L, byId } = useDerived();
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
const toast = useToast();
|
||||
const bindRaw = (sp.get("bind") || "").trim().slice(0, 80);
|
||||
const bindCode = isAdmin ? bindRaw : "";
|
||||
|
||||
const it = s.catalog.find((x: Item) => x.id === id);
|
||||
|
||||
const [err, setErr] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [f, setF] = useState(() => ({
|
||||
item: it?.item ?? "", type: it?.type ?? "", group: it?.group ?? "All",
|
||||
supplier: it?.supplier ?? "", sku: it?.sku ?? "", cost: it ? String(it.cost) : "", notes: it?.notes ?? "",
|
||||
}));
|
||||
const [newSize, setNewSize] = useState("");
|
||||
const [scanFor, setScanFor] = useState<number | null>(null);
|
||||
const [typeFor, setTypeFor] = useState<number | null>(null);
|
||||
const [typed, setTyped] = useState("");
|
||||
/* Which run is in flight, so only the button that was pressed says so. -1 is the whole garment. */
|
||||
const [genFor, setGenFor] = useState<number | null>(null);
|
||||
const [made, setMade] = useState<{ si: number; size: string; code: string }[]>([]);
|
||||
|
||||
// Binding is admin-only; an Issuer who arrives with a code goes back to the catalogue.
|
||||
useEffect(() => { if (bindRaw && !isAdmin) router.replace("/m/catalogue"); }, [bindRaw, isAdmin, router]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const set = new Set<string>(["All"]);
|
||||
for (const st of s.staff) if (st.group) set.add(st.group);
|
||||
for (const i of s.catalog) if (i.group) set.add(i.group);
|
||||
return [...set].sort();
|
||||
}, [s.staff, s.catalog]);
|
||||
|
||||
if (!it) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Garment" back />
|
||||
<MRule />
|
||||
<MBody pad><MEmpty title="No such garment" sub="It isn’t in the catalogue any more." /></MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const name = label(byId[it.id] ?? it);
|
||||
// Newest first; the snapshot already caps how many it carries.
|
||||
const costs: CostRec[] = s.costs.filter((c) => c.itemId === it.id);
|
||||
const readOnly = !isAdmin;
|
||||
// How many sizes a whole-garment run would cover, and how many labels a print run would produce —
|
||||
// one per garment on the shelf in a size that carries a code.
|
||||
const unlabelled = it.sizes.filter((_: string, si: number) => !bcBound(s, it, si)).length;
|
||||
const labels = it.sizes.reduce((n: number, _: string, si: number) =>
|
||||
n + (bcBound(s, it, si) ? Math.max(0, onhand(s, L, vkey(it.id, si))) : 0), 0);
|
||||
|
||||
/* Fill the boxes from the garment as it stands right now, not as it stood when the screen was
|
||||
* opened: the card refreshes underneath without remounting, and saving a stale form would put an
|
||||
* old price back in someone else's name. */
|
||||
function startEdit() {
|
||||
setErr("");
|
||||
setF({
|
||||
item: it!.item, type: it!.type, group: it!.group,
|
||||
supplier: it!.supplier, sku: it!.sku, cost: String(it!.cost), notes: it!.notes,
|
||||
});
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
async function saveDetails() {
|
||||
setErr("");
|
||||
if (!f.item.trim()) { setErr("The garment needs a name."); return; }
|
||||
const c = f.cost.trim() ? Number(f.cost) : 0;
|
||||
if (!(c >= 0) || Number.isNaN(c)) { setErr("Cost has to be a number."); return; }
|
||||
const r = await mutate("catalog.update", {
|
||||
id: it!.id, item: f.item.trim(), type: f.type.trim(), group: f.group,
|
||||
supplier: f.supplier.trim(), sku: f.sku.trim(), cost: c, notes: f.notes,
|
||||
});
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
async function addSize() {
|
||||
const sz = newSize.trim();
|
||||
if (!sz) return;
|
||||
setErr("");
|
||||
const r = await mutate("catalog.update", { id: it!.id, addSize: sz });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setNewSize("");
|
||||
}
|
||||
|
||||
async function setPar(si: number, next: number) {
|
||||
const r = await mutate("stock.reorder", { itemId: it!.id, si, reorder: Math.max(0, next) });
|
||||
if (!r.ok) setErr(r.error);
|
||||
}
|
||||
|
||||
/** Where a code already sits, named the way a person would name it, or "" if this snapshot has
|
||||
* never seen it. */
|
||||
function boundElsewhere(code: string): string {
|
||||
const at = s.barcodes[code];
|
||||
if (!at) return "";
|
||||
const { itemId, si } = splitKey(at);
|
||||
const other = byId[itemId];
|
||||
return other ? `${label(other)} · size ${other.sizes[si] ?? si}` : "";
|
||||
}
|
||||
|
||||
/* Binding, including the refusal that used to be a dead end: when the code already sits on another
|
||||
* garment, offer the move rather than printing the message and stopping there. A generated
|
||||
* 93XXXXXXX code is refused with or without force and is shown as it came. */
|
||||
async function bind(si: number, raw: string): Promise<boolean> {
|
||||
const code = raw.trim();
|
||||
if (!code) return false;
|
||||
setErr(""); setMade([]);
|
||||
const r = await mutate("barcode.bind", { code, itemId: it!.id, si });
|
||||
if (r.ok) { setTypeFor(null); setTyped(""); return true; }
|
||||
const at = boundElsewhere(code);
|
||||
if (!at && !/is already on/.test(r.error)) { setErr(r.error); return false; }
|
||||
const ask = at
|
||||
? `${code} is on ${at}. Take it off there and put it on ${name} · size ${it!.sizes[si]}?`
|
||||
: `${r.error}\n\nMove it onto ${name} · size ${it!.sizes[si]}?`;
|
||||
if (!confirm(ask)) { setErr(r.error); return false; }
|
||||
const moved = await mutate("barcode.bind", { code, itemId: it!.id, si, force: true });
|
||||
if (!moved.ok) { setErr(moved.error); return false; }
|
||||
setTypeFor(null); setTyped("");
|
||||
return true;
|
||||
}
|
||||
|
||||
async function bindScanned(si: number) {
|
||||
if (!(await bind(si, bindCode))) return;
|
||||
toast(`${bindCode} bound to size ${it!.sizes[si]}`);
|
||||
router.replace(`/m/catalogue/${encodeURIComponent(it!.id)}`);
|
||||
}
|
||||
|
||||
async function unbind(si: number, code: string) {
|
||||
if (!confirm(`Unbind ${code} from ${name} · size ${it!.sizes[si]}? Scanning that label won't find this size any more.`)) return;
|
||||
setErr(""); setMade([]);
|
||||
const r = await mutate("barcode.unbind", { code });
|
||||
if (!r.ok) setErr(r.error);
|
||||
}
|
||||
|
||||
/* The server decides whether a size can go; removing shifts later sizes down a place, so anything
|
||||
* held open against a position lets go of it. */
|
||||
async function removeSize(si: number) {
|
||||
if (!confirm(`Remove size ${it!.sizes[si]} from ${name}? Its par level and any barcode on it go with it.`)) return;
|
||||
setErr("");
|
||||
const r = await mutate("catalog.removeSize", { id: it!.id, si });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setScanFor(null); setTypeFor(null); setTyped(""); setMade([]);
|
||||
}
|
||||
|
||||
/* Our own barcode for stock that arrived without one. The server fills only the gaps and refuses
|
||||
* when there is nothing to do, so the offer is made and whatever comes back is shown. */
|
||||
async function generate(si?: number) {
|
||||
setErr(""); setMade([]);
|
||||
setGenFor(si ?? -1);
|
||||
const r = await mutate<{ made: { si: number; size: string; code: string }[]; count: number }>(
|
||||
"barcode.generate", si === undefined ? { itemId: it!.id } : { itemId: it!.id, si },
|
||||
);
|
||||
setGenFor(null);
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setMade(r.result.made);
|
||||
}
|
||||
|
||||
async function generateAll() {
|
||||
const ask = `Generate a barcode for ${unlabelled} size${unlabelled === 1 ? "" : "s"} on ${name}? Sizes that already carry a supplier's code keep theirs, and nothing is on a garment until the labels are printed.`;
|
||||
if (unlabelled > 0 && !confirm(ask)) return;
|
||||
await generate();
|
||||
}
|
||||
|
||||
/* A whole garment's labels: one per garment on hand, every size that carries a code. Android
|
||||
printing in the app; a printable page in a browser. */
|
||||
async function printLabels() {
|
||||
const r = await printItemLabels({ itemId: it!.id });
|
||||
if (!r.ok) setErr(r.error);
|
||||
}
|
||||
|
||||
async function archive() {
|
||||
const r = await mutate("catalog.update", { id: it!.id, archived: !it!.archived });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
if (!it!.archived) router.replace("/m/catalogue");
|
||||
}
|
||||
|
||||
const bigBtn = (tone: "line" | "accent" | "ink"): React.CSSProperties => ({
|
||||
width: "100%", minHeight: 52, border: "2px solid " + (tone === "accent" ? ACCENT : INK),
|
||||
background: tone === "accent" ? ACCENT : tone === "ink" ? INK : "transparent", color: tone === "line" ? INK : tone === "ink" ? GROUND : "#fff",
|
||||
font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 14, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title={it.archived ? "Archived" : "Garment"} right={<MTopCount>{it.sizes.length} size{it.sizes.length === 1 ? "" : "s"}</MTopCount>} back />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody pad>
|
||||
{(it.archived || bindCode) && (
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 10 }}>
|
||||
{it.archived && <MPill tone="accent">Archived</MPill>}
|
||||
{bindCode && <MPill tone="accent" mono>Binding {bindCode}</MPill>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- the description ---- */}
|
||||
{!editing ? (
|
||||
<>
|
||||
<div style={{ paddingBottom: 14, borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<h2 style={{ fontWeight: 900, fontSize: 24, lineHeight: 1.1, margin: 0 }}>{name}</h2>
|
||||
<div style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 6 }}>
|
||||
{[it.type, it.group === "All" ? "Anyone" : it.group, it.supplier, it.sku].filter(Boolean).join(" · ") || "No details yet"}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontFamily: MONO, marginTop: 4 }}>{it.cost ? `${money(it.cost)} each` : "No unit cost"}</div>
|
||||
{it.notes && <div style={{ fontSize: 13, color: "var(--color-neutral-800)", marginTop: 8 }}>{it.notes}</div>}
|
||||
</div>
|
||||
{!readOnly && !bindCode && (
|
||||
<div style={{ padding: "14px 0", borderBottom: "2px solid " + INK }}>
|
||||
<button onClick={startEdit} style={bigBtn("line")}>Edit details</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MField label="Garment">
|
||||
<input value={f.item} onChange={(e) => setF({ ...f, item: e.target.value })} autoCapitalize="words" style={inputStyle} />
|
||||
</MField>
|
||||
<MField label="Type">
|
||||
<input value={f.type} onChange={(e) => setF({ ...f, type: e.target.value })} style={inputStyle} />
|
||||
</MField>
|
||||
<MField label="Who wears it">
|
||||
<select value={f.group} onChange={(e) => setF({ ...f, group: e.target.value })} style={{ ...inputStyle, appearance: "none" }}>
|
||||
{groups.map((g) => <option key={g} value={g}>{g === "All" ? "Anyone" : g}</option>)}
|
||||
</select>
|
||||
</MField>
|
||||
<MField label="Supplier">
|
||||
<input value={f.supplier} onChange={(e) => setF({ ...f, supplier: e.target.value })} style={inputStyle} />
|
||||
</MField>
|
||||
<MField label="Supplier code">
|
||||
<input value={f.sku} onChange={(e) => setF({ ...f, sku: e.target.value })} autoCapitalize="characters" autoCorrect="off" spellCheck={false} style={inputStyle} />
|
||||
</MField>
|
||||
<MField label="Unit cost">
|
||||
<input value={f.cost} onChange={(e) => setF({ ...f, cost: e.target.value })} inputMode="decimal" style={inputStyle} />
|
||||
</MField>
|
||||
<MField label="Notes">
|
||||
<input value={f.notes} onChange={(e) => setF({ ...f, notes: e.target.value })} placeholder="Optional" style={inputStyle} />
|
||||
</MField>
|
||||
<div style={{ padding: "14px 0", display: "grid", gap: 10, borderBottom: "2px solid " + INK }}>
|
||||
<button onClick={saveDetails} disabled={busy} style={bigBtn("accent")}>{busy ? "Saving…" : "Save details"}</button>
|
||||
<button onClick={() => { setEditing(false); setErr(""); setF({ item: it.item, type: it.type, group: it.group, supplier: it.supplier, sku: it.sku, cost: String(it.cost), notes: it.notes }); }}
|
||||
style={{ width: "100%", minHeight: 48, border: 0, background: "none", color: "var(--color-neutral-700)", font: "inherit", fontSize: 14, fontWeight: 700, cursor: "pointer" }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ---- per size ---- */}
|
||||
<MSection label="Sizes" right="on hand · par" />
|
||||
{it.sizes.map((sz: string, si: number) => {
|
||||
const k = vkey(it.id, si);
|
||||
const oh = onhand(s, L, k);
|
||||
const par = reorderAt(s, k);
|
||||
// The bound supplier code only; bcFor()'s generated fallback is printed on no garment.
|
||||
const code = bcBound(s, it, si);
|
||||
return (
|
||||
<div key={si} style={{ padding: "14px 0", borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{ fontFamily: MONO, fontWeight: 600, fontSize: 18, minWidth: 54 }}>{sz}</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-700)" }}>
|
||||
<b style={{ color: oh <= par ? "var(--color-accent-700)" : INK, fontSize: 15, fontFamily: MONO }}>{oh}</b> on hand
|
||||
</div>
|
||||
<div style={{ fontSize: 12, fontFamily: MONO, color: code ? "var(--color-neutral-700)" : "var(--color-neutral-600)", marginTop: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{code || "No barcode bound"}
|
||||
</div>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 0 }}>
|
||||
<button onClick={() => setPar(si, par - 1)} aria-label={`Lower par for ${sz}`}
|
||||
style={{ width: 44, height: 44, border: "2px solid " + INK, background: "transparent", color: INK, fontSize: 20, fontWeight: 800, cursor: "pointer" }}>−</button>
|
||||
<div aria-label={`Par for ${sz}: ${par}`} style={{ minWidth: 44, height: 44, background: INK, color: GROUND, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: MONO, fontSize: 16 }}>{par}</div>
|
||||
<button onClick={() => setPar(si, par + 1)} aria-label={`Raise par for ${sz}`}
|
||||
style={{ width: 44, height: 44, border: "2px solid " + INK, background: "transparent", color: INK, fontSize: 20, fontWeight: 800, cursor: "pointer" }}>+</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && bindCode && code !== bindCode && (
|
||||
<MButton small tone="ink" label={`Bind to size ${sz}`} disabled={busy} onClick={() => bindScanned(si)} />
|
||||
)}
|
||||
{!readOnly && !bindCode && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{typeFor === si ? (
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
{/* A numeric keypad hint only: whatever arrives is taken as typed. */}
|
||||
<input value={typed} onChange={(e) => setTyped(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") bind(si, typed); }}
|
||||
placeholder="Barcode on the label" inputMode="numeric" autoFocus
|
||||
autoCapitalize="characters" autoCorrect="off" spellCheck={false}
|
||||
aria-label={`Barcode for size ${sz}`} style={inputStyle} />
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button onClick={() => bind(si, typed)} disabled={busy || !typed.trim()}
|
||||
style={{ ...codeBtn, border: "2px solid " + ACCENT, background: ACCENT, color: "#fff", opacity: typed.trim() ? 1 : 0.4 }}>
|
||||
{busy ? "Binding…" : "Bind"}
|
||||
</button>
|
||||
<button onClick={() => { setTypeFor(null); setTyped(""); }}
|
||||
style={{ ...codeBtn, border: "2px solid var(--color-neutral-400)", background: "transparent", color: "var(--color-neutral-700)" }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button onClick={() => setScanFor(si)} aria-label={`Scan a barcode for size ${sz}`}
|
||||
style={{ ...codeBtn, border: "2px solid " + INK, background: INK, color: GROUND }}>
|
||||
{code ? "Scan a new one" : "Scan"}
|
||||
</button>
|
||||
<button onClick={() => { setErr(""); setTyped(""); setTypeFor(si); }} aria-label={`Type a barcode for size ${sz}`}
|
||||
style={{ ...codeBtn, border: "2px solid " + INK, background: "transparent", color: INK }}>
|
||||
Type it in
|
||||
</button>
|
||||
</div>
|
||||
{/* Offered only where nothing is bound: a supplier's printed code always stays. */}
|
||||
{!code && (
|
||||
<button onClick={() => generate(si)} disabled={busy} aria-label={`Generate a barcode for size ${sz}`}
|
||||
style={{ ...codeBtn, width: "100%", marginTop: 8, border: "2px solid " + INK, background: "transparent", color: INK, opacity: busy ? 0.5 : 1 }}>
|
||||
{genFor === si ? "Generating…" : "Generate a barcode"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{code && <button onClick={() => unbind(si, code)} style={quietAction}>Unbind {code}</button>}
|
||||
<button onClick={() => removeSize(si)} style={quietAction}>Remove size {sz}</button>
|
||||
{made.length === 1 && made[0].si === si && <MMade made={made} labels={labels} onPrint={printLabels} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!readOnly && !bindCode && (
|
||||
<div style={{ padding: "14px 0", borderBottom: "2px solid " + INK, display: "flex", gap: 10 }}>
|
||||
<input value={newSize} onChange={(e) => setNewSize(e.target.value)} placeholder="Add a size" aria-label="New size"
|
||||
autoCapitalize="characters" autoCorrect="off" spellCheck={false}
|
||||
style={{ ...inputStyle, flex: 1 }} />
|
||||
<button onClick={addSize} disabled={busy || !newSize.trim()}
|
||||
style={{ minWidth: 96, border: "2px solid " + INK, background: INK, color: GROUND, font: "inherit", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer", opacity: newSize.trim() ? 1 : 0.4 }}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!readOnly && !bindCode && (
|
||||
<>
|
||||
<MSection label="Barcodes" right={unlabelled ? `${unlabelled} without` : "All labelled"} />
|
||||
{made.length > 1 && <MMade made={made} labels={labels} onPrint={printLabels} />}
|
||||
<div style={{ padding: "14px 0", display: "grid", gap: 10, borderBottom: "2px solid " + INK }}>
|
||||
<button onClick={generateAll} disabled={busy} style={{ ...bigBtn("line"), opacity: busy ? 0.5 : 1 }}>
|
||||
{genFor === -1 ? "Generating…" : "Generate for every unlabelled size"}
|
||||
</button>
|
||||
<button onClick={printLabels} disabled={labels === 0}
|
||||
style={{ ...bigBtn("accent"), cursor: labels === 0 ? "not-allowed" : "pointer", opacity: labels === 0 ? 0.4 : 1 }}>
|
||||
{labels ? `Print ${labels} label${labels === 1 ? "" : "s"}` : "Nothing to print"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!readOnly && !bindCode && (
|
||||
<div style={{ padding: "14px 0" }}>
|
||||
<button onClick={archive} style={{ ...quietAction, color: it.archived ? "var(--color-accent-700)" : "var(--color-neutral-700)" }}>
|
||||
{it.archived ? "Put this garment back in the catalogue" : "Archive this garment"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* What we used to pay: a price rise would otherwise erase the old figure. */}
|
||||
{costs.length > 0 && (
|
||||
<>
|
||||
<MSection label="What it has cost" right="changed by" />
|
||||
{costs.map((c) => (
|
||||
<div key={c.id} style={{ display: "flex", alignItems: "baseline", gap: 12, padding: "12px 0", borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<div style={{ fontFamily: MONO, fontWeight: 600, fontSize: 15, minWidth: 78 }}>{money(c.cost)}</div>
|
||||
<div style={{ flex: 1, minWidth: 0, fontSize: 13, color: "var(--color-neutral-700)" }}>
|
||||
{c.previous === null ? "Opening price" : `${c.previous > c.cost ? "Down" : "Up"} from ${money(c.previous)}`}
|
||||
{" · "}
|
||||
{/* The facility's zone, not the device's, so server and browser render the same day. */}
|
||||
{formatInZone(c.at, s.tz)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--color-neutral-600)", whiteSpace: "nowrap" }}>{c.byName}</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
|
||||
{!readOnly && !it.archived && !bindCode && <MBar label="Done" glyph="check" tone="ink" onClick={() => router.push("/m/catalogue")} />}
|
||||
|
||||
{scanFor !== null && (
|
||||
<MScan
|
||||
title={`Barcode for ${it.sizes[scanFor]}`}
|
||||
onClose={() => setScanFor(null)}
|
||||
onHit={(raw) => { const si = scanFor; setScanFor(null); if (si !== null) bind(si, raw); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
/* Create a garment from the counter.
|
||||
*
|
||||
* The only genuinely required things are a name and at least one size — the server enforces exactly
|
||||
* that — so everything else can be filled in later from the product card. Sizes are entered as a
|
||||
* run rather than one at a time, because that is how they arrive. */
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import type { Item } from "@/lib/compute";
|
||||
import { INK, MBar, MBody, MEmpty, MError, MField, MRule, MTop, inputStyle } from "@/components/m";
|
||||
|
||||
const COMMON_RUNS: [string, string][] = [
|
||||
["XS S M L XL", "XS · S · M · L · XL"],
|
||||
["S M L XL 2XL", "S · M · L · XL · 2XL"],
|
||||
["8 10 12 14 16 18", "8 – 18"],
|
||||
["77R 82R 87R 92R", "77R – 92R"],
|
||||
];
|
||||
|
||||
export default function MCatalogueNew() {
|
||||
const { s, isAdmin, mutate, busy } = useSnap();
|
||||
const router = useRouter();
|
||||
|
||||
const [item, setItem] = useState("");
|
||||
const [type, setType] = useState("");
|
||||
const [group, setGroup] = useState("All");
|
||||
const [supplier, setSupplier] = useState("");
|
||||
const [sku, setSku] = useState("");
|
||||
const [cost, setCost] = useState("");
|
||||
const [sizeText, setSizeText] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
// Split on commas, slashes or whitespace so a run can be typed however it comes to hand.
|
||||
const sizes = useMemo(
|
||||
() => sizeText.split(/[,/\s]+/).map((x) => x.trim()).filter(Boolean),
|
||||
[sizeText],
|
||||
);
|
||||
const dupSize = useMemo(() => sizes.length !== new Set(sizes).size, [sizes]);
|
||||
|
||||
/* The facility's configured groups are the vocabulary; groups already in use on the register or
|
||||
* the catalogue are folded in so none that predates the configured list vanishes. */
|
||||
const groups = useMemo(() => {
|
||||
const set = new Set<string>(["All", ...s.settings.staffGroups]);
|
||||
for (const st of s.staff) if (st.group) set.add(st.group);
|
||||
for (const i of s.catalog) if (i.group) set.add(i.group);
|
||||
return [...set].sort();
|
||||
}, [s.staff, s.catalog, s.settings.staffGroups]);
|
||||
|
||||
const types = useMemo(() => [...new Set(s.catalog.map((i: Item) => i.type).filter(Boolean))].sort(), [s.catalog]);
|
||||
const suppliers = useMemo(() => s.supplierDir.map((x) => x.name).sort(), [s.supplierDir]);
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="New garment" back />
|
||||
<MRule />
|
||||
<MBody pad><MEmpty title="Admins only" sub="An admin adds garments to the catalogue." /></MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setErr("");
|
||||
if (!item.trim()) { setErr("Give the garment a name."); return; }
|
||||
if (!sizes.length) { setErr("Add at least one size."); return; }
|
||||
if (dupSize) { setErr("The same size is listed twice."); return; }
|
||||
const c = cost.trim() ? Number(cost) : 0;
|
||||
if (!(c >= 0) || Number.isNaN(c)) { setErr("Cost has to be a number, or left blank."); return; }
|
||||
|
||||
const r = await mutate<{ id: string }>("catalog.add", {
|
||||
item: item.trim(), type: type.trim(), group, supplier: supplier.trim(),
|
||||
sku: sku.trim(), cost: c, sizes,
|
||||
});
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
// Straight to the product card: the next thing anyone does is bind a barcode or set par.
|
||||
router.replace(`/m/catalogue/${r.result.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="New garment" back />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody pad>
|
||||
<MField label="Garment">
|
||||
<input value={item} onChange={(e) => { setItem(e.target.value); setErr(""); }}
|
||||
placeholder="Scrub top" autoCapitalize="words" enterKeyHint="next" style={inputStyle} />
|
||||
</MField>
|
||||
|
||||
<MField label="Sizes">
|
||||
<input value={sizeText} onChange={(e) => { setSizeText(e.target.value); setErr(""); }}
|
||||
placeholder="S M L XL" autoCapitalize="characters" autoCorrect="off" spellCheck={false} style={inputStyle} />
|
||||
</MField>
|
||||
<div role="group" aria-label="Common size runs" style={{ paddingBottom: 12, display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||
{COMMON_RUNS.map(([run, pretty]) => (
|
||||
<button key={run} type="button" aria-pressed={sizeText === run} onClick={() => { setSizeText(run); setErr(""); }}
|
||||
style={{ minHeight: 44, border: "2px solid " + INK, background: sizeText === run ? INK : "transparent", color: sizeText === run ? "var(--color-bg)" : INK, padding: "0 12px", fontFamily: "inherit", fontSize: 13, fontWeight: 700, cursor: "pointer" }}>
|
||||
{pretty}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{sizes.length > 0 && (
|
||||
<div style={{ paddingBottom: 12, fontSize: 13, color: dupSize ? "var(--color-accent-700)" : "var(--color-neutral-700)", fontWeight: dupSize ? 700 : 400 }}>
|
||||
{dupSize ? "The same size is listed twice." : `${sizes.length} size${sizes.length === 1 ? "" : "s"}: ${sizes.join(" · ")}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MField label="Type">
|
||||
<input list="tc-types" value={type} onChange={(e) => setType(e.target.value)} placeholder="Scrub top" style={inputStyle} />
|
||||
<datalist id="tc-types">{types.map((t) => <option key={t} value={t} />)}</datalist>
|
||||
</MField>
|
||||
|
||||
<MField label="Who wears it">
|
||||
<select value={group} onChange={(e) => setGroup(e.target.value)} style={{ ...inputStyle, appearance: "none" }}>
|
||||
{groups.map((g) => <option key={g} value={g}>{g === "All" ? "Anyone" : g}</option>)}
|
||||
</select>
|
||||
</MField>
|
||||
|
||||
<MField label="Supplier">
|
||||
<input list="tc-suppliers" value={supplier} onChange={(e) => setSupplier(e.target.value)} placeholder="Optional" style={inputStyle} />
|
||||
<datalist id="tc-suppliers">{suppliers.map((x) => <option key={x} value={x} />)}</datalist>
|
||||
</MField>
|
||||
|
||||
<MField label="Supplier code">
|
||||
<input value={sku} onChange={(e) => setSku(e.target.value)} placeholder="Optional" autoCapitalize="characters" autoCorrect="off" spellCheck={false} style={inputStyle} />
|
||||
</MField>
|
||||
|
||||
<MField label="Unit cost">
|
||||
<input value={cost} onChange={(e) => { setCost(e.target.value); setErr(""); }}
|
||||
inputMode="decimal" placeholder="0.00" style={inputStyle} />
|
||||
</MField>
|
||||
</MBody>
|
||||
<MBar label={busy ? "Saving…" : "Create garment"} onClick={save} disabled={busy} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
/* The whole catalogue on the phone, not just what's on the shelf: Stock lists only sizes with
|
||||
* history, so a garment created five minutes ago lives here. `?bind=<code>` arrives from a scan that
|
||||
* found nothing; picking a garment carries the code to its product card (admins only). */
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { label, money, type Item } from "@/lib/compute";
|
||||
import { MBody, MButton, MEmpty, MPill, MRow, MRule, MSearch, MSection, MTop, MTopCount } from "@/components/m";
|
||||
|
||||
const PAGE = 300;
|
||||
|
||||
export default function MCatalogue() {
|
||||
const { s, isAdmin } = useSnap();
|
||||
const { byId } = useDerived();
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
const bindRaw = (sp.get("bind") || "").trim().slice(0, 80);
|
||||
const bind = isAdmin ? bindRaw : "";
|
||||
const [q, setQ] = useState("");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [shown, setShown] = useState(PAGE);
|
||||
|
||||
// Binding is an admin job; an Issuer arriving with a code just gets the catalogue.
|
||||
useEffect(() => { if (bindRaw && !isAdmin) router.replace("/m/catalogue"); }, [bindRaw, isAdmin, router]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
return s.catalog
|
||||
.filter((i: Item) => (showArchived ? i.archived : !i.archived))
|
||||
.map((i: Item) => ({
|
||||
...i,
|
||||
name: label(byId[i.id] ?? i),
|
||||
sub: [i.sizes.length ? `${i.sizes.length} size${i.sizes.length === 1 ? "" : "s"}` : "No sizes", i.supplier, i.sku].filter(Boolean).join(" · "),
|
||||
}))
|
||||
.filter((i) => !needle || `${i.name} ${i.sku} ${i.supplier} ${i.type} ${i.group}`.toLowerCase().includes(needle))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [s.catalog, byId, q, showArchived]);
|
||||
|
||||
const archivedCount = s.catalog.filter((i: Item) => i.archived).length;
|
||||
const cardHref = (id: string) => `/m/catalogue/${encodeURIComponent(id)}${bind ? `?bind=${encodeURIComponent(bind)}` : ""}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Catalogue" back right={<MTopCount>{rows.length}</MTopCount>} />
|
||||
<MRule />
|
||||
<MBody pad>
|
||||
<MSearch value={q} onChange={(v) => { setQ(v); setShown(PAGE); }} placeholder="Garment, code, supplier" label="Filter the catalogue" />
|
||||
{bind && <div style={{ marginTop: 10 }}><MPill tone="accent" mono>Binding {bind}</MPill></div>}
|
||||
|
||||
{isAdmin && !bind && <MButton icon="plus" label="New garment" href="/m/catalogue/new" />}
|
||||
|
||||
<MSection label={showArchived ? "Archived" : "Garments"} right={rows.length} />
|
||||
{rows.length === 0
|
||||
? <MEmpty title={q ? "Nothing matches" : showArchived ? "Nothing archived" : "No garments yet"}
|
||||
sub={q ? "Try a shorter search." : undefined} />
|
||||
: rows.slice(0, shown).map((i) => (
|
||||
<MRow key={i.id} href={isAdmin ? cardHref(i.id) : undefined} chev={isAdmin}
|
||||
mark={i.archived ? "mute" : "ink"} title={i.name} sub={i.sub}
|
||||
right={i.cost ? money(i.cost) : undefined} />
|
||||
))}
|
||||
{rows.length > shown && (
|
||||
<>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 10 }}>Showing {shown} of {rows.length}</div>
|
||||
<MButton small label="Show more" onClick={() => setShown((n) => n + PAGE)} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{archivedCount > 0 && (
|
||||
<MButton small label={showArchived ? "Current catalogue" : `Show ${archivedCount} archived`}
|
||||
onClick={() => { setShowArchived(!showArchived); setShown(PAGE); }} />
|
||||
)}
|
||||
</MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
/* Counting: scan a garment and its own line goes up by one and becomes the line being counted.
|
||||
Expected figures stay on screen (a sighted count, as the phone has always been). The tally lives
|
||||
in localStorage (lib/opencount.ts), so backgrounding the app mid-shelf loses nothing.
|
||||
|
||||
Hands-free keeps the camera reading continuously. Inside the Android shell MLKit runs with its
|
||||
preview behind this opaque screen, so the panel and the list stay in view and each garment is a
|
||||
beep, a buzz and the figures moving (handsfree.png). In a browser the camera needs a visible,
|
||||
playing <video>, so hands-free opens the live camera overlay with the same panel and switch. */
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { UNPLACED } from "@/lib/compute";
|
||||
import MScan from "@/components/MScan";
|
||||
import { isNative, startLive } from "@/lib/nativescan";
|
||||
import { scanReject, scanTick } from "@/lib/feedback";
|
||||
import { track } from "@/lib/analytics";
|
||||
import { useKeepAwake } from "@/lib/wakelock";
|
||||
import { SHELF_LABEL_PREFIX } from "@/lib/scanroute";
|
||||
import { readCount, writeCount } from "@/lib/opencount";
|
||||
import { GROUND, INK, MAction, MBody, MButton, MEmpty, MError, MRow, MRule, MSection, MSplit, MTop, MTopAction } from "@/components/m";
|
||||
import { CountFigures, CountPanel, HandsFree, TypeCount } from "@/components/m/count/CountPanel";
|
||||
import { lineTitle, useCountLines } from "@/components/m/count/lines";
|
||||
|
||||
const DEBOUNCE_MS = 900;
|
||||
|
||||
export default function MCounting() {
|
||||
const { s } = useSnap();
|
||||
const router = useRouter();
|
||||
const locationId = String(useParams().id || "");
|
||||
const { lines, locName, locs } = useCountLines(locationId);
|
||||
|
||||
const [counted, setCounted] = useState<Record<string, number>>({});
|
||||
// Held by variant key, never by position: the list is rebuilt on every live refresh.
|
||||
const [activeKey, setActiveKey] = useState("");
|
||||
const [single, setSingle] = useState(false);
|
||||
const [hands, setHands] = useState(false);
|
||||
// "inline": MLKit behind this screen. "overlay": the live camera overlay (browser, or a shell without MLKit).
|
||||
const [handsMode, setHandsMode] = useState<"inline" | "overlay">("inline");
|
||||
const [typing, setTyping] = useState(false);
|
||||
const [log, setLog] = useState<string[]>([]);
|
||||
const [err, setErr] = useState("");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
// Restore this person's open count of this shelf, once per shelf. The key includes the user: the
|
||||
// phone is shared. It deliberately does not re-run on `lines`, so a size moved off the shelf from
|
||||
// the desktop mid-count keeps what was already counted against it.
|
||||
const me = s.session.userId;
|
||||
useEffect(() => {
|
||||
setCounted(readCount(me, locationId)?.n ?? {});
|
||||
// Recount from Check the gaps lands here with ?line=<key>.
|
||||
try {
|
||||
const line = new URLSearchParams(window.location.search).get("line");
|
||||
if (line) setActiveKey(line);
|
||||
} catch { /* no query to read */ }
|
||||
setLoaded(true);
|
||||
}, [me, locationId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded) return;
|
||||
writeCount(me, locationId, counted);
|
||||
}, [counted, me, locationId, loaded]);
|
||||
|
||||
useKeepAwake(true);
|
||||
|
||||
const total = lines.reduce((t, l) => t + (counted[l.key] ?? 0), 0);
|
||||
const expectedAll = lines.reduce((t, l) => t + l.expected, 0);
|
||||
const cur = lines.find((l) => l.key === activeKey) || lines[0];
|
||||
const curName = cur ? `${lineTitle(cur)} · ${cur.size}` : "";
|
||||
const curN = cur ? counted[cur.key] ?? 0 : 0;
|
||||
|
||||
const bump = useCallback((k: string, by: number) => {
|
||||
setCounted((c) => ({ ...c, [k]: Math.max(0, (c[k] ?? 0) + by) }));
|
||||
}, []);
|
||||
|
||||
/** A scanned code lands on its own line, whichever line was active: the barcode is the truth. */
|
||||
const onCode = useCallback((raw: string) => {
|
||||
const code = raw.trim();
|
||||
if (!code) return;
|
||||
const hit = s.barcodes[code];
|
||||
const line = hit ? lines.find((l) => l.key === hit) : lines.find((l) => l.code === code);
|
||||
if (!line) {
|
||||
scanReject();
|
||||
const known = Object.prototype.hasOwnProperty.call(s.barcodes, code);
|
||||
track("scan_miss", { kind: known ? "wrong_shelf" : "unknown" });
|
||||
const shelf = code.startsWith(SHELF_LABEL_PREFIX) ? locs[code.slice(SHELF_LABEL_PREFIX.length)] : undefined;
|
||||
const placedAt = hit ? locs[s.placed[hit] || ""]?.name : "";
|
||||
setErr(shelf ? `That’s the label for ${shelf.name}`
|
||||
: !known ? `${code} isn’t a garment ThreadCount knows`
|
||||
: placedAt ? `${code} is on ${placedAt}, not this shelf`
|
||||
: locationId === UNPLACED ? `${code} isn’t in this count` : `${code} isn’t on a shelf yet`);
|
||||
setLog((g) => [`${code}: not on this shelf`, ...g].slice(0, 8));
|
||||
return;
|
||||
}
|
||||
setActiveKey(line.key);
|
||||
setTyping(false);
|
||||
bump(line.key, 1);
|
||||
setErr("");
|
||||
setLog((g) => [`${lineTitle(line)} ${line.size}`, ...g].slice(0, 8));
|
||||
}, [s.barcodes, s.placed, lines, locs, locationId, bump]);
|
||||
|
||||
const onCodeRef = useRef(onCode); onCodeRef.current = onCode;
|
||||
|
||||
// Hands-free inside the Android shell: MLKit reads continuously behind this screen. This screen's
|
||||
// root carries `tcx-scanui` while it runs, so globals.css leaves it visible and opaque.
|
||||
const [inlineLive, setInlineLive] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!hands || handsMode !== "inline") return;
|
||||
if (!isNative()) { setHandsMode("overlay"); return; }
|
||||
let session: { stop: () => Promise<void> } | null = null;
|
||||
let cancelled = false;
|
||||
let lastRaw = "", lastT = 0;
|
||||
setInlineLive(true);
|
||||
(async () => {
|
||||
const r = await startLive((raw) => {
|
||||
if (raw === lastRaw && Date.now() - lastT < DEBOUNCE_MS) return;
|
||||
lastRaw = raw; lastT = Date.now();
|
||||
scanTick();
|
||||
onCodeRef.current(raw);
|
||||
});
|
||||
if (cancelled) { await r.stop(); return; }
|
||||
if (r.error === "native-unavailable") { setInlineLive(false); setHandsMode("overlay"); return; }
|
||||
if (r.error) { setInlineLive(false); setHands(false); setErr(r.error); return; }
|
||||
session = r;
|
||||
})();
|
||||
track("scan_opened", { mode: "live", engine: "mlkit-inline" });
|
||||
return () => { cancelled = true; setInlineLive(false); if (session) session.stop(); };
|
||||
}, [hands, handsMode]);
|
||||
|
||||
const toggleHands = () => {
|
||||
setTyping(false);
|
||||
setSingle(false);
|
||||
setHands((h) => !h);
|
||||
};
|
||||
|
||||
if (loaded && !lines.length) {
|
||||
return (
|
||||
<>
|
||||
<MTop title={locName} back />
|
||||
<MRule />
|
||||
<MBody pad><MEmpty title="Nothing on this shelf" sub="Place sizes on it in the portal" /></MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const panelInner = cur ? <CountFigures name={curName} counted={curN} expected={cur.expected} /> : null;
|
||||
|
||||
return (
|
||||
// display: contents keeps every piece a direct flex child of .tcx-app as before; the class is what
|
||||
// spares this screen while MLKit's preview runs behind the WebView.
|
||||
<div className={inlineLive ? "tcx-scanui" : undefined} style={{ display: "contents" }}>
|
||||
<MTop title={locName} back right={<MTopAction label="Finish" onClick={() => { setHands(false); router.push(`/m/count/${locationId}/variance`); }} />} />
|
||||
<MRule n={total} of={expectedAll} />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
|
||||
<MBody pad>
|
||||
{cur && (
|
||||
<CountPanel>
|
||||
{panelInner}
|
||||
<HandsFree on={hands} onToggle={toggleHands} />
|
||||
{typing && (
|
||||
<TypeCount key={cur.key} name={curName} value={curN}
|
||||
onSet={(n) => { setCounted((c) => ({ ...c, [cur.key]: n })); setTyping(false); }} />
|
||||
)}
|
||||
</CountPanel>
|
||||
)}
|
||||
|
||||
<MSection label="Lines" right={`${total} of ${expectedAll}`} />
|
||||
{lines.map((l) => {
|
||||
const n = counted[l.key] ?? 0;
|
||||
const on = !!cur && l.key === cur.key;
|
||||
return (
|
||||
<MRow key={l.key} active={on} onClick={() => { setActiveKey(l.key); setTyping(false); }}
|
||||
mark={n === l.expected ? "ok" : n > 0 ? "ink" : "mute"}
|
||||
title={`${lineTitle(l)} ${l.size}`}
|
||||
sub={l.where || undefined}
|
||||
right={`${n}/${l.expected}`} />
|
||||
);
|
||||
})}
|
||||
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<MButton label="Type a count" onClick={() => { setHands(false); setTyping((t) => !t); }} disabled={!cur} />
|
||||
</div>
|
||||
</MBody>
|
||||
|
||||
<MSplit>
|
||||
<MAction label="Undo" flex={1} tone="ink" onClick={() => cur && bump(cur.key, -1)} disabled={!cur || curN <= 0} />
|
||||
<MAction label="Scan" flex={2} glyph="scan" glyphAt="right" onClick={() => { setHands(false); setTyping(false); setSingle(true); }} />
|
||||
</MSplit>
|
||||
|
||||
{single && (
|
||||
<MScan title="Scan a garment" onHit={(raw) => { onCode(raw); setSingle(false); }} onClose={() => setSingle(false)} />
|
||||
)}
|
||||
|
||||
{hands && handsMode === "overlay" && (
|
||||
<MScan
|
||||
title="Hands-free"
|
||||
live
|
||||
running
|
||||
log={log}
|
||||
debounceMs={DEBOUNCE_MS}
|
||||
onHit={onCode}
|
||||
onClose={() => setHands(false)}
|
||||
figure={cur ? <div style={{ background: INK, color: GROUND, padding: "12px 16px 0" }}>{panelInner}</div> : undefined}
|
||||
control={<div style={{ background: INK, padding: "0 16px calc(12px + env(safe-area-inset-bottom, 0px))" }}><HandsFree on onToggle={() => setHands(false)} /></div>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
/* Check the gaps: the lines that don't match, each with a reason, then the commit.
|
||||
A gap at or over the facility's threshold (settings.varianceReason, enforced again by
|
||||
stocktake.apply) must carry a reason before the bar will commit. The reason chosen is stored
|
||||
on the stocktake line. */
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { UNPLACED } from "@/lib/compute";
|
||||
import { clearCount, readCount, writeCount } from "@/lib/opencount";
|
||||
import { INK, MBar, MBody, MEmpty, MError, MKick, MLine, MPill, MReasonChips, MRow, MRule, MSection, MTop } from "@/components/m";
|
||||
import { OVER_REASONS, SHORT_REASONS, lineTitle, signed, useCountLines } from "@/components/m/count/lines";
|
||||
|
||||
const chip: React.CSSProperties = {
|
||||
minHeight: 44, minWidth: 48, padding: "0 12px", border: "2px solid " + INK, background: "transparent", color: INK,
|
||||
fontFamily: "inherit", fontSize: 14, fontWeight: 700, cursor: "pointer", borderRadius: 0, flex: "none",
|
||||
};
|
||||
|
||||
export default function MVariance() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
const router = useRouter();
|
||||
const locationId = String(useParams().id || "");
|
||||
const { lines, locName } = useCountLines(locationId);
|
||||
|
||||
const [counted, setCounted] = useState<Record<string, number> | null>(null);
|
||||
const [reason, setReason] = useState<Record<string, string>>({});
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const me = s.session.userId;
|
||||
useEffect(() => {
|
||||
setCounted(readCount(me, locationId)?.n ?? {});
|
||||
}, [me, locationId]);
|
||||
|
||||
const gate = Math.max(1, s.settings.varianceReason);
|
||||
const gaps = useMemo(() => (counted ? lines.filter((l) => (counted[l.key] ?? 0) !== l.expected) : []), [counted, lines]);
|
||||
const matches = useMemo(() => (counted ? lines.filter((l) => (counted[l.key] ?? 0) === l.expected) : []), [counted, lines]);
|
||||
const missing = gaps.filter((l) => Math.abs((counted?.[l.key] ?? 0) - l.expected) >= gate && !reason[l.key]);
|
||||
const ready = missing.length === 0;
|
||||
|
||||
const recount = useCallback((key: string) => {
|
||||
if (!counted) return;
|
||||
writeCount(me, locationId, { ...counted, [key]: 0 });
|
||||
setReason((r) => { const n = { ...r }; delete n[key]; return n; });
|
||||
router.push(`/m/count/${locationId}?line=${encodeURIComponent(key)}`);
|
||||
}, [counted, me, locationId, router]);
|
||||
|
||||
const commit = useCallback(async () => {
|
||||
if (!counted || !ready || busy) return;
|
||||
const payload = lines.map((l) => {
|
||||
const n = counted[l.key] ?? 0;
|
||||
return { itemId: l.itemId, si: l.si, counted: n, reason: n !== l.expected ? reason[l.key] || "" : "" };
|
||||
});
|
||||
const r = await mutate("stocktake.apply", { lines: payload, mode: "shelf", locationId: locationId === UNPLACED ? "" : locationId });
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
clearCount(me, locationId);
|
||||
router.replace(`/m?flash=counted&loc=${encodeURIComponent(locName)}&gaps=${gaps.length}`);
|
||||
}, [counted, ready, busy, lines, reason, mutate, locationId, me, router, locName, gaps.length]);
|
||||
|
||||
if (!counted) return (<><MTop title="Check the gaps" back /><MRule /><MBody pad /></>);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Check the gaps" back />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody pad>
|
||||
<MKick>{locName}</MKick>
|
||||
<h2 style={{ fontSize: 26, fontWeight: 900, margin: "2px 0 0", lineHeight: 1.15 }}>
|
||||
{gaps.length === 0 ? "Everything matches" : `${gaps.length} gap${gaps.length === 1 ? "" : "s"}`}
|
||||
</h2>
|
||||
|
||||
{lines.length === 0 && <MEmpty title="Nothing on this shelf" />}
|
||||
|
||||
{gaps.map((l) => {
|
||||
const n = counted[l.key] ?? 0;
|
||||
const d = n - l.expected;
|
||||
const name = `${lineTitle(l)} ${l.size}`;
|
||||
return (
|
||||
<div key={l.key} style={{ marginTop: 10 }}>
|
||||
<MLine title={name} flag={`${n} counted, ${l.expected} expected`}
|
||||
right={
|
||||
<>
|
||||
<MPill tone="accent" mono>{signed(d)}</MPill>
|
||||
<button type="button" style={chip} onClick={() => recount(l.key)} aria-label={`Recount ${name}`}>Recount</button>
|
||||
</>
|
||||
}>
|
||||
<MReasonChips reasons={d > 0 ? OVER_REASONS : SHORT_REASONS} value={reason[l.key] || null} label={`Reason for ${name}`}
|
||||
onPick={(r) => setReason((x) => { const o = { ...x }; if (r) o[l.key] = r; else delete o[l.key]; return o; })} />
|
||||
</MLine>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{matches.length > 0 && (
|
||||
<div style={{ marginTop: 22 }}>
|
||||
<MSection label="Match" right={matches.length} />
|
||||
{matches.map((l) => (
|
||||
<MRow key={l.key} dense mark="ok" title={`${lineTitle(l)} ${l.size}`} right={counted[l.key] ?? 0} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</MBody>
|
||||
<MBar label={busy ? "Committing…" : "Commit count"}
|
||||
small={ready ? `${lines.length} line${lines.length === 1 ? "" : "s"}` : "reason each gap"}
|
||||
onClick={commit} disabled={!ready || busy || lines.length === 0}
|
||||
offReason={!ready ? "Pick a reason for each gap" : undefined} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "@/components/m/stock/CountList";
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
/* The Done screen as its own history entry. A finished hand-over or delivery router.replace()s its
|
||||
form here (components/SignFlow showDone), so Back skips the spent form, a router refresh re-renders
|
||||
this screen rather than the form's parent, and a reload still shows what was just recorded. */
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DoneScreen, readDone, type DoneProps } from "@/components/SignFlow";
|
||||
import { MBody, MKick, MRule, MTop } from "@/components/m";
|
||||
|
||||
export default function DonePage() {
|
||||
const router = useRouter();
|
||||
const [done, setDone] = useState<DoneProps | null>(null);
|
||||
useEffect(() => {
|
||||
const d = readDone();
|
||||
if (d) setDone(d);
|
||||
else router.replace("/m");
|
||||
}, [router]);
|
||||
|
||||
if (done) return <DoneScreen {...done} />;
|
||||
return (
|
||||
<>
|
||||
<MTop title="Done" />
|
||||
<MRule />
|
||||
<MBody pad><MKick>Loading</MKick></MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* The person's record opens on its Issue segment. */
|
||||
export default async function IssueTo({ params }: { params: Promise<{ staffId: string }> }) {
|
||||
const { staffId } = await params;
|
||||
redirect(`/m/person/${encodeURIComponent(staffId)}`);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* Issuing starts from a person now: the People tab, or a scanned badge. */
|
||||
export default function IssuePicker() {
|
||||
redirect("/m/people");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* Reprinting a label is Print a label on the stock line's own page. */
|
||||
export default function Label() {
|
||||
redirect("/m/stock?seg=all");
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { currentUser } from "@/lib/session";
|
||||
import { buildSnapshot } from "@/lib/snapshot";
|
||||
import { SnapshotProvider } from "@/lib/client";
|
||||
import { MToastProvider } from "@/components/m";
|
||||
import { MBasketProvider } from "@/components/MBasket";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Everything that needs a signed-in coordinator. Sending them to /m/login rather than /auth keeps
|
||||
them in the app's own world: /auth is the website's two-pane sign-in, which is a jarring thing
|
||||
to meet on a phone halfway through opening an app.
|
||||
|
||||
The toast and the in-progress basket sit inside the snapshot, and neither renders a wrapper
|
||||
element, so every screen stays a direct child of .tcx-app (the native scan transparency relies
|
||||
on that). */
|
||||
export default async function MobileAppLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await currentUser();
|
||||
if (!user) redirect("/m/login");
|
||||
const snap = await buildSnapshot(user);
|
||||
return (
|
||||
<SnapshotProvider snap={snap}>
|
||||
<MToastProvider>
|
||||
<MBasketProvider>{children}</MBasketProvider>
|
||||
</MToastProvider>
|
||||
</SnapshotProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
/* One stock line: a garment in one size. On hand against par, where it lives, what is coming, and the
|
||||
three things done about it: print a label, count its shelf, put it on the draft order. */
|
||||
import { useCallback, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { bcBound, key, label, locMap, locTrail, money, onOrderText, onhand, reorderAt } from "@/lib/compute";
|
||||
import { INK, MBody, MButton, MEmpty, MKick, MONO, MPill, MRow, MRule, MTop, useToast } from "@/components/m";
|
||||
import PrintSheet from "@/components/m/stock/PrintSheet";
|
||||
import { heldByStaff, lastCountedText } from "@/components/m/stock/stockdata";
|
||||
|
||||
const dt: React.CSSProperties = { margin: 0, padding: "11px 14px 11px 0", borderBottom: "1px solid var(--color-divider)", fontSize: 12, fontWeight: 800, letterSpacing: "0.08em", textTransform: "uppercase", color: "var(--color-neutral-600)" };
|
||||
const dd: React.CSSProperties = { margin: 0, padding: "11px 0", borderBottom: "1px solid var(--color-divider)", textAlign: "right", fontWeight: 700 };
|
||||
|
||||
export default function MLinePage() {
|
||||
const params = useParams<{ itemId: string; si: string }>();
|
||||
const { s, isAdmin, mutate, busy } = useSnap();
|
||||
const { L } = useDerived();
|
||||
const toast = useToast();
|
||||
const [printing, setPrinting] = useState(false);
|
||||
// Stable, because MSheet re-runs its focus handling whenever onClose changes.
|
||||
const closePrint = useCallback(() => setPrinting(false), []);
|
||||
|
||||
const itemId = decodeURIComponent(String(params.itemId || ""));
|
||||
const si = /^\d+$/.test(String(params.si || "")) ? Number(params.si) : -1;
|
||||
const it = s.catalog.find((x) => x.id === itemId);
|
||||
|
||||
if (!it || it.archived || si < 0 || si >= it.sizes.length) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Stock line" back />
|
||||
<MRule />
|
||||
<MBody pad><MEmpty title="No such line" sub="It may have been archived." /></MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const k = key(it.id, si);
|
||||
const size = String(it.sizes[si]);
|
||||
const oh = onhand(s, L, k), par = reorderAt(s, k);
|
||||
const code = bcBound(s, it, si);
|
||||
const locs = locMap(s);
|
||||
const placedId = s.placed[k];
|
||||
const placed = placedId ? locs[placedId] : undefined;
|
||||
const shelf = placed ? (locTrail(locs, placed.id, 0) || placed.name) : "";
|
||||
const onOrder = onOrderText(s, it.id, si);
|
||||
const short = Math.max(0, par - oh);
|
||||
|
||||
const addToDraft = async () => {
|
||||
const r = await mutate<{ added: number; qty: number }>("stock.orderLine", { itemId: it.id, si });
|
||||
if (!r.ok) { toast(r.error); return; }
|
||||
toast(r.result.added > 0 ? `Added ${r.result.added} to the draft order` : "Already on the draft order");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title={`${it.item} ${size}`} back />
|
||||
<MRule />
|
||||
<MBody pad>
|
||||
<MKick mono>{code || (it.sku ? `SKU ${it.sku}` : "No barcode")}</MKick>
|
||||
<h2 style={{ fontSize: 24, fontWeight: 900, margin: "2px 0 0", lineHeight: 1.1 }}>{label(it)} · {size}</h2>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", gap: 12, marginTop: 14, borderBottom: "2px solid " + INK, paddingBottom: 12 }}>
|
||||
<div style={{ fontSize: 64, fontWeight: 900, lineHeight: 0.95, letterSpacing: "-0.03em", fontVariantNumeric: "tabular-nums" }}>
|
||||
{oh}<small style={{ fontSize: 22, fontWeight: 700, color: "var(--color-neutral-600)", letterSpacing: 0 }}> / {par} par</small>
|
||||
</div>
|
||||
{short > 0
|
||||
? <MPill tone={oh <= 0 ? "accent" : "ink"}>{short} short</MPill>
|
||||
: <MPill tone="ok">At par</MPill>}
|
||||
</div>
|
||||
|
||||
<dl style={{ display: "grid", gridTemplateColumns: "auto 1fr", margin: "8px 0 0" }}>
|
||||
<dt style={dt}>Shelf</dt><dd style={dd}>{shelf || "Not on a shelf"}</dd>
|
||||
<dt style={dt}>On order</dt><dd style={dd}>{onOrder || "None"}</dd>
|
||||
<dt style={dt}>Last counted</dt><dd style={dd}>{lastCountedText(s, it.id, si)}</dd>
|
||||
<dt style={dt}>Held by staff</dt><dd style={{ ...dd, fontFamily: MONO }}>{heldByStaff(s, it.id, si)}</dd>
|
||||
<dt style={dt}>Unit cost</dt><dd style={{ ...dd, fontFamily: MONO }}>{money(it.cost)}</dd>
|
||||
</dl>
|
||||
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<MButton tone="ink" icon="print" label="Print a label" disabled={!code} onClick={() => setPrinting(true)} />
|
||||
{!code && <div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 6 }}>No barcode bound</div>}
|
||||
{!code && isAdmin && <MButton label="Bind a barcode" href={`/m/catalogue/${encodeURIComponent(it.id)}`} />}
|
||||
{placed && <MButton label={`Count ${shelf}`} href={`/m/count/${encodeURIComponent(placed.id)}?line=${encodeURIComponent(k)}`} />}
|
||||
<MButton label={busy ? "Adding…" : "Add to the draft order"} onClick={addToDraft} disabled={busy} />
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<MRow chev href={`/m/catalogue/${encodeURIComponent(it.id)}`} title="Product card" />
|
||||
</div>
|
||||
)}
|
||||
</MBody>
|
||||
|
||||
{code && <PrintSheet open={printing} onClose={closePrint} code={code} title={`${label(it)} ${size}`} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/* What a tap looks like before the server answers, for the counter app.
|
||||
*
|
||||
* The twin of app/my/(app)/loading.tsx, and here for the same reason: every screen under /m is
|
||||
* rendered from its own server query, App Router keeps the previous screen fully painted until that
|
||||
* query comes back, and on linen-room wifi that is seconds in which nothing acknowledges the tap.
|
||||
* People tap again — and on this app the second tap can land on a different row.
|
||||
*
|
||||
* It draws the app's own chrome (the 56px ink bar and the 4px accent rule, the shape MTop and MRule
|
||||
* make) so the change reads as "loading" rather than "gone", and deliberately not the tab bar: the
|
||||
* nav belongs to the four screens that draw it, and painting one here would flash it into existence
|
||||
* on the way to a detail screen that has none. The bar carries no screen title for the same reason
|
||||
* — this one fallback covers every route in the group, so any title would be wrong somewhere.
|
||||
*/
|
||||
const INK = "#201e1d";
|
||||
const GROUND = "#f3f2f2";
|
||||
|
||||
/** A grey block standing in for a line of text. Sized in the same 2px system as everything else. */
|
||||
function Bar({ w, h = 16 }: { w: string; h?: number }) {
|
||||
return <div style={{ width: w, height: h, background: "var(--color-neutral-200)" }} />;
|
||||
}
|
||||
|
||||
export default function CounterLoading() {
|
||||
return (
|
||||
<>
|
||||
<header className="tcx-topbar" style={{
|
||||
height: 56, flex: "0 0 56px", background: INK, color: GROUND, display: "flex", alignItems: "center",
|
||||
paddingLeft: 16, paddingRight: 16,
|
||||
backgroundImage: "linear-gradient(to bottom, rgba(243,242,242,0.16) 0 1px, transparent 1px)",
|
||||
backgroundPosition: "0 env(safe-area-inset-top, 0px)", backgroundRepeat: "no-repeat", backgroundSize: "100% 1px",
|
||||
}}>
|
||||
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, letterSpacing: "0.06em", textTransform: "uppercase" }}>
|
||||
One moment
|
||||
</span>
|
||||
</header>
|
||||
<div style={{ height: 4, flex: "0 0 4px", background: "var(--color-accent)" }} />
|
||||
<div style={{ flex: 1, overflow: "hidden", background: GROUND }} aria-busy="true">
|
||||
{/* Announced once, quietly. The blocks below are decoration and say nothing. */}
|
||||
<div role="status" style={{ padding: "20px 16px 0", fontSize: 13, color: "var(--color-neutral-600)" }}>Loading…</div>
|
||||
<div style={{ padding: "16px 16px 0", display: "grid", gap: 10 }} aria-hidden="true">
|
||||
<Bar w="60%" h={22} />
|
||||
<Bar w="40%" />
|
||||
</div>
|
||||
<div style={{ marginTop: 24, display: "grid", gap: 2 }} aria-hidden="true">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} style={{ background: "#fff", padding: "18px 16px", display: "grid", gap: 8 }}>
|
||||
<Bar w="55%" h={18} />
|
||||
<Bar w="35%" h={12} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* More's rows moved: Settings is the gear on Today, the rest live in Stock and Work. */
|
||||
export default function More() {
|
||||
redirect("/m");
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
/* Today: what is waiting, what this counter has done today, and what just happened. */
|
||||
import { Suspense } from "react";
|
||||
import { MBody, MDay, MEmpty, MKick, MRow, MRule, MSection, MTabs, MTodo, MTop, MTopAction } from "@/components/m";
|
||||
import TodayBanner from "@/components/m/today/TodayBanner";
|
||||
import { useToday } from "@/components/m/today/useToday";
|
||||
|
||||
export default function MToday() {
|
||||
const t = useToday();
|
||||
return (
|
||||
<>
|
||||
<MTop title="Today" right={<MTopAction icon="gear" label="Settings" ariaLabel="Settings" href="/m/settings" />} />
|
||||
<MRule />
|
||||
<MBody pad>
|
||||
<Suspense fallback={null}><TodayBanner /></Suspense>
|
||||
<MKick>{t.kicker}</MKick>
|
||||
|
||||
<MSection label="To do" />
|
||||
{t.todo.length === 0
|
||||
? <MEmpty title="Nothing waiting" />
|
||||
: t.todo.map((r) => <MTodo key={r.key} n={r.n} accent={r.accent} title={r.title} sub={r.sub} href={r.href} />)}
|
||||
|
||||
<MSection label="Your day" />
|
||||
<MDay figs={[{ n: t.day.issued, label: "Issued" }, { n: t.day.back, label: "Handed back" }, { n: t.day.counted, label: "Shelves counted" }]} />
|
||||
|
||||
<MSection label="Recent" />
|
||||
{t.recent.length === 0
|
||||
? <MEmpty title="Nothing yet today" />
|
||||
: t.recent.map((r) => <MRow key={r.key} mark="mute" title={r.title} sub={r.sub} right={r.right} href={r.href} />)}
|
||||
</MBody>
|
||||
<MTabs active="today" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
/* The People tab: the staff register, served-recently first, each row showing how near they are to
|
||||
the sets one person holds (capCheck, the same check the counter uses). */
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { capCheck, staffName } from "@/lib/compute";
|
||||
import { MBody, MEmpty, MRow, MRule, MSearch, MSection, MTabs, MTop, MTopCount } from "@/components/m";
|
||||
|
||||
export default function People() {
|
||||
const { s } = useSnap();
|
||||
const sp = useSearchParams();
|
||||
const [q, setQ] = useState(() => (sp.get("q") || "").slice(0, 80));
|
||||
const active = useMemo(() => s.staff.filter((x) => !x.inactive), [s]);
|
||||
|
||||
const list = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
if (!needle) {
|
||||
const seen: Record<string, string> = {};
|
||||
for (const i of s.issues) if (i.date > (seen[i.staffId] || "")) seen[i.staffId] = i.date;
|
||||
return [...active].sort((a, b) => (seen[b.id] || "").localeCompare(seen[a.id] || "") || staffName(a).localeCompare(staffName(b))).slice(0, 12);
|
||||
}
|
||||
return s.staff
|
||||
.filter((x) => `${x.first} ${x.last} ${x.num} ${x.dept} ${x.group}`.toLowerCase().includes(needle))
|
||||
.sort((a, b) => Number(a.inactive) - Number(b.inactive) || staffName(a).localeCompare(staffName(b)))
|
||||
.slice(0, 40);
|
||||
}, [s, q, active]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="People" right={<MTopCount>{active.length}</MTopCount>} />
|
||||
<MRule />
|
||||
<MBody pad>
|
||||
<MSearch value={q} onChange={setQ} placeholder="Name or staff number" label="Search the staff register" scanHref="/m/scan" />
|
||||
{s.staff.length === 0 ? (
|
||||
<MEmpty title="No staff yet" sub="Staff are added in the portal." />
|
||||
) : (
|
||||
<>
|
||||
<MSection label={q.trim() ? "Matches" : "Served recently"} right={list.length} />
|
||||
{list.map((st) => {
|
||||
const cap = capCheck(s, st);
|
||||
const most = Math.max(cap.tops, cap.pants);
|
||||
return (
|
||||
<MRow key={st.id} href={`/m/person/${st.id}`} title={staffName(st)}
|
||||
sub={[st.group, st.num, st.dept].filter((x) => x && x.trim()).join(" · ")}
|
||||
right={`${most}/${cap.cap}`} mark={st.inactive ? "mute" : most >= cap.cap ? "accent" : "ink"} />
|
||||
);
|
||||
})}
|
||||
{q.trim() && list.length === 0 && <MEmpty title={`No one matches “${q.trim()}”`} sub="Check the spelling, or scan their badge." />}
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
<MTabs active="people" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* A size exchange is Swap size on a Hand back line. */
|
||||
export default async function ExchangeFor({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
redirect(`/m/person/${encodeURIComponent(id)}?tab=back`);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
/* The person screen is the issue screen: who they are, how near the six sets they hold, and the
|
||||
* Issue | Hand back | History segments. Each segment draws its own docked bar through setBar; a
|
||||
* finished hand back swaps the whole screen for the Done screen in place. */
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { approvalRemaining, capCheck, isNursing, staffName } from "@/lib/compute";
|
||||
import { MBody, MEmpty, MError, MHead, MHeadRow, MMeterPair, MPill, MRule, MSeg, MTop } from "@/components/m";
|
||||
import { useBasket } from "@/components/MBasket";
|
||||
import { DoneScreen, type DoneProps } from "@/components/SignFlow";
|
||||
import HandBackTab from "@/components/person/HandBackTab";
|
||||
import IssueTab from "@/components/m/issue/IssueTab";
|
||||
import HistoryTab from "@/components/m/issue/HistoryTab";
|
||||
import { personMeta, plural } from "@/components/m/issue/meta";
|
||||
|
||||
type Tab = "issue" | "back" | "history";
|
||||
const TABS: { key: Tab; label: string }[] = [
|
||||
{ key: "issue", label: "Issue" },
|
||||
{ key: "back", label: "Hand back" },
|
||||
{ key: "history", label: "History" },
|
||||
];
|
||||
const asTab = (v: string | null): Tab => (v === "back" || v === "history" ? v : "issue");
|
||||
|
||||
export default function MPersonPage() {
|
||||
const { s } = useSnap();
|
||||
const id = String(useParams().id || "");
|
||||
const sp = useSearchParams();
|
||||
const basket = useBasket();
|
||||
const st = s.staff.find((x) => x.id === id);
|
||||
const [tab, setTab] = useState<Tab>(() => asTab(sp.get("tab")));
|
||||
const [bar, setBarNode] = useState<React.ReactNode>(null);
|
||||
const [err, setErr] = useState("");
|
||||
const [done, setDone] = useState<DoneProps | null>(null);
|
||||
const top = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
const setBar = useCallback((b: React.ReactNode) => setBarNode(b), []);
|
||||
const onError = useCallback((m: string) => setErr(m), []);
|
||||
const onDone = useCallback((d: DoneProps) => { setErr(""); setDone(d); }, []);
|
||||
|
||||
if (done) return <DoneScreen {...done} />;
|
||||
|
||||
if (!st) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Person" back />
|
||||
<MRule />
|
||||
<MBody pad><MEmpty title="No such staff member" /></MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const pickTab = (k: Tab) => {
|
||||
if (k === tab) return;
|
||||
setErr("");
|
||||
setTab(k);
|
||||
try { window.history.replaceState(null, "", `/m/person/${encodeURIComponent(id)}${k === "issue" ? "" : `?tab=${k}`}`); } catch { /* not fatal */ }
|
||||
const body = top.current?.parentElement;
|
||||
if (body) body.scrollTop = 0;
|
||||
};
|
||||
|
||||
const lines = basket.issue(id);
|
||||
const cap = capCheck(s, st, lines);
|
||||
const left = approvalRemaining(s, st.id);
|
||||
const props = { staffId: st.id, setBar, onDone, onError };
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title={staffName(st)} back />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody pad>
|
||||
<span ref={top} aria-hidden="true" style={{ display: "block", height: 0 }} />
|
||||
<MHead name={staffName(st)} meta={personMeta(s, st)}>
|
||||
{tab === "issue" && (
|
||||
<>
|
||||
<MMeterPair items={[
|
||||
{ label: "Tops held", held: cap.tops, adding: cap.addTops, cap: cap.cap },
|
||||
{ label: "Pants held", held: cap.pants, adding: cap.addPants, cap: cap.cap },
|
||||
]} />
|
||||
{isNursing(s, st) && <MHeadRow label="Manager approval" value={`${plural(left, "set")} left`} />}
|
||||
</>
|
||||
)}
|
||||
</MHead>
|
||||
{st.inactive && <div style={{ margin: "-4px 0 10px" }}><MPill tone="accent">Inactive</MPill></div>}
|
||||
<MSeg label="Record" value={tab} options={TABS} onPick={pickTab} />
|
||||
{tab === "issue" ? <IssueTab {...props} /> : tab === "back" ? <HandBackTab {...props} /> : <HistoryTab {...props} />}
|
||||
</MBody>
|
||||
{bar}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* Returns are the Hand back segment of the person's record. */
|
||||
export default async function ReturnFrom({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
redirect(`/m/person/${encodeURIComponent(id)}?tab=back`);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
/* Sign (issue): what is being handed over, the manager approval deduction, their signature and the
|
||||
* slip switch. issue.create records the lines, the per-line reasons, the approval deduction and the
|
||||
* signed slip in one locked write; SignFlow then shows the Done screen in place. */
|
||||
import { useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { approvalRemaining, isNursing, isPantItem, isTopItem, issueLineFlags, label, staffName } from "@/lib/compute";
|
||||
import { MBody, MButton, MEmpty, MHead, MRow, MRule, MSection, MStepper, MTop } from "@/components/m";
|
||||
import { useBasket, type IssueLine } from "@/components/MBasket";
|
||||
import SignFlow from "@/components/SignFlow";
|
||||
import { personMeta, plural } from "@/components/m/issue/meta";
|
||||
|
||||
export default function SignIssue() {
|
||||
const { s, mutate } = useSnap();
|
||||
const { byId } = useDerived();
|
||||
const id = String(useParams().id || "");
|
||||
const basket = useBasket();
|
||||
const st = s.staff.find((x) => x.id === id);
|
||||
// Once issued the basket is cleared; the lines are kept here so the screen does not fall back to
|
||||
// "nothing to issue" in the moment before the Done screen replaces it.
|
||||
const [frozen, setFrozen] = useState<IssueLine[] | null>(null);
|
||||
const lines = frozen ?? basket.issue(id);
|
||||
|
||||
if (!st || !lines.length) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Sign" back />
|
||||
<MRule />
|
||||
<MBody pad>
|
||||
<MEmpty title={st ? "Nothing to issue" : "No such staff member"} />
|
||||
{st && <MButton label="Back to their record" href={`/m/person/${encodeURIComponent(st.id)}`} />}
|
||||
</MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const n = lines.reduce((t, l) => t + l.qty, 0);
|
||||
const tops = lines.reduce((t, l) => t + (isTopItem(byId[l.itemId]) ? l.qty : 0), 0);
|
||||
const pants = lines.reduce((t, l) => t + (isPantItem(byId[l.itemId]) ? l.qty : 0), 0);
|
||||
const sets = Math.max(tops, pants);
|
||||
const left = approvalRemaining(s, st.id);
|
||||
const approval = isNursing(s, st) && left > 0;
|
||||
const most = Math.min(left, sets);
|
||||
const chosen = basket.deduct(id);
|
||||
const deduct = Math.max(0, Math.min(most, chosen ?? most));
|
||||
|
||||
const extra = approval ? (
|
||||
<>
|
||||
<MSection label="Manager approval" right={`${plural(left, "set")} left`} />
|
||||
<MRow title="Take off the approval" sub={`This issue is ${plural(sets, "set")}`}
|
||||
right={<MStepper label="sets off the approval" n={deduct} min={0} max={most} onChange={(v) => basket.setDeduct(id, v)} />} />
|
||||
</>
|
||||
) : undefined;
|
||||
|
||||
const commit = async ({ sigId, slip }: { sigId: string; slip: boolean }) => {
|
||||
const flags = issueLineFlags(s, st, lines);
|
||||
const r = await mutate<{ slipId: string | null }>("issue.create", {
|
||||
staffId: st.id,
|
||||
lines: lines.map((l, i) => ({ itemId: l.itemId, si: l.si, qty: l.qty, src: "stock", reason: flags[i] ? l.reason || "" : "" })),
|
||||
override: flags.some(Boolean),
|
||||
lineReasons: true,
|
||||
apDeduct: approval ? deduct : 0,
|
||||
sigId,
|
||||
slip,
|
||||
});
|
||||
if (!r.ok) return { ok: false as const, error: r.error };
|
||||
const sent = lines;
|
||||
setFrozen(sent);
|
||||
basket.clear("issue", st.id);
|
||||
return {
|
||||
ok: true as const,
|
||||
done: {
|
||||
head: `${plural(n, "item")} issued`,
|
||||
sub: `${staffName(st)} · ${slip ? "slip sent to their staff app" : "signed"}`,
|
||||
shelfKeys: sent.map((l) => l.key),
|
||||
next: "scan" as const,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<SignFlow
|
||||
kind="issue"
|
||||
head={<MHead name={staffName(st)} meta={personMeta(s, st)} />}
|
||||
lines={lines.map((l) => ({ key: l.key, name: `${label(byId[l.itemId])} ${String(byId[l.itemId]?.sizes[l.si] ?? l.si)}`, qty: l.qty }))}
|
||||
signerName={staffName(st)}
|
||||
extra={extra}
|
||||
slip={{ available: !!st.selfEmail }}
|
||||
barLabel={`Issue ${plural(n, "item")}`}
|
||||
commit={commit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* The pickup call list is Work › Pickups. */
|
||||
export default function Pickups() {
|
||||
redirect("/m/work?seg=pickups");
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
/* Receive a delivery: tick each line as it is unpacked, step a line down when less came. Receiving
|
||||
closes the order; anything short goes onto a back order raised by order.receive. */
|
||||
import { useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { key, staffName } from "@/lib/compute";
|
||||
import { plural } from "@/lib/today";
|
||||
import { DoneScreen, useShowDone, type DoneProps } from "@/components/SignFlow";
|
||||
import { MBar, MBody, MButton, MEmpty, MError, MField, MKick, MPickRow, MRule, MSection, MStepper, MTop, inputStyle } from "@/components/m";
|
||||
import { RECEIVABLE, garmentSize, locMap, orderWhen, outstandingLines, segment, shelfOf, type OutLine } from "@/components/m/work/util";
|
||||
|
||||
export default function ReceiveOrder() {
|
||||
const id = segment(useParams<{ id: string }>().id);
|
||||
const { s, mutate } = useSnap();
|
||||
const { byId, staffById } = useDerived();
|
||||
const order = s.orders.find((o) => o.id === id);
|
||||
const lines = useMemo(() => (order && RECEIVABLE.includes(order.status) ? outstandingLines(order, byId) : []), [order, byId]);
|
||||
const locs = useMemo(() => locMap(s), [s]);
|
||||
const [tick, setTick] = useState<Record<string, boolean>>({});
|
||||
const [qty, setQty] = useState<Record<string, number>>({});
|
||||
const [invoice, setInvoice] = useState(() => order?.invoice || "");
|
||||
const [err, setErr] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [done, setDone] = useState<DoneProps | null>(null);
|
||||
const showDone = useShowDone();
|
||||
|
||||
if (done) return <DoneScreen {...done} />;
|
||||
|
||||
const q = (l: OutLine) => qty[l.id] ?? l.outstanding;
|
||||
const ticked = lines.filter((l) => tick[l.id]);
|
||||
const n = ticked.reduce((t, l) => t + q(l), 0);
|
||||
const all = lines.length > 0 && ticked.length === lines.length;
|
||||
|
||||
const receive = async () => {
|
||||
if (!order || saving) return;
|
||||
setSaving(true);
|
||||
setErr("");
|
||||
const r = await mutate("order.receive", {
|
||||
id: order.id, invoice: invoice.trim(),
|
||||
lines: lines.map((l) => ({ lineId: l.id, itemId: l.itemId, size: l.size, arrived: q(l), dest: order.staffId ? "pickup" : "shelf" })),
|
||||
});
|
||||
setSaving(false);
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
const short = lines.some((l) => q(l) < l.outstanding);
|
||||
const d: DoneProps = {
|
||||
head: `${plural(n, "item")} received`,
|
||||
sub: `${order.code} · ${order.supplier || "Supplier"} · order closed${short ? " · back order raised" : ""}`,
|
||||
shelfKeys: order.staffId ? [] : lines.filter((l) => q(l) > 0 && l.si >= 0).map((l) => key(l.itemId, l.si)),
|
||||
next: "work",
|
||||
};
|
||||
setDone(d);
|
||||
showDone(d);
|
||||
};
|
||||
|
||||
const forName = order?.staffId ? staffName(staffById[order.staffId]) : "";
|
||||
return (
|
||||
<>
|
||||
<MTop title={order?.code || "Receive"} back />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody pad>
|
||||
{!order || !lines.length ? (
|
||||
<>
|
||||
<MEmpty title="Nothing outstanding" />
|
||||
<MButton label="Back to Work" href="/m/work?seg=in" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MKick>{`${order.supplier || "Supplier"} · ${orderWhen(s, order)}`}</MKick>
|
||||
<MSection label="Tick what came in" right={`${ticked.length} of ${lines.length}`} />
|
||||
{lines.map((l) => {
|
||||
const name = garmentSize(byId[l.itemId], "Garment", l.size);
|
||||
const sub = forName ? `for ${forName}` : l.si >= 0 ? shelfOf(s, locs, l.itemId, l.si) : "";
|
||||
return (
|
||||
<MPickRow key={l.id} done={!!tick[l.id]} onToggle={() => setTick((x) => ({ ...x, [l.id]: !x[l.id] }))} title={name} sub={sub || undefined} tickLabel={`Came in ${name}`}>
|
||||
<MStepper n={q(l)} min={0} max={l.outstanding} label={name} onChange={(v) => setQty((x) => ({ ...x, [l.id]: v }))} />
|
||||
</MPickRow>
|
||||
);
|
||||
})}
|
||||
<MButton label="Tick all" onClick={() => setTick(Object.fromEntries(lines.map((l) => [l.id, true])))} />
|
||||
<MField label="Invoice number">
|
||||
<input value={invoice} onChange={(e) => setInvoice(e.target.value)} autoComplete="off" maxLength={80} style={inputStyle} />
|
||||
</MField>
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
{order && lines.length > 0 && (
|
||||
<MBar label={saving ? "Recording…" : `Receive ${plural(n, "item")}`} disabled={saving || !all || n === 0}
|
||||
offReason={saving ? undefined : !all ? "Tick each line" : "Nothing arrived"} onClick={receive} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* Deliveries to receive are Work › In; each opens /m/receive/[id]. */
|
||||
export default function Receive() {
|
||||
redirect("/m/work?seg=in");
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
/* Draft order: what fell below par, at quantities that bring each line back up. Raising it makes a
|
||||
draft on Ordering; nothing reaches a supplier until someone approves it there. */
|
||||
import { useMemo, useState } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { flaggedNeeds, label, onhand, reorderAt, touched } from "@/lib/compute";
|
||||
import { MBar, MBody, MButton, MDone, MEmpty, MError, MRow, MRule, MSection, MStepper, MTop, MTopCount } from "@/components/m";
|
||||
|
||||
export default function MReorder() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
const { L, byId, variants } = useDerived();
|
||||
const [qty, setQty] = useState<Record<string, number>>({});
|
||||
const [err, setErr] = useState("");
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
|
||||
const needs = useMemo(() => flaggedNeeds(s, L, byId).map((n) => {
|
||||
const k = `${n.itemId}:${n.si}`;
|
||||
return { ...n, key: k, name: `${label(byId[n.itemId])} ${n.size}`, oh: onhand(s, L, k), par: reorderAt(s, k) };
|
||||
}), [s, L, byId]);
|
||||
|
||||
// Stock's "short" counts every line at or under par; flaggedNeeds drops the ones open orders cover.
|
||||
const belowPar = useMemo(
|
||||
() => variants.filter((v) => touched(s, L, v.key) && onhand(s, L, v.key) <= reorderAt(s, v.key)).length,
|
||||
[s, L, variants],
|
||||
);
|
||||
const covered = Math.max(0, belowPar - needs.length);
|
||||
|
||||
const q = (k: string, fallback: number) => qty[k] ?? fallback;
|
||||
const total = needs.reduce((t, n) => t + q(n.key, n.qty), 0);
|
||||
const suppliers = [...new Set(needs.map((n) => n.supplier))];
|
||||
|
||||
const raise = async () => {
|
||||
const bySup: Record<string, typeof needs> = {};
|
||||
for (const n of needs) if (q(n.key, n.qty) > 0) (bySup[n.supplier] ||= []).push(n);
|
||||
const codes: string[] = [];
|
||||
for (const sup of Object.keys(bySup)) {
|
||||
const r = await mutate<{ code: string }>("order.create", {
|
||||
orderFor: "Stock", supplier: sup, replenish: false, notes: "Raised from a stocktake on the app",
|
||||
lines: bySup[sup].map((n) => ({ itemId: n.itemId, size: n.size, qty: q(n.key, n.qty) })),
|
||||
});
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
codes.push(r.result.code);
|
||||
}
|
||||
setDone(codes.join(" · "));
|
||||
};
|
||||
|
||||
if (done) return (
|
||||
<>
|
||||
<MTop title="Done" />
|
||||
<MRule />
|
||||
<MBody pad>
|
||||
<MDone head={`Draft ${done} raised`} sub="Waiting on Ordering" />
|
||||
<MButton label="Back to Stock" href="/m/stock" />
|
||||
</MBody>
|
||||
</>
|
||||
);
|
||||
|
||||
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Draft order" back right={<MTopCount>{plural(needs.length, "line")}</MTopCount>} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody pad>
|
||||
{needs.length === 0 ? (
|
||||
<MEmpty title={belowPar ? "Already on order" : "Nothing to reorder"}
|
||||
sub={belowPar ? `${plural(belowPar, "line")} short, all on open orders` : "Every line is above par"} />
|
||||
) : (
|
||||
<>
|
||||
<MSection label="To order" right={suppliers.length === 1 ? suppliers[0] : plural(suppliers.length, "supplier")} />
|
||||
{needs.map((n) => (
|
||||
<MRow key={n.key} mark={n.oh <= 0 ? "accent" : "ink"} title={n.name}
|
||||
sub={`${n.oh}/${n.par} · ${n.supplier}`}
|
||||
right={<MStepper n={q(n.key, n.qty)} onChange={(v) => setQty((x) => ({ ...x, [n.key]: v }))} label={n.name} />} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{covered > 0 && needs.length > 0 && (
|
||||
<>
|
||||
<MSection label="Already on order" right={covered} />
|
||||
<MRow mark="mute" chev href="/m/stock?seg=order" title="On order" right={covered} />
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
{needs.length > 0 && (
|
||||
<MBar label={busy ? "Raising…" : "Raise the draft"} small={plural(total, "item")} onClick={raise}
|
||||
disabled={busy || total === 0} offReason={total === 0 ? "Set a quantity on at least one line" : undefined} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
/* Pick an approved staff request: tick or scan each garment in the bag, then hand it over. The first
|
||||
pick moves the request to "picking" through request.pick, so the desktop queue shows it as being
|
||||
picked. Ticks live in the basket (this phone only) until the hand-over is signed. */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { wasSpent } from "@/components/SignFlow";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { bcParse, key, onhand } from "@/lib/compute";
|
||||
import { scanReject } from "@/lib/feedback";
|
||||
import { relativeDay } from "@/lib/today";
|
||||
import { PICK_STATUSES } from "@/lib/workcount";
|
||||
import { useRequests } from "@/components/requests/RequestList";
|
||||
import { useBasket } from "@/components/MBasket";
|
||||
import MScan from "@/components/MScan";
|
||||
import {
|
||||
MBar, MBody, MButton, MEmpty, MError, MHead, MKick, MPickRow, MRow, MRule, MSection, MTop, useScanFlash, useToast,
|
||||
} from "@/components/m";
|
||||
import { garmentSize, locMap, personMeta, segment, shelfOf } from "@/components/m/work/util";
|
||||
|
||||
export default function PickRequest() {
|
||||
const id = segment(useParams<{ id: string }>().id);
|
||||
const { s, mutate } = useSnap();
|
||||
const { L, byId, staffById } = useDerived();
|
||||
const { data, error, reload } = useRequests();
|
||||
const basket = useBasket();
|
||||
const toast = useToast();
|
||||
const flash = useScanFlash();
|
||||
const [err, setErr] = useState("");
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const pickFired = useRef(false);
|
||||
const locs = useMemo(() => locMap(s), [s]);
|
||||
|
||||
const r = data?.requests.find((x) => x.id === id);
|
||||
const lines = useMemo(() => r?.bag ?? [], [r]);
|
||||
const stored = basket.picked(id);
|
||||
const router = useRouter();
|
||||
const gone = !!data && (!r || !PICK_STATUSES.has(r.status) || !lines.length);
|
||||
const spent = gone && wasSpent(`/m/request/${id}`);
|
||||
// Back from this request's own Done screen: the list is spent, so carry on to the queue.
|
||||
useEffect(() => { if (spent) router.replace("/m/work?seg=picks"); }, [spent, router]);
|
||||
// A ready bag was packed at the counter already: every line starts ticked.
|
||||
const got = useCallback((lineId: string, q: number) => {
|
||||
const v = stored[lineId];
|
||||
if (v === undefined) return r?.status === "ready" ? q : 0;
|
||||
return Math.max(0, Math.min(q, v));
|
||||
}, [stored, r]);
|
||||
const total = lines.reduce((t, l) => t + l.qty, 0);
|
||||
const picked = lines.reduce((t, l) => t + got(l.id, l.qty), 0);
|
||||
|
||||
const setLine = (lineId: string, n: number) => {
|
||||
const prev = stored;
|
||||
basket.setPicked(id, { ...prev, [lineId]: n });
|
||||
return () => basket.setPicked(id, prev);
|
||||
};
|
||||
|
||||
/** The first garment picked off an approved request tells the queue it is being picked. */
|
||||
const firstPick = async (undo: () => void) => {
|
||||
if (!r || r.status !== "accepted" || pickFired.current) return;
|
||||
pickFired.current = true;
|
||||
const res = await mutate("request.pick", { id: r.id });
|
||||
if (!res.ok) { pickFired.current = false; undo(); setErr(res.error); void reload(); return; }
|
||||
void reload();
|
||||
};
|
||||
|
||||
const toggle = (lineId: string, q: number) => {
|
||||
const cur = got(lineId, q);
|
||||
const undo = setLine(lineId, cur >= q ? 0 : q);
|
||||
if (cur < q) void firstPick(undo);
|
||||
};
|
||||
|
||||
const onScan = (raw: string) => {
|
||||
setScanning(false);
|
||||
const hit = bcParse(s, raw);
|
||||
if (!hit) { scanReject(); toast("That code isn’t a garment"); return; }
|
||||
const it = byId[hit.itemId];
|
||||
const name = garmentSize(it, "Garment", it?.sizes[hit.si] ?? hit.si);
|
||||
const match = lines.filter((l) => l.itemId === hit.itemId && l.si === hit.si);
|
||||
if (!match.length) { scanReject(); toast(`${name} isn’t on this request`); return; }
|
||||
const next = match.find((l) => got(l.id, l.qty) < l.qty);
|
||||
if (!next) { toast("Everything is picked"); return; }
|
||||
const n = got(next.id, next.qty) + 1;
|
||||
flash("Garment", name, () => { const undo = setLine(next.id, n); void firstPick(undo); });
|
||||
};
|
||||
|
||||
const shell = (body: React.ReactNode, bar?: React.ReactNode) => (
|
||||
<>
|
||||
<MTop title="Pick request" back />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody pad>{body}</MBody>
|
||||
{bar}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!data) {
|
||||
return shell(error
|
||||
? <><MError msg="Requests couldn’t be loaded." /><MButton small label="Try again" onClick={() => void reload()} /></>
|
||||
: <MKick>Loading</MKick>);
|
||||
}
|
||||
if (!r || !PICK_STATUSES.has(r.status) || !lines.length) {
|
||||
if (spent) return shell(<MKick>Loading</MKick>);
|
||||
return shell(<><MEmpty title="That request has moved on" /><MButton label="Back to Work" href="/m/work?seg=picks" /></>);
|
||||
}
|
||||
|
||||
const st = staffById[r.staffId];
|
||||
const when = relativeDay(r.decidedAt, s);
|
||||
return (
|
||||
<>
|
||||
{shell(
|
||||
<>
|
||||
<MHead name={r.staffName} meta={personMeta(s, st, r.ward)} />
|
||||
<MButton tone="ink" icon="scan" label="Scan to pick"
|
||||
onClick={() => (picked >= total ? toast("Everything is picked") : setScanning(true))} />
|
||||
<MSection label="Pick list" right={`${picked} of ${total}`} />
|
||||
{lines.map((l, i) => {
|
||||
const n = got(l.id, l.qty);
|
||||
const onShelf = Math.max(0, onhand(s, L, key(l.itemId, l.si)));
|
||||
const sub = [shelfOf(s, locs, l.itemId, l.si), `${onShelf} on shelf`, r.status === "ready" && i === 0 && r.collectCode ? `code ${r.collectCode}` : ""]
|
||||
.filter(Boolean).join(" · ");
|
||||
return (
|
||||
<MPickRow key={l.id} done={n >= l.qty} onToggle={() => toggle(l.id, l.qty)}
|
||||
title={garmentSize(byId[l.itemId], l.item, l.size)} sub={sub} right={`${n}/${l.qty}`} />
|
||||
);
|
||||
})}
|
||||
<MSection label="Approved" />
|
||||
<MRow mark="ok" title={r.managerName || "Manager"} sub={when ? `Approved ${when}` : "Approved"} />
|
||||
</>,
|
||||
<MBar label="Hand over" small={`${picked} of ${total} picked`} disabled={picked < total}
|
||||
offReason="Pick every line first" href={`/m/request/${r.id}/sign`} />,
|
||||
)}
|
||||
{scanning && <MScan title="Scan to pick" onHit={onScan} onClose={() => setScanning(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
/* Sign for a picked request, then Done. A request still "accepted" is moved to "picking" first
|
||||
(the queue only lets a picked request be collected); request.collected writes the issue rows, the
|
||||
signature and, when asked, the slip to the wearer's staff app. */
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { key } from "@/lib/compute";
|
||||
import { plural } from "@/lib/today";
|
||||
import { PICK_STATUSES } from "@/lib/workcount";
|
||||
import { useRequests, type RequestRow } from "@/components/requests/RequestList";
|
||||
import { useBasket } from "@/components/MBasket";
|
||||
import SignFlow from "@/components/SignFlow";
|
||||
import { MBody, MButton, MEmpty, MError, MHead, MKick, MRule, MTop } from "@/components/m";
|
||||
import { garmentSize, personMeta, segment } from "@/components/m/work/util";
|
||||
|
||||
export default function RequestSign() {
|
||||
const id = segment(useParams<{ id: string }>().id);
|
||||
const { s, mutate } = useSnap();
|
||||
const { byId, staffById } = useDerived();
|
||||
const { data, error, reload } = useRequests();
|
||||
const basket = useBasket();
|
||||
// Held once loaded: the hand-over refreshes the queue, and the Done screen must not turn into
|
||||
// "moved on" when this request leaves it.
|
||||
const [held, setHeld] = useState<{ r: RequestRow; ready: boolean } | null>(null);
|
||||
const [seen, setSeen] = useState(false);
|
||||
|
||||
const stored = basket.picked(id);
|
||||
useEffect(() => {
|
||||
if (held || !data) return;
|
||||
const r = data.requests.find((x) => x.id === id);
|
||||
if (r && PICK_STATUSES.has(r.status) && r.bag.length) {
|
||||
const ready = r.status === "ready" || r.bag.every((l) => (stored[l.id] ?? 0) >= l.qty);
|
||||
setHeld({ r, ready });
|
||||
}
|
||||
setSeen(true);
|
||||
}, [data, id, held, stored]);
|
||||
|
||||
const shell = (body: React.ReactNode) => (
|
||||
<>
|
||||
<MTop title="Sign" back />
|
||||
<MRule />
|
||||
<MBody pad>{body}</MBody>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!held) {
|
||||
if (!data && error) return shell(<><MError msg="Requests couldn’t be loaded." /><MButton small label="Try again" onClick={() => void reload()} /></>);
|
||||
if (!data || !seen) return shell(<MKick>Loading</MKick>);
|
||||
return shell(<><MEmpty title="That request has moved on" /><MButton label="Back to Work" href="/m/work?seg=picks" /></>);
|
||||
}
|
||||
const { r } = held;
|
||||
if (!held.ready) {
|
||||
return shell(<><MEmpty title="Pick every line first" /><MButton label="Back to the pick list" href={`/m/request/${r.id}`} /></>);
|
||||
}
|
||||
|
||||
const st = staffById[r.staffId];
|
||||
const total = r.bag.reduce((t, l) => t + l.qty, 0);
|
||||
return (
|
||||
<SignFlow
|
||||
kind="request"
|
||||
head={<MHead name={r.staffName} meta={personMeta(s, st, r.ward)} />}
|
||||
lines={r.bag.map((l) => ({ key: l.id, name: garmentSize(byId[l.itemId], l.item, l.size), qty: l.qty }))}
|
||||
signerName={r.staffName}
|
||||
slip={{ available: !!st?.selfEmail }}
|
||||
barLabel={`Hand over ${plural(total, "item")}`}
|
||||
spentHref={`/m/request/${r.id}`}
|
||||
commit={async ({ sigId, slip }) => {
|
||||
// Accepted cannot go straight to collected. If the pick screen already moved it, this
|
||||
// refusal is expected and collected below answers for the real state.
|
||||
if (r.status === "accepted") await mutate("request.pick", { id: r.id });
|
||||
const c = await mutate("request.collected", { id: r.id, sigId, slip });
|
||||
if (!c.ok) { void reload(); return { ok: false, error: c.error }; }
|
||||
basket.clear("picked", r.id);
|
||||
void reload();
|
||||
return {
|
||||
ok: true,
|
||||
done: {
|
||||
head: "Request handed over",
|
||||
sub: `${r.staffName} · ${plural(total, "item")}${slip ? " · slip sent" : ""}`,
|
||||
shelfKeys: r.bag.map((l) => key(l.itemId, l.si)),
|
||||
next: "work",
|
||||
},
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
/* Sign a ward round: every bag waiting for that ward (lib/today roundSheet, the same set the Rounds
|
||||
segment counted), one signature for the lot through pickup.deliverWard, then Done. */
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { plural, roundSheet } from "@/lib/today";
|
||||
import { takePhoto, uploadPhoto } from "@/lib/photo";
|
||||
import SignFlow from "@/components/SignFlow";
|
||||
import { MBody, MButton, MEmpty, MField, MHead, MRule, MTop, inputStyle, useToast } from "@/components/m";
|
||||
import { roundLines, segment } from "@/components/m/work/util";
|
||||
|
||||
const BATCH = 40; // pickup.deliverWard takes at most 40 bags a call
|
||||
|
||||
export default function RoundSign() {
|
||||
const ward = segment(useParams<{ ward: string }>().ward);
|
||||
const { s, mutate } = useSnap();
|
||||
const { byId, staffById } = useDerived();
|
||||
const toast = useToast();
|
||||
// Held from the first render: delivering removes the ward from the sheet, and the Done screen
|
||||
// must not turn into an empty round.
|
||||
const [rows] = useState(() => roundSheet(s, byId, staffById).find((w) => w.ward === ward)?.rows ?? []);
|
||||
const lines = useMemo(() => roundLines(rows, byId), [rows, byId]);
|
||||
const [name, setName] = useState("");
|
||||
const [proofId, setProofId] = useState<string | null>(null);
|
||||
const [photoBusy, setPhotoBusy] = useState(false);
|
||||
const delivered = useRef(new Set<string>());
|
||||
|
||||
if (!rows.length) {
|
||||
return (
|
||||
<>
|
||||
<MTop title="Sign" back />
|
||||
<MRule />
|
||||
<MBody pad>
|
||||
<MEmpty title="Nothing to deliver" />
|
||||
<MButton label="Back to Work" href="/m/work?seg=rounds" />
|
||||
</MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const items = lines.reduce((t, l) => t + l.qty, 0);
|
||||
const people = new Set(rows.map((r) => r.p.staffId)).size;
|
||||
const who = name.trim();
|
||||
|
||||
const takeProof = async () => {
|
||||
if (photoBusy) return;
|
||||
const data = await takePhoto();
|
||||
if (!data) return;
|
||||
setPhotoBusy(true);
|
||||
const up = await uploadPhoto(mutate, "proof", data);
|
||||
setPhotoBusy(false);
|
||||
if ("error" in up) { toast(up.error); return; }
|
||||
setProofId(up.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<SignFlow
|
||||
kind="round"
|
||||
head={<MHead name={`${ward} round`} meta={`${plural(people, "person", "people")} · ${plural(items, "item")}`} />}
|
||||
lines={lines.map((l) => ({ key: l.key, name: l.name, qty: l.qty }))}
|
||||
signerName={who || `${ward}, nurse in charge`}
|
||||
extra={
|
||||
<>
|
||||
<MField label="Received by">
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} autoComplete="off" maxLength={120} style={inputStyle} />
|
||||
</MField>
|
||||
<MButton small label={photoBusy ? "Saving photo…" : proofId ? "Photo attached · retake" : "Add handover photo"} onClick={() => void takeProof()} disabled={photoBusy} />
|
||||
</>
|
||||
}
|
||||
barLabel="Delivered"
|
||||
commit={async ({ sigId }) => {
|
||||
// A ward with more bags than one call takes goes in batches; a retry skips what already went.
|
||||
const ids = rows.map((r) => r.p.id).filter((x) => !delivered.current.has(x));
|
||||
for (let i = 0; i < ids.length; i += BATCH) {
|
||||
const part = ids.slice(i, i + BATCH);
|
||||
const r = await mutate("pickup.deliverWard", { ids: part, deliveredTo: who, sigId, proofId });
|
||||
if (!r.ok) return { ok: false, error: delivered.current.size ? `${delivered.current.size} of ${rows.length} bags recorded. ${r.error}` : r.error };
|
||||
part.forEach((x) => delivered.current.add(x));
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
done: { head: `${ward} delivered`, sub: `${plural(items, "item")} · signed by ${who || "the nurse in charge"}`, shelfKeys: [], next: "work" },
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* Delivery rounds are Work › Rounds; each ward signs at /m/round/[ward]. */
|
||||
export default function Rounds() {
|
||||
redirect("/m/work?seg=rounds");
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
/* The Scan tab: one camera for a staff badge, a garment or a shelf label, routed by lib/scanroute.
|
||||
Under the Android shell MLKit's preview shows through the transparent viewfinder; in a browser a
|
||||
<video> fills it through lib/webscan. */
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { isNative, startLive } from "@/lib/nativescan";
|
||||
import { startWebLive } from "@/lib/webscan";
|
||||
import { scanReject, scanTick } from "@/lib/feedback";
|
||||
import { track } from "@/lib/analytics";
|
||||
import { useKeepAwake } from "@/lib/wakelock";
|
||||
import { SCAN_KIND_LABEL, resolveScan, type ScanHit } from "@/lib/scanroute";
|
||||
import { AC3, GROUND, INK, MBody, MButton, MONO, MPill, MRule, MSection, MTabs, MTop, useScanFlash } from "@/components/m";
|
||||
|
||||
type Recent = { kind: string; label: string; code: string; href: string };
|
||||
/* What this session resolved, newest first. Module memory: it survives moving between tabs and is
|
||||
gone when the app is closed. */
|
||||
let RECENT: Recent[] = [];
|
||||
|
||||
const OFF_GREY = "#b5b1af";
|
||||
const EDGE = "#57534f";
|
||||
const DEBOUNCE = 1500;
|
||||
|
||||
export default function ScanTab() {
|
||||
const { s, isAdmin } = useSnap();
|
||||
const router = useRouter();
|
||||
const flash = useScanFlash();
|
||||
const vid = useRef<HTMLVideoElement | null>(null);
|
||||
const [armed, setArmed] = useState(true);
|
||||
const [miss, setMiss] = useState<Extract<ScanHit, { kind: "unknown" | "inactive" }> | null>(null);
|
||||
const [caption, setCaption] = useState<{ msg: string; denied: boolean } | null>(null);
|
||||
const [recent, setRecent] = useState<Recent[]>([]);
|
||||
const [typed, setTyped] = useState("");
|
||||
const native = isNative();
|
||||
const [nativeOk, setNativeOk] = useState(native);
|
||||
const [nativeLive, setNativeLive] = useState(false);
|
||||
useKeepAwake(true);
|
||||
useEffect(() => { setRecent(RECENT); }, []);
|
||||
|
||||
const handle = useCallback((raw: string) => {
|
||||
const hit = resolveScan(s, raw);
|
||||
if (hit.kind === "unknown" || hit.kind === "inactive") {
|
||||
scanReject();
|
||||
if (hit.kind === "unknown") track("scan_miss", { kind: "unknown" });
|
||||
setArmed(false);
|
||||
setMiss(hit);
|
||||
return;
|
||||
}
|
||||
setArmed(false);
|
||||
const row: Recent = { kind: SCAN_KIND_LABEL[hit.kind], label: hit.label, code: raw.trim(), href: hit.href };
|
||||
RECENT = [row, ...RECENT.filter((r) => r.href !== row.href)].slice(0, 4);
|
||||
setRecent(RECENT);
|
||||
flash(row.kind, row.label, () => router.push(hit.href));
|
||||
}, [s, flash, router]);
|
||||
const handleRef = useRef(handle); handleRef.current = handle;
|
||||
|
||||
// Native: MLKit live session behind the WebView while armed.
|
||||
useEffect(() => {
|
||||
if (!native || !nativeOk || !armed) return;
|
||||
let session: { stop: () => Promise<void> } | null = null;
|
||||
let cancelled = false;
|
||||
let lastRaw = "", lastT = 0;
|
||||
(async () => {
|
||||
const r = await startLive((raw) => {
|
||||
if (raw === lastRaw && Date.now() - lastT < DEBOUNCE) return;
|
||||
lastRaw = raw; lastT = Date.now();
|
||||
scanTick();
|
||||
handleRef.current(raw);
|
||||
});
|
||||
if (cancelled) { await r.stop(); return; }
|
||||
if (r.error === "native-unavailable") { setNativeOk(false); return; }
|
||||
if (r.error) { setCaption({ msg: r.error, denied: true }); return; }
|
||||
session = r;
|
||||
setNativeLive(true);
|
||||
})();
|
||||
return () => { cancelled = true; setNativeLive(false); if (session) void session.stop(); };
|
||||
}, [native, nativeOk, armed]);
|
||||
|
||||
// Browser: the shared BarcodeDetector loop into the viewfinder's video.
|
||||
useEffect(() => {
|
||||
if (nativeOk || !armed || !vid.current) return;
|
||||
const session = startWebLive(vid.current, (raw) => handleRef.current(raw), DEBOUNCE, {
|
||||
onStatus: (st) => setCaption(st.state === "ready" ? null : { msg: st.msg, denied: st.state === "denied" }),
|
||||
});
|
||||
return () => session.stop();
|
||||
}, [nativeOk, armed]);
|
||||
|
||||
useEffect(() => {
|
||||
track("scan_opened", { mode: "tab", engine: nativeOk ? "mlkit" : native ? "mlkit-missing" : "browser" });
|
||||
}, [native, nativeOk]);
|
||||
|
||||
const again = () => { setMiss(null); setCaption(null); setArmed(true); };
|
||||
const find = () => { const c = typed.trim(); if (!c) return; setTyped(""); handle(c); };
|
||||
|
||||
return (
|
||||
<div className="tcx-scanui" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<MTop title="Scan" />
|
||||
<MRule />
|
||||
<MBody dark pad className="tcx-scanbody">
|
||||
<div style={{ position: "relative", height: 300, margin: "-16px -16px 0", overflow: "hidden", background: nativeLive ? "transparent" : "radial-gradient(ellipse at 50% 45%, #3a3735 0, #151413 70%)" }} className="tcx-camwin">
|
||||
{!nativeOk && <video ref={vid} autoPlay playsInline muted aria-hidden="true" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover" }} />}
|
||||
<div aria-hidden="true" style={{ position: "absolute", left: 16, top: 14, display: "flex", gap: 8, alignItems: "center", color: "#fff", fontSize: 12, fontWeight: 800, letterSpacing: "0.1em" }}>
|
||||
<i style={{ width: 10, height: 10, background: armed ? "var(--color-accent)" : EDGE }} />LIVE
|
||||
</div>
|
||||
<div aria-hidden="true" style={{ position: "absolute", left: "14%", right: "14%", top: "22%", bottom: "26%" }}>
|
||||
{([["left", "top"], ["right", "top"], ["left", "bottom"], ["right", "bottom"]] as const).map(([x, y]) => (
|
||||
<i key={x + y} style={{ position: "absolute", width: 34, height: 34, [x]: 0, [y]: 0, borderStyle: "solid", borderColor: "#fff", borderWidth: 0, [`border${y === "top" ? "Top" : "Bottom"}Width`]: 4, [`border${x === "left" ? "Left" : "Right"}Width`]: 4 }} />
|
||||
))}
|
||||
</div>
|
||||
{armed && !caption?.denied && <div aria-hidden="true" className="tcx-vf-laser" style={{ position: "absolute", left: "8%", right: "8%", height: 2, background: "var(--color-accent)", boxShadow: "0 0 12px var(--color-accent)" }} />}
|
||||
<div role={caption?.denied ? "alert" : undefined} style={{ position: "absolute", left: 12, right: 12, bottom: 16, textAlign: "center", color: caption ? AC3 : "#fff", fontWeight: 800, fontSize: 14, letterSpacing: caption ? 0 : "0.06em", textTransform: caption ? "none" : "uppercase" }}>
|
||||
{caption ? caption.msg : "Badge, garment or shelf label"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: INK, margin: "0 -16px -16px", padding: "0 16px 16px", minHeight: "calc(100% - 268px)" }}>
|
||||
<div style={{ height: 1 }} />
|
||||
{miss ? (
|
||||
<div style={{ paddingTop: 18 }}>
|
||||
<MPill tone="accent">{miss.kind === "unknown" ? "Not found" : "Inactive"}</MPill>
|
||||
<div style={{ fontFamily: miss.kind === "unknown" ? MONO : undefined, fontSize: 22, fontWeight: miss.kind === "unknown" ? 500 : 800, marginTop: 10, wordBreak: "break-all" }}>
|
||||
{miss.kind === "unknown" ? miss.code || "—" : miss.label}
|
||||
</div>
|
||||
<div style={{ display: "grid", marginTop: 4 }}>
|
||||
{miss.kind === "unknown" && (isAdmin
|
||||
? <DarkButton label="Bind to a garment" href={`/m/catalogue?bind=${encodeURIComponent(miss.code)}`} />
|
||||
: <div style={{ fontSize: 13, color: OFF_GREY, marginTop: 10 }}>Only an admin can bind a code</div>)}
|
||||
{miss.kind === "inactive" && <DarkButton label="Open their record" href={`/m/person/${miss.staffId}`} />}
|
||||
<DarkButton label="Scan again" onClick={again} />
|
||||
</div>
|
||||
</div>
|
||||
) : recent.length > 0 && (
|
||||
<>
|
||||
<MSection label="Recent scans" right={recent.length} />
|
||||
<div style={{ display: "grid", gap: 8, marginTop: 10 }}>
|
||||
{recent.map((r) => (
|
||||
<button key={r.href} type="button" onClick={() => router.push(r.href)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 12, minHeight: 56, border: "2px solid " + EDGE, background: "transparent", color: GROUND, padding: "0 12px", textAlign: "left", fontFamily: "inherit", fontSize: 15, fontWeight: 700, cursor: "pointer" }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: OFF_GREY, width: 62, flex: "none" }}>{r.kind}</span>
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{r.label}</span>
|
||||
<span style={{ fontFamily: MONO, fontSize: 12, color: OFF_GREY, fontWeight: 500 }}>{r.code}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); find(); }} style={{ marginTop: 22 }}>
|
||||
<label htmlFor="scan-typed" style={{ display: "block", fontSize: 12, fontWeight: 800, letterSpacing: "0.09em", textTransform: "uppercase", color: OFF_GREY, marginBottom: 6 }}>Type a code</label>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input id="scan-typed" value={typed} onChange={(e) => setTyped(e.target.value)} autoComplete="off" autoCapitalize="none" autoCorrect="off" spellCheck={false}
|
||||
style={{ flex: 1, minWidth: 0, height: 52, border: "2px solid " + EDGE, background: "transparent", color: GROUND, fontFamily: MONO, fontSize: 16, padding: "0 14px", borderRadius: 0 }} />
|
||||
<button type="submit" style={{ minWidth: 72, minHeight: 52, border: "2px solid " + GROUND, background: GROUND, color: INK, fontFamily: "inherit", fontSize: 14, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase", cursor: "pointer" }}>Find</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</MBody>
|
||||
<MTabs active="scan" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** MButton's outline, drawn in ground on the ink body. */
|
||||
function DarkButton({ label, href, onClick }: { label: string; href?: string; onClick?: () => void }) {
|
||||
return (
|
||||
<div style={{ "--color-text": "var(--color-bg)" } as React.CSSProperties}>
|
||||
<MButton label={label} href={href} onClick={onClick} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* Search became the People tab (stock search is on the Stock tab). The query comes along. */
|
||||
export default async function Search({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const q = await searchParams;
|
||||
const term = typeof q.q === "string" ? q.q.trim().slice(0, 80) : "";
|
||||
redirect(term ? `/m/people?q=${encodeURIComponent(term)}` : "/m/people");
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
/* Settings, behind the gear on Today: this phone, the account, help and signing out. Everything else
|
||||
about the facility lives on the desktop, so there is one place a setting can be wrong. */
|
||||
import { DELETE_ACCOUNT_URL, PRIVACY_URL, TERMS_URL } from "@/lib/links";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { clearAllCounts } from "@/lib/opencount";
|
||||
import { printState, type PrintState } from "@/lib/nativeprint";
|
||||
import { clearBasket } from "@/components/MBasket";
|
||||
import { MBody, MButton, MError, MONO, MPill, MRow, MRule, MSection, MStepper, MSwitchRow, MTop } from "@/components/m";
|
||||
|
||||
const BEEP_KEY = "tc.beep";
|
||||
|
||||
export default function MSettings() {
|
||||
const { s, isAdmin, mutate, busy } = useSnap();
|
||||
const [beep, setBeep] = useState(true);
|
||||
const [gate, setGate] = useState(s.settings.varianceReason);
|
||||
const [err, setErr] = useState("");
|
||||
const [printer, setPrinter] = useState<PrintState | null>(null);
|
||||
const [host, setHost] = useState("");
|
||||
const [leaving, setLeaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try { setBeep(localStorage.getItem(BEEP_KEY) !== "0"); } catch { /* blocked store */ }
|
||||
setHost(window.location.host);
|
||||
let live = true;
|
||||
printState().then((p) => { if (live) setPrinter(p); }).catch(() => { /* stays blank */ });
|
||||
return () => { live = false; };
|
||||
}, []);
|
||||
|
||||
const toggleBeep = () => {
|
||||
const next = !beep;
|
||||
setBeep(next);
|
||||
try { localStorage.setItem(BEEP_KEY, next ? "1" : "0"); } catch { /* blocked store */ }
|
||||
};
|
||||
|
||||
const saveGate = async (n: number) => {
|
||||
setGate(n);
|
||||
const r = await mutate("settings.update", { varianceReason: n });
|
||||
if (!r.ok) { setErr(r.error); setGate(s.settings.varianceReason); }
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
// Part-counted shelves and the half-built basket are this person's, on a phone that is passed
|
||||
// around a linen room. Cleared before the logout POST so a failed request still leaves the
|
||||
// device tidy. A full navigation afterwards: the session cookie is gone and every page behind
|
||||
// it is server-rendered. /m/login, not /auth, keeps them inside the app.
|
||||
setLeaving(true);
|
||||
clearAllCounts(s.session.userId);
|
||||
clearBasket(s.session.userId);
|
||||
try { await fetch("/api/auth/logout", { method: "POST" }); } catch { /* the cookie check on /m/login decides */ }
|
||||
window.location.replace("/m/login");
|
||||
};
|
||||
|
||||
const roleWord = s.session.role === "Admin" ? "Admin" : "Issuer";
|
||||
const tone = printer?.state === "ready" ? "ok" : printer?.state === "unavailable" ? "accent" : "mute";
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Settings" back />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody pad>
|
||||
<MSection label="This phone" />
|
||||
<MSwitchRow title="Beep and buzz on scan" on={beep} onToggle={toggleBeep} />
|
||||
<MRow title="Shelf printer" sub={printer?.sub ?? ""} right={printer ? <MPill tone={tone}>{printer.label}</MPill> : null} />
|
||||
<MRow title="Server" sub={host} />
|
||||
|
||||
<MSection label="Account" />
|
||||
<MRow title={s.settings.facility} sub={s.session.title || roleWord} />
|
||||
<MRow title="Count gap needing a reason"
|
||||
right={isAdmin
|
||||
? <MStepper n={gate} onChange={saveGate} min={1} max={99} label="gap" />
|
||||
: <span style={{ fontFamily: MONO, fontWeight: 600, fontSize: 14 }}>{gate}</span>} />
|
||||
<MRow title="Help" chev href="/app/help/apps/counter-app" />
|
||||
|
||||
{/* Deleting an account has to be reachable from inside the app (a Play requirement), and this
|
||||
is the only place the signed-in counter app names the policies. They go to the site's own
|
||||
pages: there is one account-deletion flow, and it is the one on the website. */}
|
||||
{(DELETE_ACCOUNT_URL || PRIVACY_URL || TERMS_URL) && <MSection label="Your account and your data" />}
|
||||
{DELETE_ACCOUNT_URL && <MRow href={DELETE_ACCOUNT_URL} external title="Delete your account" />}
|
||||
{PRIVACY_URL && <MRow href={PRIVACY_URL} external title="Privacy policy" />}
|
||||
{TERMS_URL && <MRow href={TERMS_URL} external title="Terms of use" />}
|
||||
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<MButton label="Sign out" onClick={signOut} disabled={busy || leaving} />
|
||||
</div>
|
||||
</MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/* The old signed-in welcome. Today is the first screen now; a brand-new facility gets its banner. */
|
||||
export default async function SignedIn({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
|
||||
const q = await searchParams;
|
||||
redirect(q.new === "1" ? "/m?flash=created" : "/m");
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
/* The Stock tab: on hand against par, worst first, in three segments, and the stock jobs underneath. */
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { atReorderVariants } from "@/lib/portalcounts";
|
||||
import { bcBound, flaggedNeeds, label, locMap, locTrail, onhand, reorderAt, touched } from "@/lib/compute";
|
||||
import { MBody, MButton, MEmpty, MRow, MRule, MSearch, MSection, MSeg, MTabs, MTop, MTopCount } from "@/components/m";
|
||||
import { PAGE, byShortfall, countRows, placedOnOrder } from "@/components/m/stock/stockdata";
|
||||
|
||||
type Seg = "below" | "order" | "all";
|
||||
const SEGS: Seg[] = ["below", "order", "all"];
|
||||
|
||||
export default function MStock() {
|
||||
const { s } = useSnap();
|
||||
const { L, byId, variants } = useDerived();
|
||||
const router = useRouter();
|
||||
const sp = useSearchParams();
|
||||
const [seg, setSeg] = useState<Seg>(() => {
|
||||
const v = sp.get("seg") as Seg | null;
|
||||
return v && SEGS.includes(v) ? v : "below";
|
||||
});
|
||||
const [q, setQ] = useState("");
|
||||
const [shown, setShown] = useState(PAGE);
|
||||
|
||||
const data = useMemo(() => {
|
||||
const locs = locMap(s);
|
||||
const onOrd = placedOnOrder(s, byId);
|
||||
const below = new Set(atReorderVariants(s, L).map((v) => v.key));
|
||||
const all = variants.filter((v) => touched(s, L, v.key)).map((v) => ({
|
||||
key: v.key, itemId: v.itemId, si: v.si, size: v.size,
|
||||
name: label(v.item), oh: onhand(s, L, v.key), par: reorderAt(s, v.key),
|
||||
code: bcBound(s, v.item, v.si), shelf: locTrail(locs, s.placed[v.key], 0),
|
||||
below: below.has(v.key), onOrder: (onOrd[v.key] || 0) > 0,
|
||||
}));
|
||||
const order = variants.filter((v) => (onOrd[v.key] || 0) > 0).length;
|
||||
return { all, below: below.size, order, drafts: flaggedNeeds(s, L, byId).length };
|
||||
}, [s, L, byId, variants]);
|
||||
|
||||
const shelves = useMemo(() => countRows(s, L, variants).length, [s, L, variants]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
return data.all
|
||||
.filter((r) => seg === "all" || (seg === "below" ? r.below : r.onOrder))
|
||||
.filter((r) => !needle || `${r.name} ${r.size} ${r.code} ${r.shelf}`.toLowerCase().includes(needle))
|
||||
.sort(byShortfall);
|
||||
}, [data, seg, q]);
|
||||
|
||||
const pick = (k: Seg) => {
|
||||
setSeg(k); setShown(PAGE);
|
||||
router.replace(`/m/stock?seg=${k}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Stock" right={<MTopCount>{data.below} short</MTopCount>} />
|
||||
<MRule />
|
||||
<MBody pad>
|
||||
<MSearch value={q} onChange={(v) => { setQ(v); setShown(PAGE); }} placeholder="Garment or size" label="Filter stock" scanHref="/m/scan" scanLabel="Scan a garment" />
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<MSeg label="Stock lines" value={seg} onPick={pick} options={[
|
||||
{ key: "below", label: "Below par", n: data.below },
|
||||
{ key: "order", label: "On order", n: data.order },
|
||||
{ key: "all", label: "All", n: data.all.length },
|
||||
]} />
|
||||
</div>
|
||||
|
||||
{rows.length === 0
|
||||
? <MEmpty title="Nothing here" sub="Try All, or clear the search." />
|
||||
: rows.slice(0, shown).map((r) => (
|
||||
<MRow key={r.key} href={`/m/line/${encodeURIComponent(r.itemId)}/${r.si}`}
|
||||
mark={r.oh <= 0 ? "accent" : r.oh <= r.par ? "ink" : "mute"}
|
||||
title={`${r.name} ${r.size}`} right={`${r.oh}/${r.par}`} />
|
||||
))}
|
||||
{rows.length > shown && (
|
||||
<>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 10 }}>Showing {shown} of {rows.length}</div>
|
||||
<MButton small label="Show more" onClick={() => setShown((n) => n + PAGE)} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<MSection label="Also in Stock" />
|
||||
<MRow mark="mute" chev href="/m/reorder" title={`Draft order · ${data.drafts} line${data.drafts === 1 ? "" : "s"}`} />
|
||||
<MRow mark="mute" chev href="/m/variance" title="Variance over time" />
|
||||
<MRow mark="mute" chev href="/m/catalogue" title="Catalogue" />
|
||||
<MRow mark="mute" chev href="/m/count" title={`Count a shelf · ${shelves} shel${shelves === 1 ? "f" : "ves"}`} />
|
||||
</MBody>
|
||||
<MTabs active="stock" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
/* Variance over time: the pattern, not the number. A line short at every count is a different
|
||||
problem from one short once, so the chart is the point and the latest gap is the figure. */
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { itemMap, label, monthLabel } from "@/lib/compute";
|
||||
import { INK, MBody, MButton, MEmpty, MKick, MONO, MRule, MSection, MTop, MTopCount } from "@/components/m";
|
||||
|
||||
const MAX_BAR = 56;
|
||||
const PAGE = 40;
|
||||
|
||||
export default function MVarianceOverTime() {
|
||||
const { s } = useSnap();
|
||||
const [shown, setShown] = useState(PAGE);
|
||||
|
||||
const { rows, counts } = useMemo(() => {
|
||||
const byId = itemMap(s);
|
||||
// Oldest first, shelf counts only, the last six.
|
||||
const takes = s.stocktakes.filter((t) => t.mode !== "preloved").slice(0, 6).reverse();
|
||||
const seen: Record<string, { name: string; gaps: (number | null)[] }> = {};
|
||||
takes.forEach((t, col) => {
|
||||
for (const l of t.lines) {
|
||||
const k = `${l.itemId}:${l.si}`;
|
||||
const it = byId[l.itemId];
|
||||
if (!it) continue;
|
||||
(seen[k] ||= { name: `${label(it)} ${it.sizes[l.si] ?? l.si}`, gaps: takes.map(() => null) });
|
||||
seen[k].gaps[col] = l.counted - l.sys;
|
||||
}
|
||||
});
|
||||
const out = Object.entries(seen).map(([k, v]) => {
|
||||
const known = v.gaps.filter((g): g is number => g !== null);
|
||||
const latest = [...v.gaps].reverse().find((g) => g !== null) ?? 0;
|
||||
const shortEvery = known.length >= 2 && known.every((g) => g < 0);
|
||||
const worsening = known.length >= 3 && known[known.length - 1] < known[0] && known[known.length - 1] < 0;
|
||||
const verdict = known.every((g) => g === 0) ? "Steady"
|
||||
: shortEvery ? `Short at every count since ${monthLabel(takes[v.gaps.findIndex((g) => g !== null)]?.date.slice(0, 7) || "", { month: "long" })}`
|
||||
: worsening ? "Drifting short"
|
||||
: latest === 0 ? "Back in line" : "Occasional gap";
|
||||
return { key: k, name: v.name, gaps: v.gaps, latest, verdict, persistent: shortEvery || worsening };
|
||||
});
|
||||
// Worst pattern first: persistent problems, then the biggest gap.
|
||||
out.sort((a, b) => Number(b.persistent) - Number(a.persistent) || a.latest - b.latest || a.name.localeCompare(b.name));
|
||||
return { rows: out, counts: takes };
|
||||
}, [s]);
|
||||
|
||||
const peak = Math.max(1, ...rows.flatMap((r) => r.gaps.map((g) => Math.abs(g ?? 0))));
|
||||
const month = (d: string, m: "short" | "long") => monthLabel(d.slice(0, 7), { month: m });
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Variance over time" back right={<MTopCount>{counts.length} count{counts.length === 1 ? "" : "s"}</MTopCount>} />
|
||||
<MRule />
|
||||
<MBody pad>
|
||||
{counts.length > 0 && <MKick>Gap at each count since {month(counts[0].date, "long")}</MKick>}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<MEmpty title="No counts to compare yet" sub="Commit two shelf counts to see a pattern." />
|
||||
) : (
|
||||
<>
|
||||
<MSection label="Lines" right={rows.length} />
|
||||
{rows.slice(0, shown).map((r) => (
|
||||
<div key={r.key} style={{ padding: "12px 0", borderBottom: "1px solid var(--color-divider)", boxShadow: r.persistent ? "inset 4px 0 0 var(--color-accent)" : undefined, paddingLeft: r.persistent ? 12 : 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700 }}>{r.name}</div>
|
||||
<div style={{ fontSize: 13, color: r.persistent ? "var(--color-accent-700)" : "var(--color-neutral-600)", fontWeight: r.persistent ? 800 : 400, marginTop: 1 }}>{r.verdict}</div>
|
||||
</div>
|
||||
<div style={{ fontFamily: MONO, fontWeight: 600, fontSize: 14, color: r.latest === 0 ? INK : "var(--color-accent-700)", whiteSpace: "nowrap" }}>
|
||||
{r.latest === 0 ? "Match" : r.latest > 0 ? `+${r.latest}` : `−${-r.latest}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="tcx-chart" style={{ marginTop: 12 }} role="img"
|
||||
aria-label={`Gap at each count: ${r.gaps.map((g, i) => `${counts[i] ? month(counts[i].date, "short") : ""} ${g === null ? "not counted" : g}`).join(", ")}`}>
|
||||
{r.gaps.map((g, i) => {
|
||||
const mag = Math.abs(g ?? 0);
|
||||
const h = g === null ? 4 : Math.max(4, Math.round((mag / peak) * MAX_BAR));
|
||||
const col = g === null ? "var(--color-neutral-300)" : mag === 0 ? "var(--color-divider)" : mag >= 3 ? "var(--color-accent)" : INK;
|
||||
return <i key={i} style={{ height: h, background: col }} />;
|
||||
})}
|
||||
</div>
|
||||
<div aria-hidden="true" style={{ display: "flex", gap: 5, marginTop: 6 }}>
|
||||
{counts.map((t, i) => (
|
||||
<span key={i} style={{ flex: 1, textAlign: "center", fontSize: 12, fontWeight: 800, letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--color-neutral-600)" }}>
|
||||
{month(t.date, "short")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{rows.length > shown && (
|
||||
<>
|
||||
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 10 }}>Showing {shown} of {rows.length}</div>
|
||||
<MButton small label="Show more" onClick={() => setShown((n) => n + PAGE)} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
/* The Work tab: the queue at the window. Approved requests to pick, deliveries to receive, pickups to
|
||||
call and ward rounds, counted by the same useWorkCount() as the tab badge and Today. */
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { collectRows, plural, receiveRows, relativeDay, roundSheet } from "@/lib/today";
|
||||
import { PICK_STATUSES, useWorkCount } from "@/lib/workcount";
|
||||
import { useRequests } from "@/components/requests/RequestList";
|
||||
import {
|
||||
MBody, MButton, MCard, MCardChip, MEmpty, MError, MKick, MPill, MRow, MRule, MSection, MSeg, MTabs, MTop, MTopCount, useToast,
|
||||
} from "@/components/m";
|
||||
import { openReceivable, orderWhen, outstandingTotal } from "@/components/m/work/util";
|
||||
|
||||
type Seg = "picks" | "in" | "pickups" | "rounds";
|
||||
const SEGS: readonly Seg[] = ["picks", "in", "pickups", "rounds"];
|
||||
const asSeg = (v: string | null): Seg => (SEGS.includes(v as Seg) ? (v as Seg) : "picks");
|
||||
|
||||
export default function Work() {
|
||||
const sp = useSearchParams();
|
||||
const [seg, setSeg] = useState<Seg>(() => asSeg(sp.get("seg")));
|
||||
const w = useWorkCount();
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
// A link from Today (?seg=in) lands on its segment even when Work is already mounted.
|
||||
useEffect(() => { setSeg(asSeg(sp.get("seg"))); }, [sp]);
|
||||
|
||||
const pick = (k: Seg) => {
|
||||
setSeg(k);
|
||||
setErr("");
|
||||
// Shallow: the segment is only a view of the snapshot already on the phone. null state, so Next's
|
||||
// router takes the new URL as canonical and a later refresh doesn't put the old segment back.
|
||||
try { window.history.replaceState(null, "", `/m/work?seg=${k}`); } catch { /* not fatal */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Work" right={<MTopCount>{w.total} open</MTopCount>} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody pad>
|
||||
<MSeg label="Work" value={seg} onPick={pick} options={[
|
||||
{ key: "picks", label: "Picks", n: w.picks },
|
||||
{ key: "in", label: "In", n: w.in },
|
||||
{ key: "pickups", label: "Pickups", n: w.pickups },
|
||||
{ key: "rounds", label: "Rounds", n: w.rounds },
|
||||
]} />
|
||||
{seg === "picks" && <Picks />}
|
||||
{seg === "in" && <Inbound />}
|
||||
{seg === "pickups" && <Pickups onError={setErr} />}
|
||||
{seg === "rounds" && <Rounds />}
|
||||
</MBody>
|
||||
<MTabs active="work" workBadge={w.total} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const line = (text: string) => <span style={{ display: "block" }}>{text}</span>;
|
||||
|
||||
function Picks() {
|
||||
const { s } = useSnap();
|
||||
const { staffById } = useDerived();
|
||||
const { data, error, reload } = useRequests();
|
||||
|
||||
const rows = useMemo(() => (data?.requests ?? [])
|
||||
.filter((r) => PICK_STATUSES.has(r.status))
|
||||
.sort((a, b) => (a.decidedAt || a.createdAt).localeCompare(b.decidedAt || b.createdAt)), [data]);
|
||||
|
||||
if (!data) {
|
||||
if (error) return (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<MError msg="Requests couldn’t be loaded." />
|
||||
<MButton small label="Try again" onClick={() => void reload()} />
|
||||
</div>
|
||||
);
|
||||
return <div style={{ marginTop: 14 }}><MKick>Loading</MKick></div>;
|
||||
}
|
||||
if (!rows.length) return <MEmpty title="Nothing to pick" sub="Approved requests land here." />;
|
||||
|
||||
return (
|
||||
<>
|
||||
{rows.map((r) => {
|
||||
const st = staffById[r.staffId];
|
||||
const what = r.bag.map((l) => `${l.item.toLowerCase()} ${l.size}`).join(", ");
|
||||
const when = relativeDay(r.decidedAt, s);
|
||||
const approved = [when ? `Approved ${when}` : "Approved", r.managerName].filter(Boolean).join(" · ")
|
||||
+ (r.status === "ready" && r.collectCode ? ` · ready, code ${r.collectCode}` : "");
|
||||
return (
|
||||
<MRow key={r.id} mark="ink" chev href={`/m/request/${r.id}`} title={r.staffName}
|
||||
sub={<>{line([st?.group || r.ward, what].filter(Boolean).join(" · "))}{line(approved)}</>}
|
||||
right={r.garments} />
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Inbound() {
|
||||
const { s } = useSnap();
|
||||
const { byId, staffById } = useDerived();
|
||||
const soon = useMemo(() => receiveRows(s, staffById), [s, staffById]);
|
||||
const later = useMemo(() => {
|
||||
const ids = new Set(soon.map((r) => r.o.id));
|
||||
return openReceivable(s, byId).filter((o) => !ids.has(o.id));
|
||||
}, [s, byId, soon]);
|
||||
|
||||
const row = (o: (typeof later)[number], forName: string) => (
|
||||
<MRow key={o.id} mark="ink" chev href={`/m/receive/${o.id}`} title={`${o.code} · ${o.supplier || "Supplier"}`}
|
||||
sub={[orderWhen(s, o), plural(o.lines.length, "line"), forName ? `for ${forName}` : ""].filter(Boolean).join(" · ")}
|
||||
right={outstandingTotal(o, byId)} />
|
||||
);
|
||||
|
||||
if (!soon.length && !later.length) return <MEmpty title="Nothing to receive" sub="Open orders show here when they arrive." />;
|
||||
return (
|
||||
<>
|
||||
{soon.map((r) => row(r.o, r.forName))}
|
||||
{later.length > 0 && (
|
||||
<>
|
||||
<MSection label="Later" right={later.length} />
|
||||
{later.map((o) => row(o, o.staffId ? (staffById[o.staffId] ? `${staffById[o.staffId].first} ${staffById[o.staffId].last}`.trim() : "") : ""))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Pickups({ onError }: { onError: (msg: string) => void }) {
|
||||
const { s, mutate } = useSnap();
|
||||
const { byId, staffById } = useDerived();
|
||||
const toast = useToast();
|
||||
const rows = useMemo(() => collectRows(s, byId, staffById, { includeRound: true }), [s, byId, staffById]);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const run = async (id: string, op: string, payload: Record<string, unknown>, then?: () => void) => {
|
||||
if (busy) return;
|
||||
setBusy(id);
|
||||
onError("");
|
||||
const r = await mutate(op, payload);
|
||||
setBusy(null);
|
||||
if (!r.ok) { onError(r.error); return; }
|
||||
then?.();
|
||||
};
|
||||
|
||||
if (!rows.length) return <MEmpty title="No one waiting" sub="Orders in for a person show here when they arrive." />;
|
||||
return (
|
||||
<>
|
||||
{rows.map((r) => {
|
||||
const items = r.lines.map((l) => `${l.garment} ${l.size} ×${l.qty}`).join(", ");
|
||||
const id = r.p.id;
|
||||
return (
|
||||
<MCard key={id} title={r.name} sub={[r.st?.dept, items].filter(Boolean).join(" · ")}
|
||||
pill={<MPill tone={r.late ? "accent" : "mute"}>{r.days}d</MPill>}
|
||||
actions={
|
||||
<>
|
||||
<MCardChip label="Call" href={r.tel || undefined} disabled={!r.tel} />
|
||||
<MCardChip label={r.p.contacted ? "Contacted" : "Contacted?"} on={r.p.contacted} disabled={busy === id}
|
||||
onClick={() => void run(id, "pickup.contacted", { id, contacted: !r.p.contacted })} />
|
||||
<MCardChip label="Collected" disabled={busy === id}
|
||||
onClick={() => void run(id, "pickup.pickedUp", { id }, () => toast(`${r.name} collected ${items}`))} />
|
||||
</>
|
||||
} />
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Rounds() {
|
||||
const { s } = useSnap();
|
||||
const { byId, staffById } = useDerived();
|
||||
const wards = useMemo(() => roundSheet(s, byId, staffById), [s, byId, staffById]);
|
||||
if (!wards.length) return <MEmpty title="No rounds today" sub="Delivered rounds are in History on the desktop." />;
|
||||
return (
|
||||
<>
|
||||
{wards.map((w) => {
|
||||
const people = new Set(w.rows.map((r) => r.p.staffId)).size;
|
||||
return (
|
||||
<MRow key={w.ward} mark="ink" chev href={`/m/round/${encodeURIComponent(w.ward)}`} title={w.ward}
|
||||
sub={`${plural(people, "person", "people")} · ${plural(w.rows.length, "bag")}`} right={w.garments} />
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user