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,65 @@
|
||||
"use client";
|
||||
/* Count a shelf: every location holding garments, plus the unplaced bucket, each shelf with a
|
||||
shelf-label print chip. Mounted by app/m/(app)/count/page.tsx (a Stock detail, no tab bar). */
|
||||
import { useMemo } from "react";
|
||||
import { useDerived, useSnap } from "@/lib/client";
|
||||
import { UNPLACED, daysBetween } from "@/lib/compute";
|
||||
import { printShelfLabel } from "@/lib/nativeprint";
|
||||
import { isNative } from "@/lib/nativescan";
|
||||
import { INK, MBody, MEmpty, MRow, MRule, MSection, MTop, MTopCount, useToast } from "@/components/m";
|
||||
import { countRows } from "@/components/m/stock/stockdata";
|
||||
|
||||
function lastCounted(last: string | undefined, today: string): string {
|
||||
if (!last) return "Never counted";
|
||||
const n = daysBetween(last, today);
|
||||
if (n <= 0) return "Counted today";
|
||||
if (n === 1) return "Counted yesterday";
|
||||
return `Counted ${n} days ago`;
|
||||
}
|
||||
|
||||
export default function CountList() {
|
||||
const { s } = useSnap();
|
||||
const { L, variants } = useDerived();
|
||||
const toast = useToast();
|
||||
const rows = useMemo(() => countRows(s, L, variants), [s, L, variants]);
|
||||
|
||||
const print = async (id: string, name: string) => {
|
||||
const r = await printShelfLabel({ locationId: id, copies: 1 });
|
||||
if (!r.ok) toast(r.error);
|
||||
else if (isNative()) toast(`${name} label sent to the shelf printer`);
|
||||
};
|
||||
|
||||
const chip: React.CSSProperties = {
|
||||
minHeight: 44, minWidth: 64, padding: "0 12px", border: "2px solid " + INK, background: "transparent", color: INK,
|
||||
fontFamily: "inherit", fontSize: 13, fontWeight: 800, letterSpacing: "0.05em", textTransform: "uppercase", cursor: "pointer", flex: "none",
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Count a shelf" back right={<MTopCount>{rows.length}</MTopCount>} />
|
||||
<MRule />
|
||||
<MBody pad>
|
||||
{rows.length === 0 ? (
|
||||
<MEmpty title="Nothing to count yet" sub="Place garments on a shelf in the portal." />
|
||||
) : (
|
||||
<>
|
||||
<MSection label="Shelves" right="lines · units" />
|
||||
{rows.map((r) => (
|
||||
<div key={r.id} style={{ display: "flex", alignItems: "center", gap: 10, borderBottom: "1px solid var(--color-divider)" }}>
|
||||
<div style={{ flex: 1, minWidth: 0, marginBottom: -1 }}>
|
||||
<MRow href={`/m/count/${r.id}`} mark={r.id === UNPLACED ? "mute" : "ink"}
|
||||
title={<span style={{ paddingLeft: r.depth * 14 }}>{r.name}</span>}
|
||||
sub={<span style={{ paddingLeft: r.depth * 14 }}>{lastCounted(r.last, s.today)}</span>}
|
||||
right={`${r.lines} · ${r.units}`} />
|
||||
</div>
|
||||
{r.id !== UNPLACED && (
|
||||
<button type="button" style={chip} onClick={() => print(r.id, r.name)} aria-label={`Print a shelf label for ${r.name}`}>Label</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</MBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
/* The print sheet over a stock line: copies, why, and the bar that sends it. A native shell hands the
|
||||
label page to Android printing; a browser opens the printable label page. */
|
||||
import { useEffect, useState } from "react";
|
||||
import { printLabels, printState, type PrintState } from "@/lib/nativeprint";
|
||||
import { MBar, MChipRow, MKick, MRow, MSheet, MStepper, useToast } from "@/components/m";
|
||||
|
||||
const REASONS = ["Torn", "Faded", "New shelf"] as const;
|
||||
type Reason = (typeof REASONS)[number];
|
||||
|
||||
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? "" : "s"}`;
|
||||
|
||||
export default function PrintSheet({ open, onClose, code, title }: { open: boolean; onClose: () => void; code: string; title: string }) {
|
||||
const toast = useToast();
|
||||
const [copies, setCopies] = useState(1);
|
||||
const [reason, setReason] = useState<Reason>("Torn");
|
||||
const [state, setState] = useState<PrintState | null>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCopies(1); setReason("Torn");
|
||||
let live = true;
|
||||
printState().then((p) => { if (live) setState(p); });
|
||||
return () => { live = false; };
|
||||
}, [open]);
|
||||
|
||||
const go = async () => {
|
||||
if (sending) return;
|
||||
setSending(true);
|
||||
const r = await printLabels({ code, copies, reason });
|
||||
setSending(false);
|
||||
if (!r.ok) { toast(r.error); return; }
|
||||
onClose();
|
||||
toast(state?.state === "browser" ? `${plural(copies, "label")} opened to print` : `${plural(copies, "label")} sent to the shelf printer`);
|
||||
};
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} labelId="tc-print-title"
|
||||
bar={<MBar label={sending ? "Sending…" : `Print ${plural(copies, "label")}`} onClick={go} disabled={sending} />}>
|
||||
<MKick>Shelf printer{state ? ` · ${state.label}` : ""}</MKick>
|
||||
<h2 id="tc-print-title" style={{ fontSize: 20, fontWeight: 900, margin: "2px 0 0", lineHeight: 1.15 }}>{title}</h2>
|
||||
<MRow title="Copies" sub="Barcode, garment, size, shelf"
|
||||
right={<MStepper n={copies} onChange={setCopies} min={1} max={20} label="copies" />} />
|
||||
<MChipRow label="Why it is being reprinted" value={reason} onPick={setReason}
|
||||
options={REASONS.map((r) => ({ value: r, label: r }))} />
|
||||
</MSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/* Figures the Stock tab and a stock line's page both read, so the two never disagree. Pure. */
|
||||
import { UNPLACED, bcBound, daysBetween, isOpen, key, locSubtree, locTree, onhand, touched, type Item, type Ledger, type Snapshot, type Variant } from "@/lib/compute";
|
||||
|
||||
export type CountRow = { id: string; name: string; depth: number; lines: number; units: number; last: string | undefined };
|
||||
/** The shelves Count a shelf lists: every location holding placed garments, then the unplaced bucket
|
||||
* (the same test the counting and variance screens use for it). */
|
||||
export function countRows(s: Snapshot, L: Ledger, variants: Variant[]): CountRow[] {
|
||||
const lastAt: Record<string, string> = {};
|
||||
for (const t of s.stocktakes) if (t.mode !== "preloved" && t.locationId && (!lastAt[t.locationId] || t.date > lastAt[t.locationId])) lastAt[t.locationId] = t.date;
|
||||
const out: CountRow[] = locTree(s).map(({ loc, depth }) => {
|
||||
const sub = locSubtree(s, loc.id);
|
||||
const mine = variants.filter((v) => sub.has(s.placed[v.key] || ""));
|
||||
return { id: loc.id, name: loc.name, depth, lines: mine.length, units: mine.reduce((t, v) => t + onhand(s, L, v.key), 0), last: lastAt[loc.id] };
|
||||
}).filter((r) => r.lines > 0);
|
||||
const loose = variants.filter((v) => !s.placed[v.key] && (touched(s, L, v.key) || !!bcBound(s, v.item, v.si)));
|
||||
if (loose.length) out.push({ id: UNPLACED, name: "Not on a shelf yet", depth: 0, lines: loose.length, units: loose.reduce((t, v) => t + onhand(s, L, v.key), 0), last: undefined });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Outstanding units per variant key on open orders placed with a supplier (drafts excluded) —
|
||||
* the same set onOrderText() reads for one size. */
|
||||
export function placedOnOrder(s: Snapshot, byId: Record<string, Item>): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const o of s.orders) {
|
||||
if (!isOpen(o) || o.status === "Draft") continue;
|
||||
const got: Record<string, number> = {};
|
||||
for (const rc of o.receipts) for (const l of rc.lines) got[l.itemId + "|" + l.size] = (got[l.itemId + "|" + l.size] || 0) + l.qty;
|
||||
for (const l of o.lines) {
|
||||
const g = l.itemId + "|" + l.size;
|
||||
const done = Math.min(l.qty, got[g] || 0);
|
||||
got[g] = (got[g] || 0) - done;
|
||||
if (l.qty <= done) continue;
|
||||
const it = byId[l.itemId];
|
||||
if (!it) continue;
|
||||
const si = it.sizes.findIndex((z) => String(z) === l.size);
|
||||
if (si < 0) continue;
|
||||
const k = key(it.id, si);
|
||||
out[k] = (out[k] || 0) + l.qty - done;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** "Today", "Yesterday", "n days ago" or "Never", from the newest shelf count holding this line. */
|
||||
export function lastCountedText(s: Snapshot, itemId: string, si: number): string {
|
||||
let last = "";
|
||||
for (const t of s.stocktakes) {
|
||||
if (t.mode === "preloved" || (last && t.date <= last)) continue;
|
||||
if (t.lines.some((l) => l.itemId === itemId && l.si === si)) last = t.date;
|
||||
}
|
||||
if (!last) return "Never";
|
||||
const n = daysBetween(last, s.today);
|
||||
return n <= 0 ? "Today" : n === 1 ? "Yesterday" : `${n} days ago`;
|
||||
}
|
||||
|
||||
/** Units of one size out in staff hands now: issued, not returned, not handed in. */
|
||||
export function heldByStaff(s: Snapshot, itemId: string, si: number): number {
|
||||
let n = 0;
|
||||
for (const i of s.issues) if (i.itemId === itemId && i.si === si && !i.returned && !i.handedIn) n += i.qty;
|
||||
return n;
|
||||
}
|
||||
|
||||
type Ranked = { oh: number; par: number; name: string };
|
||||
/** Worst first: on hand as a share of par, par 0 last. */
|
||||
export function byShortfall(a: Ranked, b: Ranked): number {
|
||||
const ra = a.par > 0 ? a.oh / a.par : Infinity, rb = b.par > 0 ? b.oh / b.par : Infinity;
|
||||
if (ra !== rb) return ra < rb ? -1 : 1;
|
||||
return a.oh - b.oh || a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
/** Rows drawn per page on the long lists; the rest arrive with "Show more", never silently cut. */
|
||||
export const PAGE = 200;
|
||||
Reference in New Issue
Block a user