Files
threadcount-community/app/m/(app)/rounds/page.tsx
T
ThreadCount 057da00fd2 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 e2d6d42 on 2026-09-13. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-13 11:16:36 +10:00

111 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 />
</>
);
}