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,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";
|
||||
Reference in New Issue
Block a user