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).
90 lines
4.5 KiB
TypeScript
90 lines
4.5 KiB
TypeScript
"use client";
|
|
/* One field over people and stock together — at the counter you don't know in advance which one
|
|
you're after. People take an accent marker, stock lines an ink one. */
|
|
import { useMemo, useState } from "react";
|
|
import { useDerived, useSnap } from "@/lib/client";
|
|
import { bcBound, ccOf, label, locMap, locTrail, onhand, touched, reorderAt, staffName, variantName } from "@/lib/compute";
|
|
import MScan from "@/components/MScan";
|
|
import { INK, IconScan, MBody, MEmpty, MNav, MRow, MRule, MSection, MTop, inputStyle } from "@/components/m";
|
|
|
|
export default function MSearch() {
|
|
const { s } = useSnap();
|
|
const { L, byId, variants } = useDerived();
|
|
const [q, setQ] = useState("");
|
|
const [scan, setScan] = useState(false);
|
|
const locs = useMemo(() => locMap(s), [s]);
|
|
|
|
const { people, lines } = useMemo(() => {
|
|
const needle = q.trim().toLowerCase();
|
|
const stockRows = variants.filter((v) => touched(s, L, v.key)).map((v) => ({
|
|
// Only the bound supplier code: it is what someone reads off a label and types in here.
|
|
...v, oh: onhand(s, L, v.key), par: reorderAt(s, v.key), code: bcBound(s, v.item, v.si),
|
|
where: locTrail(locs, s.placed[v.key], 0), name: `${variantName(byId[v.itemId], v.size)}`,
|
|
}));
|
|
if (!needle) {
|
|
const seen: Record<string, string> = {};
|
|
for (const i of s.issues) seen[i.staffId] = i.date > (seen[i.staffId] || "") ? i.date : seen[i.staffId];
|
|
return {
|
|
people: s.staff.filter((x) => !x.inactive).sort((a, b) => (seen[b.id] || "").localeCompare(seen[a.id] || "")).slice(0, 5),
|
|
lines: stockRows.filter((r) => r.oh <= r.par).slice(0, 5),
|
|
};
|
|
}
|
|
return {
|
|
people: s.staff.filter((x) => !x.inactive && `${x.first} ${x.last} ${x.num} ${x.dept}`.toLowerCase().includes(needle)).slice(0, 12),
|
|
lines: stockRows.filter((r) => `${r.name} ${r.code} ${r.where}`.toLowerCase().includes(needle)).slice(0, 20),
|
|
};
|
|
}, [s, L, variants, byId, q, locs]);
|
|
|
|
const empty = q.trim() && people.length === 0 && lines.length === 0;
|
|
|
|
return (
|
|
<>
|
|
<MTop title="Search" right={q.trim() ? `${people.length + lines.length} result${people.length + lines.length === 1 ? "" : "s"}` : undefined} />
|
|
<MRule />
|
|
<MBody>
|
|
<div style={{ padding: 16, borderBottom: "2px solid " + INK, display: "flex", gap: 8 }}>
|
|
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Name, staff number, garment or code" autoFocus
|
|
aria-label="Search people and stock" style={{ ...inputStyle, flex: 1 }} />
|
|
<button onClick={() => setScan(true)} aria-label="Scan a barcode"
|
|
style={{ width: 56, minHeight: 48, border: "2px solid " + INK, background: "var(--color-accent)", color: "#fff", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer" }}>
|
|
<IconScan />
|
|
</button>
|
|
</div>
|
|
|
|
{empty ? (
|
|
<MEmpty title="Nothing matches that" sub="Try a surname, a staff number, or part of a garment name. Scanning a label finds it straight away." />
|
|
) : (
|
|
<>
|
|
{people.length > 0 && (
|
|
<>
|
|
<MSection label={q.trim() ? "People" : "Recently served"} />
|
|
{people.map((st) => (
|
|
<MRow key={st.id} href={`/m/person/${st.id}`} mark="accent" title={staffName(st)}
|
|
sub={[st.num, st.dept || st.group, ccOf(s, st) && `CC ${ccOf(s, st)}`].filter(Boolean).join(" · ")} />
|
|
))}
|
|
</>
|
|
)}
|
|
{lines.length > 0 && (
|
|
<>
|
|
<MSection label={q.trim() ? "Stock" : "At or below par"} right="On hand / par" />
|
|
{lines.map((r) => (
|
|
<MRow key={r.key} href="/m/stock" mark="ink" attention={r.oh <= r.par} title={r.name}
|
|
sub={[r.code || "No barcode bound", r.where].filter(Boolean).join(" · ")}
|
|
right={<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 19, fontVariantNumeric: "tabular-nums", color: r.oh <= r.par ? "var(--color-accent-700)" : INK }}>{r.oh}<span style={{ color: "var(--color-neutral-700)" }}>/{r.par}</span></span>} />
|
|
))}
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</MBody>
|
|
<MNav />
|
|
{scan && <MScan title="Scan to find" onHit={(raw) => {
|
|
const k = s.barcodes[raw.trim()];
|
|
const v = k ? variants.find((x) => x.key === k) : undefined;
|
|
setQ(v ? `${variantName(byId[v.itemId], v.size)}` : raw.trim());
|
|
setScan(false);
|
|
}} onClose={() => setScan(false)} />}
|
|
</>
|
|
);
|
|
}
|