Files
threadcount-community/app/m/(app)/count/[id]/variance/page.tsx
T
ThreadCount 822c0b7c0b 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 8685140 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-13 11:09:20 +10:00

172 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/* Variance — only the lines that don't match, what happens when the count commits, and the commit.
A gap at or over the facility's threshold has to carry a reason before anything is filed. */
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useDerived, useSnap } from "@/lib/client";
import { UNPLACED, bcBound, formatInZone, locMap, locSubtree, locUnder, onhand, reorderAt, touched, variantName } from "@/lib/compute";
import { INK, MBar, MBody, MEmpty, MError, MRule, MTop, MPanel, MInkLink } from "@/components/m";
import { clearCount, readCount } from "@/lib/opencount";
const REASONS = ["At laundry", "Condemned", "Missing", "Other"];
export default function MVariance() {
const { s, mutate, busy } = useSnap();
const { L, byId, variants } = useDerived();
const router = useRouter();
const locationId = String(useParams().id || "");
const locs = useMemo(() => locMap(s), [s]);
const locName = locationId === UNPLACED ? "Not on a shelf" : locs[locationId]?.name || "Location";
const lines = useMemo(() => {
const sub = locationId === UNPLACED ? null : locSubtree(s, locationId);
// Exactly the set the counting screen lists, and it has to stay the same test. A placed size
// counts even with no history, and so does an unplaced size with a barcode bound to it —
// somebody stood at the counter and scanned that label onto that size, which is why the
// counting screen lets you count it. Leave that arm off here and a size counted on the phone
// has no row on this screen and no line in the payload: committing files a stocktake without
// it, the garments found on the trolley are never counted in, and clearCount() then wipes the
// tally that was the only record they had been found.
return variants
.filter((v) => (sub ? sub.has(s.placed[v.key] || "") : !s.placed[v.key] && (touched(s, L, v.key) || !!bcBound(s, v.item, v.si))))
.map((v) => ({ ...v, expected: onhand(s, L, v.key), code: bcBound(s, v.item, v.si), where: locUnder(locs, s.placed[v.key], locationId) }));
}, [s, L, variants, locationId, locs]);
const [counted, setCounted] = useState<Record<string, number> | null>(null);
const [savedAt, setSavedAt] = useState("");
const [reason, setReason] = useState<Record<string, string>>({});
const [accepted, setAccepted] = useState<Record<string, boolean>>({});
const [err, setErr] = useState("");
// The tally belongs to the person who took it, so it is read back under their own key — the
// counting screen writes it under theirs. When it was taken matters as much as what it says:
// a count resumed the next morning has had a night of issuing against it, and the screen should
// say when it was last touched rather than present a stale tally as if it were fresh.
const me = s.session.userId;
useEffect(() => {
const open = readCount(me, locationId);
setCounted(open?.n ?? {});
setSavedAt(open?.savedAt ?? "");
}, [me, locationId]);
const gate = Math.max(1, s.settings.varianceReason);
const off = useMemo(() => (counted ? lines.filter((l) => (counted[l.key] ?? 0) !== l.expected) : []), [counted, lines]);
const totalCounted = counted ? lines.reduce((t, l) => t + (counted[l.key] ?? 0), 0) : 0;
const totalExpected = lines.reduce((t, l) => t + l.expected, 0);
const needsReason = off.filter((l) => Math.abs((counted?.[l.key] ?? 0) - l.expected) >= gate && !reason[l.key]);
// What the shelf will look like once this commits — not what the commit does. Committing a count
// writes stock adjustments and the stocktake itself and nothing else; the reorder draft is a
// separate, deliberate step on Reorder, which is where the quantities can still be changed
// before anything goes to a supplier.
const willReorder = useMemo(() => {
if (!counted) return { lines: 0, units: 0 };
let n = 0, units = 0;
for (const l of lines) {
const after = counted[l.key] ?? 0;
const par = reorderAt(s, l.key);
if (after <= par && l.expected > par) { n++; units += Math.max(0, par * 2 - after); }
}
return { lines: n, units };
}, [counted, lines, s]);
const commit = useCallback(async () => {
if (!counted) return;
if (needsReason.length) { setErr(`A gap of ${gate} or more needs a reason — ${needsReason.length} line${needsReason.length === 1 ? "" : "s"} still to go.`); return; }
const payload = lines.map((l) => ({ itemId: l.itemId, si: l.si, counted: counted[l.key] ?? 0, reason: reason[l.key] || "" }));
const r = await mutate("stocktake.apply", { lines: payload, mode: "shelf", locationId: locationId === UNPLACED ? "" : locationId });
if (!r.ok) { setErr(r.error); return; }
clearCount(me, locationId);
// A count that leaves lines below par hands straight over to Reorder. Nothing is drafted by
// the commit itself, and a count that ends on the home screen is a count whose shortfall
// nobody ever goes back for.
router.push(willReorder.lines > 0 ? "/m/reorder" : "/m?counted=1");
}, [counted, lines, reason, needsReason.length, gate, mutate, me, locationId, router, willReorder.lines]);
if (!counted) return (<><MTop title="Variance" back /><MRule /><MBody /></>);
return (
<>
<MTop title="Variance" back />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody>
<div style={{ padding: "20px 16px 22px", borderBottom: "2px solid " + INK }}>
<h2 style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 30, letterSpacing: "-0.03em", lineHeight: 1.05 }}>
{off.length === 0 ? "Everything matches" : `${off.length} line${off.length === 1 ? "" : "s"} dont match`}
</h2>
<p style={{ fontSize: 14, color: "var(--color-neutral-700)", marginTop: 8 }}>{locName} · counted {totalCounted} of {totalExpected} expected</p>
{savedAt && (
<p style={{ fontSize: 13, color: "var(--color-neutral-700)", marginTop: 4 }}>
Tallied {formatInZone(savedAt, s.tz, { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" })}.
{" "}Anything issued since then is already off the expected figure.
</p>
)}
</div>
{off.length === 0 ? (
<MEmpty title="No gaps to explain" sub="Every line came out at what the system expected. Commit the count to file it against this shelf." />
) : off.map((l) => {
const n = counted[l.key] ?? 0;
const d = n - l.expected;
const big = Math.abs(d) >= gate;
return (
<div key={l.key} style={{ padding: 16, background: "#fff", borderBottom: "1px solid var(--color-divider)" }}>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, letterSpacing: "-0.02em" }}>{variantName(byId[l.itemId], l.size)}</div>
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 4 }}>{[l.code || (l.item.sku ? `SKU ${l.item.sku}` : "No barcode bound"), l.where].filter(Boolean).join(" · ")}</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 24, letterSpacing: "-0.02em", color: "var(--color-accent-700)", fontVariantNumeric: "tabular-nums" }}>{d > 0 ? `+${d}` : `${-d}`}</div>
<div style={{ fontSize: 13, color: "var(--color-neutral-600)", marginTop: 2 }}>{n} of {l.expected}</div>
</div>
</div>
<div style={{ display: "flex", gap: 8, marginTop: 14 }}>
<button onClick={() => router.push(`/m/count/${locationId}`)}
style={{ flex: 1, minHeight: 44, border: "2px solid " + INK, background: "transparent", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>Recount</button>
<button onClick={() => setAccepted((a) => ({ ...a, [l.key]: !a[l.key] }))} aria-pressed={!!accepted[l.key]}
style={{ flex: 1, minHeight: 44, border: "2px solid " + INK, background: accepted[l.key] ? INK : "transparent", color: accepted[l.key] ? "var(--color-bg)" : INK, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer" }}>
{accepted[l.key] ? "Accepted" : "Accept"}
</button>
</div>
{big && (
<div style={{ marginTop: 14, padding: 14, background: "var(--color-bg)" }}>
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-accent-700)" }}>
A gap of {gate} or more needs a reason
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 10 }}>
{REASONS.map((r) => {
const on = reason[l.key] === r;
return (
<button key={r} onClick={() => setReason((x) => ({ ...x, [l.key]: on ? "" : r }))} aria-pressed={on}
style={{ minHeight: 44, padding: "0 14px", border: "2px solid " + INK, background: on ? INK : "transparent", color: on ? "var(--color-bg)" : INK, fontSize: 13, fontWeight: 700, cursor: "pointer" }}>{r}</button>
);
})}
</div>
</div>
)}
</div>
);
})}
<div style={{ padding: 16 }}>
<MPanel kicker="After this count">
<p style={{ fontSize: 14, lineHeight: 1.6, margin: 0 }}>
{willReorder.lines === 0
? "Nothing falls below par when this commits, so there is nothing to reorder."
: `${willReorder.lines} line${willReorder.lines === 1 ? "" : "s"} will be below par once this count commits — about ${willReorder.units} item${willReorder.units === 1 ? "" : "s"} to order. Committing orders nothing on its own: it takes you to Reorder, where you raise the draft.`}
</p>
<p style={{ fontSize: 13, lineHeight: 1.6, marginTop: 10, color: "var(--color-neutral-400)" }}>Nothing is sent to a supplier without approval.</p>
{willReorder.lines > 0 && <div style={{ marginTop: 14 }}><MInkLink label="Reorder" href="/m/reorder" /></div>}
</MPanel>
</div>
</MBody>
<MBar label={busy ? "Committing…" : "Commit count"} glyph="check" onClick={commit} disabled={busy || needsReason.length > 0}
sub={needsReason.length ? `${needsReason.length} gap${needsReason.length === 1 ? " still needs" : "s still need"} a reason` : undefined} />
</>
);
}