diff --git a/frontend/components/graph/record-graph.tsx b/frontend/components/graph/record-graph.tsx new file mode 100644 index 0000000..3bbdcd6 --- /dev/null +++ b/frontend/components/graph/record-graph.tsx @@ -0,0 +1,180 @@ +"use client"; + +// An Obsidian-style knowledge graph of one patient's record: the patient sits +// at the centre, their problems (illnesses) and encounters (visits) orbit it, +// and a visit links to a problem when the visit references it. Layout is a +// d3-force simulation computed once; rendering + pan/zoom/drag is React Flow. + +import { + Background, + Controls, + type Edge, + Handle, + type Node, + type NodeProps, + Position, + ReactFlow, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import { + forceCenter, + forceCollide, + forceLink, + forceManyBody, + forceSimulation, + type SimulationLinkDatum, + type SimulationNodeDatum, +} from "d3-force"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import type { Patient } from "@/lib/patients"; +import { cn } from "@/lib/utils"; + +type Kind = "patient" | "problem" | "visit"; + +type SimNode = SimulationNodeDatum & { + id: string; + kind: Kind; + label: string; + sub?: string; +}; +type SimLink = SimulationLinkDatum; + +type RecordNodeData = { label: string; sub?: string; kind: Kind }; + +const kindClass: Record = { + patient: "bg-primary text-primary-foreground border-primary px-4 py-3 text-sm", + problem: "bg-destructive/12 text-foreground border-destructive/50", + visit: "bg-muted text-foreground border-border", +}; + +// A pill node with hidden connection handles so edges meet it cleanly. +function RecordNode({ data }: NodeProps) { + const { label, sub, kind } = data as RecordNodeData; + return ( +
+ +
{label}
+ {sub ?
{sub}
: null} + +
+ ); +} + +const nodeTypes = { record: RecordNode }; + +// Build nodes + edges from the record. A visit links to a problem when its +// type/summary mentions the problem label (case-insensitive) — that produces +// the clustered "hub" look. +function buildGraph(patient: Patient): { + nodes: SimNode[]; + edges: { source: string; target: string }[]; +} { + const nodes: SimNode[] = [ + { id: "patient", kind: "patient", label: patient.name }, + ]; + const edges: { source: string; target: string }[] = []; + + patient.problems.forEach((p, i) => { + const id = `prob-${i}`; + nodes.push({ id, kind: "problem", label: p.label, sub: p.since }); + edges.push({ source: "patient", target: id }); + }); + + patient.encounters.forEach((e, i) => { + const id = `enc-${i}`; + nodes.push({ id, kind: "visit", label: e.type, sub: e.date }); + edges.push({ source: "patient", target: id }); + const hay = `${e.type} ${e.summary}`.toLowerCase(); + patient.problems.forEach((p, pi) => { + if (p.label && hay.includes(p.label.toLowerCase())) { + edges.push({ source: id, target: `prob-${pi}` }); + } + }); + }); + + return { nodes, edges }; +} + +// Run the force simulation to completion so positions are stable on first paint. +function layout(nodes: SimNode[], edges: { source: string; target: string }[]) { + const links: SimLink[] = edges.map((e) => ({ ...e })); + forceSimulation(nodes) + .force("charge", forceManyBody().strength(-340)) + .force( + "link", + forceLink(links) + .id((d) => d.id) + .distance(96) + .strength(0.45), + ) + .force("center", forceCenter(240, 170)) + .force("collide", forceCollide(50)) + .stop() + .tick(320); +} + +export function RecordGraph({ + patient, + className, +}: { + patient: Patient; + className?: string; +}) { + const { t } = useTranslation(); + + const { nodes, edges } = useMemo(() => { + const g = buildGraph(patient); + layout(g.nodes, g.edges); + const rfNodes: Node[] = g.nodes.map((n) => ({ + id: n.id, + type: "record", + position: { x: n.x ?? 0, y: n.y ?? 0 }, + data: { label: n.label, sub: n.sub, kind: n.kind }, + })); + const rfEdges: Edge[] = g.edges.map((e, i) => ({ + id: `e-${i}`, + source: e.source, + target: e.target, + style: { stroke: "var(--border)", strokeWidth: 1.5 }, + })); + return { nodes: rfNodes, edges: rfEdges }; + }, [patient]); + + if (patient.problems.length === 0 && patient.encounters.length === 0) { + return ( +

{t("patientCard.graph.empty")}

+ ); + } + + return ( +
+ + + + +
+ ); +} diff --git a/frontend/components/patients/patient-detail-sheet.tsx b/frontend/components/patients/patient-detail-sheet.tsx index 6289375..2134a71 100644 --- a/frontend/components/patients/patient-detail-sheet.tsx +++ b/frontend/components/patients/patient-detail-sheet.tsx @@ -7,6 +7,7 @@ import { AiBadge } from "@/components/ai-badge"; import { PatientFormDialog } from "@/components/chat/patient-form-dialog"; import { PatientDetail } from "@/components/patients/patient-detail"; import { TransferPatientDialog } from "@/components/patients/transfer-patient-dialog"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { Sheet, SheetHeader, @@ -15,8 +16,12 @@ import { SheetTitle, } from "@/components/ui/sheet"; import { Skeleton } from "@/components/ui/skeleton"; -import { getPatient, type Patient } from "@/lib/patients"; +import { type Appointment, listAppointments } from "@/lib/appointments"; +import { type Invoice, listInvoices } from "@/lib/invoices"; +import { deletePatient, getPatient, type Patient } from "@/lib/patients"; +import { listPrescriptions, type Prescription } from "@/lib/prescriptions"; import { hasClinicalAccess, useActiveRole } from "@/lib/roles"; +import { notify } from "@/lib/toast"; type Status = "loading" | "ready" | "not-found"; @@ -60,10 +65,18 @@ export function PatientDetailSheet({ const role = useActiveRole(); // Clinical roles can reassign a chart; show optimistically while role loads. const canTransfer = role == null || hasClinicalAccess(role); + // Deleting a chart is destructive — only offer it once we know the role is + // a full clinician (patient:delete), never optimistically. + const canDelete = role != null && hasClinicalAccess(role); const [patient, setPatient] = useState(null); const [status, setStatus] = useState("loading"); const [editOpen, setEditOpen] = useState(false); const [transferOpen, setTransferOpen] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + // Related records aggregated into the sheet for a 360° view. + const [prescriptions, setPrescriptions] = useState([]); + const [appointments, setAppointments] = useState([]); + const [invoices, setInvoices] = useState([]); // Bumped on open so the editor remounts with the latest patient data. const [editKey, setEditKey] = useState(0); @@ -72,6 +85,9 @@ export function PatientDetailSheet({ let active = true; setStatus("loading"); setPatient(null); + setPrescriptions([]); + setAppointments([]); + setInvoices([]); getPatient(fileNumber) .then((data) => { if (!active) return; @@ -81,11 +97,37 @@ export function PatientDetailSheet({ .catch(() => { if (active) setStatus("not-found"); }); + // Pull related records in parallel; filter to this chart. Best-effort — a + // missing permission (e.g. reception + prescriptions) just leaves it empty. + const forFile = (fn: string) => fn === fileNumber; + listPrescriptions() + .then((rx) => active && setPrescriptions(rx.filter((r) => forFile(r.fileNumber)))) + .catch(() => {}); + listAppointments() + .then((a) => active && setAppointments(a.filter((r) => forFile(r.fileNumber)))) + .catch(() => {}); + listInvoices() + .then((i) => active && setInvoices(i.filter((r) => forFile(r.fileNumber)))) + .catch(() => {}); return () => { active = false; }; }, [open, fileNumber]); + const remove = async () => { + if (!patient) return; + try { + await deletePatient(patient.fileNumber); + notify.success(t("patients.delete.doneTitle"), patient.name); + onOpenChange(false); + } catch { + notify.error( + t("patients.delete.failedTitle"), + t("patients.delete.failedBody"), + ); + } + }; + const title = status === "ready" && patient ? patient.name @@ -112,6 +154,9 @@ export function PatientDetailSheet({ )} {status === "ready" && patient && ( setConfirmOpen(true) : undefined} onEdit={() => { setEditKey((k) => k + 1); setEditOpen(true); @@ -120,6 +165,7 @@ export function PatientDetailSheet({ canTransfer ? () => setTransferOpen(true) : undefined } patient={patient} + prescriptions={prescriptions} /> )} @@ -145,6 +191,18 @@ export function PatientDetailSheet({ patient={patient} /> )} + + {patient && ( + + )} ); } diff --git a/frontend/components/patients/patient-detail.tsx b/frontend/components/patients/patient-detail.tsx index 6e10cc8..b0bbe1b 100644 --- a/frontend/components/patients/patient-detail.tsx +++ b/frontend/components/patients/patient-detail.tsx @@ -1,14 +1,22 @@ "use client"; -import { ArrowLeftRight, Pencil } from "lucide-react"; +import { ArrowLeftRight, Pencil, Trash2 } from "lucide-react"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Sparkline } from "@/components/chat/sparkline"; +import { RecordGraph } from "@/components/graph/record-graph"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import type { Appointment } from "@/lib/appointments"; +import { + formatMoney, + type Invoice, + invoiceTotal, +} from "@/lib/invoices"; import type { AllergySeverity, LabFlag, Patient, Trend } from "@/lib/patients"; +import type { Prescription } from "@/lib/prescriptions"; type BadgeVariant = "default" | "secondary" | "destructive" | "outline"; @@ -82,10 +90,18 @@ export function PatientDetail({ patient, onEdit, onTransfer, + onDelete, + prescriptions, + appointments, + invoices, }: { patient: Patient; onEdit?: () => void; onTransfer?: () => void; + onDelete?: () => void; + prescriptions?: Prescription[]; + appointments?: Appointment[]; + invoices?: Invoice[]; }) { const { t } = useTranslation(); const sex = t(`patientCard.sex.${patient.sex}`); @@ -135,6 +151,17 @@ export function PatientDetail({ {t("patientCard.edit")} )} + {onDelete && ( + + )} @@ -159,6 +186,13 @@ export function PatientDetail({ +
+

+ {t("patientCard.graph.hint")} +

+ +
+
@@ -301,6 +335,87 @@ export function PatientDetail({
)}
+ + {appointments && ( +
+ {appointments.length === 0 ? ( +

+ {t("patientCard.appointments.empty")} +

+ ) : ( +
+ {appointments.map((appt) => ( + + {appt.type} + + {t(`appointments.status.${appt.status}`)} + + + } + /> + ))} +
+ )} +
+ )} + + {prescriptions && ( +
+ {prescriptions.length === 0 ? ( +

+ {t("patientCard.prescriptions.empty")} +

+ ) : ( +
+ {prescriptions.map((rx) => ( + + {`${rx.dose} · ${rx.frequency}`} + + {t(`prescriptions.status.${rx.status}`)} + + + } + /> + ))} +
+ )} +
+ )} + + {invoices && ( +
+ {invoices.length === 0 ? ( +

+ {t("patientCard.invoices.empty")} +

+ ) : ( +
+ {invoices.map((inv) => ( + + {formatMoney(invoiceTotal(inv))} + + {t(`invoices.status.${inv.status}`)} + + + } + /> + ))} +
+ )} +
+ )} ); } diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index 75d2d10..b83d075 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -206,6 +206,16 @@ "successBody": "{{name}} is now with {{provider}}.", "errorTitle": "Couldn't transfer patient", "error": "Please try again." + }, + "delete": { + "action": "Delete patient", + "title": "Delete patient?", + "body": "Permanently delete {{name}}'s chart and all of its records. This cannot be undone.", + "confirm": "Delete patient", + "cancel": "Cancel", + "doneTitle": "Patient deleted", + "failedTitle": "Couldn't delete patient", + "failedBody": "Please try again." } }, "appointments": { @@ -1022,6 +1032,23 @@ "recent": "{{count}} recent", "empty": "No visits yet." }, + "graph": { + "title": "Record graph", + "hint": "How this patient's problems and visits connect. Drag nodes; scroll to pan.", + "empty": "Not enough record data to graph yet." + }, + "appointments": { + "title": "Appointments", + "empty": "No appointments on file." + }, + "prescriptions": { + "title": "Prescriptions", + "empty": "No prescriptions on file." + }, + "invoices": { + "title": "Invoices", + "empty": "No invoices on file." + }, "trend": { "empty": "No trend data yet.", "last": "{{label}} · last {{count}}", diff --git a/frontend/lib/patients.ts b/frontend/lib/patients.ts index 55160cc..5cdc704 100644 --- a/frontend/lib/patients.ts +++ b/frontend/lib/patients.ts @@ -130,6 +130,16 @@ export async function appendLabs( ); } +// Permanently delete a patient's chart. Backed by DELETE +// /api/patients/:fileNumber (gated by `patient:delete` — the full-clinician +// marker). Resolves on 204; throws ApiError on failure. +export async function deletePatient(fileNumber: string): Promise { + await apiFetch( + `/api/patients/${encodeURIComponent(fileNumber.trim())}`, + { method: "DELETE" }, + ); +} + // Reassign a patient to another clinician (sets their primary provider + PCP). export async function transferPatient( fileNumber: string, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8ec9e07..1f5e145 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,6 +22,7 @@ "@tiptap/pm": "^3.25.0", "@tiptap/react": "^3.25.0", "@tiptap/starter-kit": "^3.25.0", + "@types/d3-force": "^3.0.10", "@visx/curve": "^4.0.1-alpha.0", "@visx/event": "^4.0.1-alpha.0", "@visx/gradient": "^4.0.1-alpha.0", @@ -38,6 +39,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "d3-array": "^3.2.4", + "d3-force": "^3.0.0", "embla-carousel-react": "^8.6.0", "framer-motion": "^12.40.0", "i18next": "^26.3.1", diff --git a/frontend/package.json b/frontend/package.json index f799755..c71e290 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "@tiptap/pm": "^3.25.0", "@tiptap/react": "^3.25.0", "@tiptap/starter-kit": "^3.25.0", + "@types/d3-force": "^3.0.10", "@visx/curve": "^4.0.1-alpha.0", "@visx/event": "^4.0.1-alpha.0", "@visx/gradient": "^4.0.1-alpha.0", @@ -39,6 +40,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "d3-array": "^3.2.4", + "d3-force": "^3.0.0", "embla-carousel-react": "^8.6.0", "framer-motion": "^12.40.0", "i18next": "^26.3.1",