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 }) => (
);
/** 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 ? (