ThreadCount Community edition

Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
ThreadCount
2026-09-13 08:54:35 +10:00
commit 344b1701dd
505 changed files with 56231 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
"use client";
/* The call list — longest wait first, because thats the one somebody is annoyed about. */
import { useMemo, useState } from "react";
import { useSnap } from "@/lib/client";
import { daysBetween, itemMap, label, staffMap, staffName } from "@/lib/compute";
import { INK, MBody, MEmpty, MError, MNav, MRow, MRule, MSection, MTop } from "@/components/m";
export default function MPickups() {
const { s, mutate, busy } = useSnap();
const [err, setErr] = useState("");
const rows = useMemo(() => {
const staffById = staffMap(s);
const byId = itemMap(s);
return s.pickups
.filter((p) => !p.pickedUp)
.map((p) => ({
...p,
who: staffById[p.staffId],
phone: staffById[p.staffId]?.phone || "",
days: daysBetween(p.received, s.today),
what: p.lines.map((l) => `${label(byId[l.itemId])} · ${l.size}${l.qty > 1 ? ` ×${l.qty}` : ""}`).join(", "),
}))
.sort((a, b) => b.days - a.days);
}, [s]);
const act = async (op: string, id: string) => {
const r = await mutate(op, { id });
if (!r.ok) setErr(r.error);
};
return (
<>
<MTop title="Pickups" back right={rows.length ? `${rows.length} waiting` : undefined} />
<MRule />
<MError msg={err} onDismiss={() => setErr("")} />
<MBody>
{rows.length === 0 ? (
<MEmpty title="Nobody is waiting" sub="Everything that has come in has been collected." />
) : (
<>
<MSection label="Waiting" right="Longest first" />
{rows.map((p) => (
<div key={p.id} style={{ padding: 16, background: p.days > 10 ? "#fff" : "var(--color-bg)", borderBottom: "1px solid var(--color-divider)", display: "flex", gap: 12 }}>
<span aria-hidden="true" style={{ width: 4, flex: "0 0 4px", background: p.days > 10 ? "var(--color-accent)" : INK, alignSelf: "stretch" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
<span style={{ flex: 1, fontSize: 16.5, fontWeight: 700 }}>{staffName(p.who, "Staff member")}</span>
<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 15, color: p.days > 10 ? "var(--color-accent-700)" : "var(--color-neutral-600)", fontVariantNumeric: "tabular-nums" }}>{p.days}d</span>
</div>
<div style={{ fontSize: 13.5, color: "var(--color-neutral-600)", marginTop: 3 }}>{p.what} · {p.orderCode}</div>
<div style={{ display: "flex", gap: 8, marginTop: 12, flexWrap: "wrap" }}>
{p.phone && (
<a href={`tel:${p.phone.replace(/\s+/g, "")}`}
style={{ minHeight: 44, display: "inline-flex", alignItems: "center", padding: "0 14px", border: "2px solid " + INK, color: INK, textDecoration: "none", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase" }}>Call</a>
)}
<button onClick={() => act("pickup.contacted", p.id)} disabled={busy || p.contacted}
style={{ minHeight: 44, padding: "0 14px", border: "2px solid " + INK, background: p.contacted ? INK : "transparent", color: p.contacted ? "var(--color-bg)" : INK, fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: p.contacted ? "default" : "pointer", opacity: busy ? 0.5 : 1 }}>
{p.contacted ? "Contacted" : "Mark contacted"}
</button>
<button onClick={() => act("pickup.pickedUp", p.id)} disabled={busy}
style={{ minHeight: 44, padding: "0 14px", border: "2px solid var(--color-accent)", background: "var(--color-accent)", color: "#fff", fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase", cursor: "pointer", opacity: busy ? 0.5 : 1 }}>
Collected
</button>
</div>
</div>
</div>
))}
</>
)}
</MBody>
<MNav />
</>
);
}