From 3bafe38889e80d3bd1618cfc43ff0f69ca5a4a23 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Thu, 11 Jun 2026 21:03:51 +0300 Subject: [PATCH] frontend: lab dashboard with add-result flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /lab department home: the lab's task queue (tasks assigned to the Lab department, with done toggle) and an "Add result" dialog โ€” pick a patient, enter test/value/flag/date โ€” that posts to the new labs append endpoint via appendLabs() in lib/patients.ts. Co-Authored-By: Claude Fable 5 --- frontend/app/(app)/lab/page.tsx | 10 + frontend/components/lab/lab-view.tsx | 455 +++++++++++++++++++++++++++ frontend/lib/patients.ts | 16 + 3 files changed, 481 insertions(+) create mode 100644 frontend/app/(app)/lab/page.tsx create mode 100644 frontend/components/lab/lab-view.tsx diff --git a/frontend/app/(app)/lab/page.tsx b/frontend/app/(app)/lab/page.tsx new file mode 100644 index 0000000..6b83dc0 --- /dev/null +++ b/frontend/app/(app)/lab/page.tsx @@ -0,0 +1,10 @@ +import { LabView } from "@/components/lab/lab-view"; +import { SidebarInset } from "@/components/ui/sidebar"; + +export default function LabPage() { + return ( + + + + ); +} diff --git a/frontend/components/lab/lab-view.tsx b/frontend/components/lab/lab-view.tsx new file mode 100644 index 0000000..6e2d48d --- /dev/null +++ b/frontend/components/lab/lab-view.tsx @@ -0,0 +1,455 @@ +"use client"; + +import { Check, FlaskConical, Plus, Search } from "lucide-react"; +import { + type FormEvent, + 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 { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + type LabFlag, + type Patient, + appendLabs, + 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 LAB_FLAGS: LabFlag[] = ["normal", "low", "high", "critical"]; + +// 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", + }); + +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 ( + + ); +} + +// 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: () => void; +}) { + const { t } = useTranslation(); + const [patient, setPatient] = useState(null); + const [patientQuery, setPatientQuery] = useState(""); + const [name, setName] = useState(""); + const [value, setValue] = useState(""); + const [flag, setFlag] = useState("normal"); + const [takenAt, setTakenAt] = useState(today()); + const [saving, setSaving] = useState(false); + + const reset = () => { + setPatient(null); + setPatientQuery(""); + setName(""); + setValue(""); + setFlag("normal"); + setTakenAt(today()); + setSaving(false); + }; + + const search = patientQuery.trim().toLowerCase(); + const matches = useMemo(() => { + const base = search + ? patients.filter( + (p) => + p.name.toLowerCase().includes(search) || + p.fileNumber.includes(search), + ) + : patients; + return base.slice(0, 6); + }, [patients, search]); + + 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; + } + setSaving(true); + try { + await appendLabs(patient.fileNumber, [ + { + name: name.trim(), + value: value.trim(), + flag, + takenAt: takenAt.trim() || today(), + }, + ]); + notify.success( + t("lab.addResult.addedTitle"), + t("lab.addResult.addedBody", { test: name.trim(), name: patient.name }), + ); + reset(); + onOpenChange(false); + onAdded(); + } 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)} + placeholder={t("lab.addResult.patientPlaceholder")} + value={patientQuery} + /> +
+
+ {matches.map((p) => ( + + ))} + {matches.length === 0 && ( +

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

+ )} +
+
+ )} + +
+ + setName(event.target.value)} + placeholder={t("lab.addResult.testPlaceholder")} + value={name} + /> + + + setValue(event.target.value)} + placeholder={t("lab.addResult.valuePlaceholder")} + value={value} + /> + +
+
+ + + + + setTakenAt(event.target.value)} + value={takenAt} + /> + +
+
+ + + }> + {t("lab.addResult.cancel")} + + + +
+
+
+ ); +} + +// The lab department home: the lab's task queue plus the "add result" flow +// for submitting a patient's analyses to their record. +export function LabView() { + const { t } = useTranslation(); + const [tasks, setTasks] = useState([]); + const [patients, setPatients] = useState([]); + const [addOpen, setAddOpen] = useState(false); + + 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); + }) + .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], + ); + + // 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")}

+
+ +
+ +
+
+

+ {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}`)} + +
+ )) + )} +
+
+ + { + /* the patient record is reloaded on next lookup; nothing to refresh here */ + }} + onOpenChange={setAddOpen} + open={addOpen} + patients={patients} + /> +
+ ); +} diff --git a/frontend/lib/patients.ts b/frontend/lib/patients.ts index 191fb9b..bec4938 100644 --- a/frontend/lib/patients.ts +++ b/frontend/lib/patients.ts @@ -113,6 +113,22 @@ export async function updatePatient(patient: Patient): Promise { ); } +// Append lab results to a patient's record without touching the rest of it. +// Backed by POST /api/patients/:fileNumber/labs (gated by `lab:write`, so lab +// staff can submit analyses without patient-edit rights). +export async function appendLabs( + fileNumber: string, + labs: Lab[], +): Promise { + return apiFetch( + `/api/patients/${encodeURIComponent(fileNumber.trim())}/labs`, + { + method: "POST", + body: JSON.stringify({ labs }), + }, + ); +} + // Reassign a patient to another clinician (sets their primary provider + PCP). export async function transferPatient( fileNumber: string,