import { useState } from "react"; import { AlertCircle, Loader2 } from "lucide-react"; import { Alert, AlertDescription } from "../../../components/ui/alert.tsx"; import { Button } from "../../../components/ui/button.tsx"; import { Input } from "../../../components/ui/input.tsx"; import { Label } from "../../../components/ui/label.tsx"; import { priorityWord } from "../../../components/ui/row-meta.tsx"; import { useStaffPermissionServiceContext } from "../../../services"; import { useCapabilityServiceContext } from "../../../services/CapabilityService.tsx"; import { useAdmission } from "../../../hooks/useAdmission.ts"; import { relayServerFailure } from "../../../utils/fetchFailureMessage"; import { reserveReceiptForSend, confirmReceiptAttempt } from "../../../utils/unconfirmedReceipts"; import { useBench, type BenchRow, type RejectionInput } from "../../../hooks/useBench.ts"; import ReleaseResultDialog from "../DiagnosticOrdersPage/ReleaseResultDialog.tsx"; import RecordRefusalDialog from "../DiagnosticOrdersPage/RecordRefusalDialog.tsx"; import { QualityControlStrip } from "./QualityControlStrip.tsx"; import { CollectDialog } from "./CollectDialog.tsx"; import { RejectSpecimenDialog } from "./RejectSpecimenDialog.tsx"; import { OrderDetailsDialog } from "./OrderDetailsDialog.tsx"; import EnterTestResultDialog from "../DiagnosticOrdersPage/EnterTestResultDialog.tsx"; import { benchTime, dayAndTime } from "./benchTime.ts"; import "./BenchPage.css"; // Worked by specimen rather than by order: the queue next door answers what is outstanding, this // answers what is in hand. Each act opens over the worklist from the row it names. const isUrgent = (row: BenchRow) => Boolean(row.priority && row.priority !== "routine"); const joined = (...parts: (string | undefined)[]) => parts.filter(Boolean).join(" · "); // Names the person behind the step as well as its time, so the technician knows whom to ask. const who = (row: BenchRow, verb: string, iso?: string, by?: string) => { const step = [verb, benchTime(iso), by ? `by ${by}` : undefined].filter(Boolean); return joined(row.patientDisplay ?? "Patient not recorded", step.length > 1 ? step.join(" ") : undefined); }; const matches = (row: BenchRow, query: string) => [row.patientDisplay, row.test, row.accession].some((value) => value?.toLowerCase().includes(query)); /** What to draw it into, led by the priority when the order is not routine. */ const tubeLine = (row: BenchRow) => joined(isUrgent(row) ? priorityWord(row.priority) : undefined, row.container, row.volume); const Row = ({ row, action, children }: { row: BenchRow; action?: React.ReactNode; children?: React.ReactNode }) => (
{row.accession ? {row.accession} : null} {row.test ?? "Test not recorded"} {children}
{action}
); /** A section always states its count, so a zero reads as settled rather than as a failed load. */ const Group = ({ title, rows, empty, searching, children }: { title: string; rows: BenchRow[]; empty: string; searching: boolean; children: (row: BenchRow) => React.ReactNode; }) => (

{title}

{`· ${rows.length}`}
{rows.length ?
{rows.map(children)}
: searching ? null :

{empty}

}
); const BenchPage = () => { const { canManage, permissionsSettled } = useStaffPermissionServiceContext(); // Every act here writes through a diagnostics route the server gates at manage. const mayWork = canManage("diagnostics"); const { mayCall } = useCapabilityServiceContext(); // The practice's permissions decide whether the bench is worked here at all; the server's own answer for the route // each act calls can still say no, and then the act is not offered. An unanswered capability read narrows nothing. const mayAct = (method: string, route: string) => mayWork && mayCall(method, route) !== false; const mayCollect = mayAct("PUT", "/diagnostic-order/*/state"); const mayReceive = mayAct("POST", "/Specimen"); const mayReject = mayAct("POST", "/diagnostic-order/*/rejection"); const mayEnterResult = permissionsSettled && mayAct("POST", "/diagnostic-order/*/result"); const mayRefuse = mayAct("POST", "/diagnostic-order/*/refusal"); const mayRelease = mayAct("POST", "/diagnostic-order/*/release"); // An act already open follows its route's answer too: withdrawn, its dialog cannot send, and a write still // waiting on its token when the answer changes is stopped before it leaves. const admitCollect = useAdmission(mayCollect); const admitReceive = useAdmission(mayReceive); const admitReject = useAdmission(mayReject); const { bench, loading, error, reload, markCollected, receive, rejectAtReceipt, markReleased } = useBench(); const [query, setQuery] = useState(""); const [collecting, setCollecting] = useState(); const [viewing, setViewing] = useState(); const [refusing, setRefusing] = useState(); const [releasing, setReleasing] = useState(); const [receiving, setReceiving] = useState(); const [entering, setEntering] = useState(); const [rejecting, setRejecting] = useState(); const searching = query.trim().toLowerCase(); const shown = (rows: BenchRow[]) => (searching ? rows.filter((row) => matches(row, searching)) : rows); const sections = { toCollect: shown(bench.toCollect), onTheBench: shown(bench.onTheBench), awaitingRelease: shown(bench.awaitingRelease), rejectedToday: shown(bench.rejectedToday), }; const nothingMatches = Boolean(searching) && Object.values(sections).every((rows) => rows.length === 0); const enterResult = (row: BenchRow) => { if (row.orderId && permissionsSettled && canManage("diagnostics") && mayCall("POST", "/diagnostic-order/*/result") !== false) setEntering(row); }; // Collection only moves the order; a failure keeps the dialog open with the words. const take = async (row: BenchRow) => { try { await markCollected(row.orderId as string, admitCollect); } catch (failure) { console.error("Error marking a sample collected:", failure); throw new Error(relayServerFailure(failure, "That sample was not marked collected. Nothing changed.")); } setCollecting(undefined); }; // Receipt mints the accession from the test specimen requirements. An order keeps one attempt until its receipt is // confirmed or definitely refused, across leaving the bench and reloading, so trying again after an unanswered // receipt is answered with the tube it wrote, not a second one. const takeIn = async (row: BenchRow) => { const orderId = row.orderId as string; const attemptId = await reserveReceiptForSend(orderId); try { await receive(orderId, admitReceive, attemptId); } catch (failure) { console.error("Error receiving a specimen:", failure); const status = (failure as { status?: unknown } | null)?.status; if (typeof status === "number" && status >= 400 && status < 500) { try { await confirmReceiptAttempt(orderId, attemptId); } catch { /* Keep the refusal and its recovery identity. */ } } throw new Error(relayServerFailure(failure, "That specimen could not be confirmed as received. Try again; it will not be received twice.")); } await confirmReceiptAttempt(orderId, attemptId); setReceiving(undefined); }; // A refusal whose answer was lost is retried as the same attempt, so the words say to try again. const turnAway = async (row: BenchRow, input: RejectionInput) => { try { return await rejectAtReceipt(row, input, admitReject); } catch (failure) { console.error("Error rejecting a specimen:", failure); throw new Error(relayServerFailure(failure, "The rejection could not be confirmed. Try again; it will not be recorded twice.")); } }; return (

Bench

Specimens and the next step for each.

{/* Its own request, so a quality record that cannot be read never takes the worklist with it. */} {error && !loading ? ( {error} No counts are shown until the bench can be read. ) : null} {loading ? (

Loading the bench…

) : error ? null : ( <>
{(row) => ( setCollecting(row)}> Mark collected ) : undefined} > {who(row, "Ordered", row.authoredOn, row.orderedBy)} {tubeLine(row) ? ( {tubeLine(row)} ) : null} )} {(row) => ( setReceiving(row)}>Receive specimen : undefined ) : row.sentOut ? ( ) : mayEnterResult ? ( ) : undefined ) : undefined} > {who(row, "Collected", row.collectedAt, row.collectedBy)} {row.awaitingReceipt ? ( {row.acceptedFromNetwork ? "Accepted · awaiting specimen" : "Awaiting receipt · no accession yet"} ) : null} {row.recollectionFor ? ( {"Recollection needed · last specimen rejected: " + row.recollectionFor} ) : null} {row.sentOut ? ( {`Sent out · ${row.sentOut.laboratory ?? "laboratory not named"}`} ) : null} )} {(row) => ( setReleasing(row)}>Review & release ) : undefined} > {who(row, "Entered", row.enteredAt)} Entered · Not released )} {(row) => ( {row.patientDisplay ?? "Patient not recorded"} {row.rejectedFor ?? "Rejected · reason not recorded"} )}
{nothingMatches ? (

No work matches your search. Clear it to see this worklist.

) : null} )}
setCollecting(undefined)} onConfirm={take} withdrawn={!mayCollect} /> setReceiving(undefined)} onConfirm={takeIn} withdrawn={!mayReceive} onReject={mayReject ? (row) => { setReceiving(undefined); setRejecting(row); } : undefined} /> setRejecting(undefined)} onReject={turnAway} withdrawn={!mayReject} /> setViewing(undefined)} onEnterResult={mayEnterResult ? (row) => { setViewing(undefined); enterResult(row); } : undefined} onRecordRefusal={mayRefuse ? (row) => { setViewing(undefined); setRefusing(row); } : undefined} /> setEntering(undefined)} onSaved={() => void reload()} withdrawn={!mayEnterResult} /> setRefusing(undefined)} onRecorded={() => void reload()} withdrawn={!mayRefuse} /> setReleasing(undefined)} onReleased={() => { if (releasing?.orderId) markReleased(releasing.orderId); }} withdrawn={!mayRelease} // The control records are this page's own strip, at its top. onOpenControls={() => window.scrollTo({ top: 0, behavior: "smooth" })} />
); }; export default BenchPage;