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