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 a113353 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
/* Delivery round — everything waiting, grouped by ward, handed over on the floor with a signature.
|
||||
Reuses the same signature pad and photo upload as the desktop, so a handover looks identical
|
||||
in the record whichever screen recorded it. */
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useSnap } from "@/lib/client";
|
||||
import { daysBetween, itemMap, label, staffMap, staffName, type PickupRec } from "@/lib/compute";
|
||||
import { uploadPhoto } from "@/lib/photo";
|
||||
import { SignaturePad } from "@/components/dialogs";
|
||||
import { INK, MBar, MBody, MEmpty, MError, MNav, MRow, MRule, MSection, MTop, inputStyle } from "@/components/m";
|
||||
|
||||
export default function MRounds() {
|
||||
const { s, mutate, busy } = useSnap();
|
||||
const [pick, setPick] = useState<PickupRec | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [err, setErr] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const pad = useRef<{ clear: () => void; dataUrl: () => string | null } | null>(null);
|
||||
|
||||
const byId = useMemo(() => itemMap(s), [s]);
|
||||
const staffById = useMemo(() => staffMap(s), [s]);
|
||||
|
||||
/* Grouped by ward — a round is walked ward by ward, not order by order. */
|
||||
const wards = useMemo(() => {
|
||||
const m: Record<string, PickupRec[]> = {};
|
||||
for (const p of s.pickups) {
|
||||
if (p.pickedUp) continue;
|
||||
const w = staffById[p.staffId]?.dept || "No ward recorded";
|
||||
(m[w] ||= []).push(p);
|
||||
}
|
||||
return Object.entries(m).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
}, [s, staffById]);
|
||||
|
||||
const items = (p: PickupRec) => p.lines.map((l) => `${label(byId[l.itemId])} ${l.size}${l.qty > 1 ? ` ×${l.qty}` : ""}`).join(", ");
|
||||
|
||||
const deliver = async () => {
|
||||
if (!pick || saving) return;
|
||||
setSaving(true); setErr("");
|
||||
let sigId: string | null = null;
|
||||
const png = pad.current?.dataUrl() || null;
|
||||
if (png) {
|
||||
const up = await uploadPhoto(mutate, "sig", png);
|
||||
if ("error" in up) { setSaving(false); setErr(up.error); return; }
|
||||
sigId = up.id;
|
||||
}
|
||||
const r = await mutate("pickup.deliver", { id: pick.id, deliveredTo: name.trim(), sigId });
|
||||
setSaving(false);
|
||||
if (!r.ok) { setErr(r.error); return; }
|
||||
setPick(null); setName("");
|
||||
};
|
||||
|
||||
const total = wards.reduce((t, [, ps]) => t + ps.length, 0);
|
||||
|
||||
if (pick) {
|
||||
const st = staffById[pick.staffId];
|
||||
return (
|
||||
<>
|
||||
<MTop title="Hand over" back onBack={() => setPick(null)} right={st?.dept || undefined} />
|
||||
<MRule />
|
||||
<MError msg={err} onDismiss={() => setErr("")} />
|
||||
<MBody>
|
||||
<section style={{ background: INK, color: "var(--color-bg)", padding: "18px 16px" }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, letterSpacing: "0.12em", textTransform: "uppercase", color: "var(--color-accent-300)" }}>{pick.orderCode}</div>
|
||||
<div style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 26, letterSpacing: "-0.02em", marginTop: 8 }}>{staffName(st, "Staff member")}</div>
|
||||
<div style={{ fontSize: 13.5, color: "var(--color-neutral-400)", marginTop: 8 }}>{items(pick)}</div>
|
||||
</section>
|
||||
|
||||
<MSection label="Received by" />
|
||||
<div style={{ padding: 16 }}>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name of whoever signs, e.g. the manager" aria-label="Received by" style={inputStyle} />
|
||||
<div style={{ fontSize: 11, fontWeight: 600, letterSpacing: "0.1em", textTransform: "uppercase", color: "var(--color-neutral-600)", marginTop: 18, marginBottom: 8 }}>Signature</div>
|
||||
<SignaturePad onReady={(api) => { pad.current = api; }} />
|
||||
<button onClick={() => pad.current?.clear()} style={{ marginTop: 10, background: "none", border: 0, padding: 0, color: "var(--color-accent-700)", fontSize: 14, fontWeight: 600, textDecoration: "underline", textUnderlineOffset: 3, cursor: "pointer" }}>Clear the signature</button>
|
||||
<p style={{ fontSize: 13.5, color: "var(--color-neutral-700)", marginTop: 18, lineHeight: 1.6 }}>
|
||||
Handing over records the garments as collected — the same as a pickup at the counter — and keeps the name and signature with the record.
|
||||
</p>
|
||||
</div>
|
||||
</MBody>
|
||||
<MBar label={saving ? "Recording…" : "Delivered"} glyph="check" onClick={deliver} disabled={saving || busy} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MTop title="Round" back right={total ? `${total} to drop off` : undefined} />
|
||||
<MRule />
|
||||
<MBody>
|
||||
{total === 0 ? (
|
||||
<MEmpty title="Nothing to deliver" sub="Everything that has come in has been collected or handed over." />
|
||||
) : wards.map(([ward, ps]) => (
|
||||
<div key={ward}>
|
||||
<MSection label={ward} right={`${ps.length} order${ps.length === 1 ? "" : "s"}`} />
|
||||
{ps.map((p) => {
|
||||
const st = staffById[p.staffId];
|
||||
const days = daysBetween(p.received, s.today);
|
||||
return (
|
||||
<MRow key={p.id} onClick={() => { setPick(p); setName(""); }} mark={days > 10 ? "accent" : "ink"} attention={days > 10}
|
||||
title={staffName(st, "Staff member")}
|
||||
sub={`${items(p)} · waiting ${days}d`}
|
||||
right={<span style={{ fontFamily: "var(--font-heading)", fontWeight: 800, fontSize: 13, letterSpacing: "0.08em", textTransform: "uppercase" }}>Sign</span>} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</MBody>
|
||||
<MNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user