"use client"; import { Check, ChevronDown, FlaskConical, Plus, Search, Trash2, } from "lucide-react"; import { type FormEvent, type KeyboardEvent, type ReactNode, useEffect, useMemo, useState, } from "react"; import { useTranslation } from "react-i18next"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { Dialog, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { LabIntegrationCard } from "@/components/lab/lab-integration-card"; import { StagedFilesField } from "@/components/patients/patient-files"; import { uploadAttachment } from "@/lib/attachments"; import { LAB_ANALYSES, LAB_ANALYSIS_UNITS } from "@/lib/lab-analyses"; import { type Lab, type LabFlag, type Patient, appendLabs, deleteLab, listPatients, } from "@/lib/patients"; import { type Priority, type Task, listTasks, updateTask } from "@/lib/tasks"; import { notify } from "@/lib/toast"; import { cn } from "@/lib/utils"; const priorityVariant: Record = { high: "destructive", medium: "secondary", low: "outline", }; const flagVariant: Record = { normal: "secondary", low: "warning", high: "warning", critical: "destructive", }; const LAB_FLAGS: LabFlag[] = ["normal", "low", "high", "critical"]; // A patient + one of their lab results, used by the "Recent results" feed. type RecentResult = { patient: Patient; lab: Lab }; // Patient dates are stored as formatted strings (e.g. "Jun 02, 2026") — match // the format used by the patient form. const today = () => new Date().toLocaleDateString("en-US", { month: "short", day: "2-digit", year: "numeric", }); // Parse a stored date string to a timestamp for sorting; unknown formats sort // last (0). const ts = (s: string): number => { const t = Date.parse(s); return Number.isNaN(t) ? 0 : t; }; // Flatten every patient's labs into a recent-results feed, newest first. function buildRecent(patients: Patient[]): RecentResult[] { const all: RecentResult[] = []; for (const patient of patients) { for (const lab of patient.labs) all.push({ patient, lab }); } all.sort((a, b) => ts(b.lab.takenAt) - ts(a.lab.takenAt)); return all.slice(0, 12); } function Field({ label, children }: { label: string; children: ReactNode }) { return ( ); } const controlClass = "h-9 w-full rounded-3xl border border-transparent bg-input/50 px-3 text-sm text-foreground outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30"; function CheckButton({ done, onClick, label, }: { done: boolean; onClick: () => void; label: string; }) { return ( ); } // One labelled field in a work-queue item's expanded detail. function QueueMeta({ label, value }: { label: string; value: string }) { return (
{label}
{value}
); } // Dialog for submitting an analysis result: pick a patient, then enter the // test name, value, flag and date. Posts to the lab-only append endpoint. function AddResultDialog({ open, onOpenChange, patients, onAdded, }: { open: boolean; onOpenChange: (open: boolean) => void; patients: Patient[]; onAdded: (patient: Patient, lab: Lab) => void; }) { const { t } = useTranslation(); const [patient, setPatient] = useState(null); const [patientQuery, setPatientQuery] = useState(""); // Highlighted index in the patient match list, for arrow-key navigation. const [activeIndex, setActiveIndex] = useState(0); const [name, setName] = useState(""); const [value, setValue] = useState(""); const [flag, setFlag] = useState("normal"); const [takenAt, setTakenAt] = useState(today()); // Advanced mode reveals a free-form reference-range field for analyses that // aren't in the catalog (the test field already accepts any text). const [advanced, setAdvanced] = useState(false); const [refRange, setRefRange] = useState(""); const [saving, setSaving] = useState(false); // Analysis files (PDF/image) attached to this result. const [files, setFiles] = useState([]); const reset = () => { setPatient(null); setPatientQuery(""); setActiveIndex(0); setName(""); setValue(""); setFlag("normal"); setTakenAt(today()); setAdvanced(false); setRefRange(""); setFiles([]); setSaving(false); }; const search = patientQuery.trim().toLowerCase(); // Only surface patients once the user has typed something — never the full // roster on an empty field. const matches = useMemo(() => { if (!search) return []; return patients .filter( (p) => p.name.toLowerCase().includes(search) || p.fileNumber.includes(search), ) .slice(0, 6); }, [patients, search]); // Keyboard navigation for the patient list: ↑/↓ to move, Enter to select. const onPatientKeyDown = (event: KeyboardEvent) => { if (matches.length === 0) return; if (event.key === "ArrowDown") { event.preventDefault(); setActiveIndex((i) => Math.min(i + 1, matches.length - 1)); } else if (event.key === "ArrowUp") { event.preventDefault(); setActiveIndex((i) => Math.max(i - 1, 0)); } else if (event.key === "Enter") { // Don't submit the form — pick the highlighted patient instead. event.preventDefault(); const picked = matches[activeIndex]; if (picked) setPatient(picked); } }; // Unit hint for the value field once a catalogued analysis is chosen. const unitHint = LAB_ANALYSIS_UNITS[name.trim()]; const submit = async (event: FormEvent) => { event.preventDefault(); if (!patient) { notify.error( t("lab.addResult.needPatientTitle"), t("lab.addResult.needPatientBody"), ); return; } if (!name.trim() || !value.trim()) { notify.error( t("lab.addResult.needFieldsTitle"), t("lab.addResult.needFieldsBody"), ); return; } const finalValue = advanced && refRange.trim() ? `${value.trim()} (ref ${refRange.trim()})` : value.trim(); const lab: Lab = { name: name.trim(), value: finalValue, flag, takenAt: takenAt.trim() || today(), }; setSaving(true); try { await appendLabs(patient.fileNumber, [lab]); // Attach any analysis files to this result (best-effort). if (files.length > 0) { const labKey = `${lab.name} · ${lab.takenAt}`; const results = await Promise.allSettled( files.map((file) => uploadAttachment({ file, fileNumber: patient.fileNumber, labKey, }), ), ); if (results.some((r) => r.status === "rejected")) { notify.error( t("patientFiles.uploadFailedTitle"), t("patientFiles.uploadFailedBody"), ); } } notify.success( t("lab.addResult.addedTitle"), t("lab.addResult.addedBody", { test: lab.name, name: patient.name }), ); const added = patient; reset(); onOpenChange(false); onAdded(added, lab); } catch { setSaving(false); notify.error( t("lab.addResult.failedTitle"), t("lab.addResult.failedBody"), ); } }; return ( { onOpenChange(o); if (!o) reset(); }} open={open} > {t("lab.addResult.title")} {t("lab.addResult.description")}
{patient ? (
{patient.initials}
{patient.name} #{patient.fileNumber}
) : (
{t("lab.addResult.patient")}
{ setPatientQuery(event.target.value); setActiveIndex(0); }} onKeyDown={onPatientKeyDown} placeholder={t("lab.addResult.patientPlaceholder")} value={patientQuery} />
{search.length > 0 && (
{matches.map((p, index) => ( ))} {matches.length === 0 && (

{t("lab.addResult.noPatients")}

)}
)}
)}
setName(event.target.value)} placeholder={t("lab.addResult.testPlaceholder")} value={name} /> {LAB_ANALYSES.map((a) => ( ))} setValue(event.target.value)} placeholder={ unitHint ? t("lab.addResult.valueUnitPlaceholder", { unit: unitHint, }) : t("lab.addResult.valuePlaceholder") } value={value} />
setTakenAt(event.target.value)} value={takenAt} />
{advanced && ( setRefRange(event.target.value)} placeholder={t("lab.addResult.refRangePlaceholder")} value={refRange} /> )}
}> {t("lab.addResult.cancel")}
); } // The lab department home: the lab's task queue, an "add result" flow for // submitting a patient's analyses, and a feed of recently recorded results. export function LabView() { const { t } = useTranslation(); const [tasks, setTasks] = useState([]); const [patients, setPatients] = useState([]); const [recent, setRecent] = useState([]); const [addOpen, setAddOpen] = useState(false); // The result staged for deletion (drives the confirm dialog). const [toDelete, setToDelete] = useState(null); useEffect(() => { let active = true; listTasks() .then((data) => { if (active) setTasks(data); }) .catch(() => { /* api-client redirects on 401; otherwise leave the list empty */ }); listPatients() .then((data) => { if (active) { setPatients(data); setRecent(buildRecent(data)); } }) .catch(() => { /* same */ }); return () => { active = false; }; }, []); // The lab work queue. The backend already scopes visibility; the client // filter keeps the page focused for admins (who see every task). const queue = useMemo( () => tasks.filter((task) => task.assigneeRole === "lab"), [tasks], ); // After a result is submitted: show it instantly at the top of the feed, and // refresh patient records in the background so it survives a reload. const handleAdded = (patient: Patient, lab: Lab) => { setRecent((prev) => [{ patient, lab }, ...prev].slice(0, 12)); listPatients() .then((data) => setPatients(data)) .catch(() => { /* keep the optimistic feed if the refresh fails */ }); }; // Remove a result from the feed + the patient's record. const confirmDelete = async () => { if (!toDelete) return; const { patient, lab } = toDelete; try { await deleteLab(patient.fileNumber, lab); setRecent((prev) => prev.filter( (r) => !( r.patient.fileNumber === patient.fileNumber && r.lab.name === lab.name && r.lab.value === lab.value && r.lab.takenAt === lab.takenAt ), ), ); notify.success( t("lab.recent.deletedTitle"), t("lab.recent.deletedBody", { test: lab.name, name: patient.name }), ); } catch { notify.error( t("lab.recent.deleteFailedTitle"), t("lab.recent.deleteFailedBody"), ); } finally { setToDelete(null); } }; // Optimistically flip done, then persist; roll back on failure. const toggle = async (id: string) => { const current = tasks.find((task) => task.id === id); if (!current) return; const next = !current.done; setTasks((prev) => prev.map((row) => (row.id === id ? { ...row, done: next } : row)), ); try { await updateTask(id, { done: next }); } catch { setTasks((prev) => prev.map((row) => (row.id === id ? { ...row, done: current.done } : row)), ); notify.error( t("lab.toast.updateFailedTitle"), t("lab.toast.updateFailedBody"), ); } }; return (

{t("lab.title")}

{t("lab.subtitle")}

listPatients() .then((data) => { setPatients(data); setRecent(buildRecent(data)); }) .catch(() => {}) } patients={patients} />

{t("lab.queue.title")}

{t("lab.queue.description")}

{queue.length === 0 ? (

{t("lab.queue.empty")}

) : ( queue.map((task) => (
toggle(task.id)} />
{task.title} {task.due} {task.createdByName ? ` · ${t("tasks.list.byCreator", { name: task.createdByName })}` : ""}
{t(`tasks.priority.${task.priority}`)}

{task.notes || t("lab.queue.noNotes")}

)) )}

{t("lab.recent.title")}

{t("lab.recent.description")}

{recent.length === 0 ? (

{t("lab.recent.empty")}

) : ( recent.map(({ patient, lab }, index) => (
{patient.initials}
{lab.name} {" "} · {lab.value} {patient.name} · #{patient.fileNumber} · {lab.takenAt}
{t(`patientCard.labFlag.${lab.flag}`)}
)) )}
{ if (!o) setToDelete(null); }} open={toDelete !== null} title={t("lab.recent.deleteTitle")} />
); }