mirror of
https://github.com/temetro/temetro.git
synced 2026-08-28 19:37:33 +00:00
frontend: patient delete + complete sheet + Obsidian-style record graph
- deletePatient() lib fn (DELETE /api/patients/:fileNumber already existed) - confirmed delete button in the patient sheet (full-clinician only) - aggregate appointments/prescriptions/invoices into the sheet (by file #) - new shared RecordGraph (@xyflow/react + d3-force) linking problems↔visits, added as a "Record graph" section + reusable by AI Graph mode - i18n keys for delete + new sheet sections Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<SimNode>;
|
||||||
|
|
||||||
|
type RecordNodeData = { label: string; sub?: string; kind: Kind };
|
||||||
|
|
||||||
|
const kindClass: Record<Kind, string> = {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-2xl border px-3 py-2 text-center text-xs shadow-sm",
|
||||||
|
kindClass[kind],
|
||||||
|
kind === "patient" && "font-semibold",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Handle className="!opacity-0" position={Position.Top} type="target" />
|
||||||
|
<div className="max-w-36 truncate font-medium">{label}</div>
|
||||||
|
{sub ? <div className="max-w-36 truncate opacity-70">{sub}</div> : null}
|
||||||
|
<Handle className="!opacity-0" position={Position.Bottom} type="source" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<SimNode, SimLink>(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 (
|
||||||
|
<p className="text-muted-foreground text-sm">{t("patientCard.graph.empty")}</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-80 w-full overflow-hidden rounded-2xl border bg-card/30",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ReactFlow
|
||||||
|
edges={edges}
|
||||||
|
fitView
|
||||||
|
fitViewOptions={{ padding: 0.2 }}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
nodes={nodes}
|
||||||
|
nodesConnectable={false}
|
||||||
|
panOnScroll
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
zoomOnScroll={false}
|
||||||
|
>
|
||||||
|
<Background color="var(--border)" gap={20} />
|
||||||
|
<Controls showInteractive={false} />
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { AiBadge } from "@/components/ai-badge";
|
|||||||
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
|
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
|
||||||
import { PatientDetail } from "@/components/patients/patient-detail";
|
import { PatientDetail } from "@/components/patients/patient-detail";
|
||||||
import { TransferPatientDialog } from "@/components/patients/transfer-patient-dialog";
|
import { TransferPatientDialog } from "@/components/patients/transfer-patient-dialog";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetHeader,
|
SheetHeader,
|
||||||
@@ -15,8 +16,12 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
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 { hasClinicalAccess, useActiveRole } from "@/lib/roles";
|
||||||
|
import { notify } from "@/lib/toast";
|
||||||
|
|
||||||
type Status = "loading" | "ready" | "not-found";
|
type Status = "loading" | "ready" | "not-found";
|
||||||
|
|
||||||
@@ -60,10 +65,18 @@ export function PatientDetailSheet({
|
|||||||
const role = useActiveRole();
|
const role = useActiveRole();
|
||||||
// Clinical roles can reassign a chart; show optimistically while role loads.
|
// Clinical roles can reassign a chart; show optimistically while role loads.
|
||||||
const canTransfer = role == null || hasClinicalAccess(role);
|
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<Patient | null>(null);
|
const [patient, setPatient] = useState<Patient | null>(null);
|
||||||
const [status, setStatus] = useState<Status>("loading");
|
const [status, setStatus] = useState<Status>("loading");
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [transferOpen, setTransferOpen] = 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<Prescription[]>([]);
|
||||||
|
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||||
|
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||||
// Bumped on open so the editor remounts with the latest patient data.
|
// Bumped on open so the editor remounts with the latest patient data.
|
||||||
const [editKey, setEditKey] = useState(0);
|
const [editKey, setEditKey] = useState(0);
|
||||||
|
|
||||||
@@ -72,6 +85,9 @@ export function PatientDetailSheet({
|
|||||||
let active = true;
|
let active = true;
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
setPatient(null);
|
setPatient(null);
|
||||||
|
setPrescriptions([]);
|
||||||
|
setAppointments([]);
|
||||||
|
setInvoices([]);
|
||||||
getPatient(fileNumber)
|
getPatient(fileNumber)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
@@ -81,11 +97,37 @@ export function PatientDetailSheet({
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (active) setStatus("not-found");
|
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 () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [open, fileNumber]);
|
}, [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 =
|
const title =
|
||||||
status === "ready" && patient
|
status === "ready" && patient
|
||||||
? patient.name
|
? patient.name
|
||||||
@@ -112,6 +154,9 @@ export function PatientDetailSheet({
|
|||||||
)}
|
)}
|
||||||
{status === "ready" && patient && (
|
{status === "ready" && patient && (
|
||||||
<PatientDetail
|
<PatientDetail
|
||||||
|
appointments={appointments}
|
||||||
|
invoices={invoices}
|
||||||
|
onDelete={canDelete ? () => setConfirmOpen(true) : undefined}
|
||||||
onEdit={() => {
|
onEdit={() => {
|
||||||
setEditKey((k) => k + 1);
|
setEditKey((k) => k + 1);
|
||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
@@ -120,6 +165,7 @@ export function PatientDetailSheet({
|
|||||||
canTransfer ? () => setTransferOpen(true) : undefined
|
canTransfer ? () => setTransferOpen(true) : undefined
|
||||||
}
|
}
|
||||||
patient={patient}
|
patient={patient}
|
||||||
|
prescriptions={prescriptions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</SheetPanel>
|
</SheetPanel>
|
||||||
@@ -145,6 +191,18 @@ export function PatientDetailSheet({
|
|||||||
patient={patient}
|
patient={patient}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{patient && (
|
||||||
|
<ConfirmDialog
|
||||||
|
cancelLabel={t("patients.delete.cancel")}
|
||||||
|
confirmLabel={t("patients.delete.confirm")}
|
||||||
|
description={t("patients.delete.body", { name: patient.name })}
|
||||||
|
onConfirm={remove}
|
||||||
|
onOpenChange={setConfirmOpen}
|
||||||
|
open={confirmOpen}
|
||||||
|
title={t("patients.delete.title")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowLeftRight, Pencil } from "lucide-react";
|
import { ArrowLeftRight, Pencil, Trash2 } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Sparkline } from "@/components/chat/sparkline";
|
import { Sparkline } from "@/components/chat/sparkline";
|
||||||
|
import { RecordGraph } from "@/components/graph/record-graph";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { AllergySeverity, LabFlag, Patient, Trend } from "@/lib/patients";
|
||||||
|
import type { Prescription } from "@/lib/prescriptions";
|
||||||
|
|
||||||
type BadgeVariant = "default" | "secondary" | "destructive" | "outline";
|
type BadgeVariant = "default" | "secondary" | "destructive" | "outline";
|
||||||
|
|
||||||
@@ -82,10 +90,18 @@ export function PatientDetail({
|
|||||||
patient,
|
patient,
|
||||||
onEdit,
|
onEdit,
|
||||||
onTransfer,
|
onTransfer,
|
||||||
|
onDelete,
|
||||||
|
prescriptions,
|
||||||
|
appointments,
|
||||||
|
invoices,
|
||||||
}: {
|
}: {
|
||||||
patient: Patient;
|
patient: Patient;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
onTransfer?: () => void;
|
onTransfer?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
prescriptions?: Prescription[];
|
||||||
|
appointments?: Appointment[];
|
||||||
|
invoices?: Invoice[];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const sex = t(`patientCard.sex.${patient.sex}`);
|
const sex = t(`patientCard.sex.${patient.sex}`);
|
||||||
@@ -135,6 +151,17 @@ export function PatientDetail({
|
|||||||
{t("patientCard.edit")}
|
{t("patientCard.edit")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<Button
|
||||||
|
aria-label={t("patients.delete.action")}
|
||||||
|
onClick={onDelete}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -159,6 +186,13 @@ export function PatientDetail({
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t("patientCard.graph.title")}>
|
||||||
|
<p className="mb-3 text-muted-foreground text-xs">
|
||||||
|
{t("patientCard.graph.hint")}
|
||||||
|
</p>
|
||||||
|
<RecordGraph patient={patient} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Section title={t("patientCard.vitals.title")}>
|
<Section title={t("patientCard.vitals.title")}>
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
|
||||||
<Stat label={t("patientCard.vitals.bp")} value={patient.vitals.bp} />
|
<Stat label={t("patientCard.vitals.bp")} value={patient.vitals.bp} />
|
||||||
@@ -301,6 +335,87 @@ export function PatientDetail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{appointments && (
|
||||||
|
<Section title={t("patientCard.appointments.title")}>
|
||||||
|
{appointments.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t("patientCard.appointments.empty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{appointments.map((appt) => (
|
||||||
|
<Row
|
||||||
|
key={appt.id}
|
||||||
|
label={`${appt.date} · ${appt.time}`}
|
||||||
|
value={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{appt.type}
|
||||||
|
<Badge variant="outline">
|
||||||
|
{t(`appointments.status.${appt.status}`)}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{prescriptions && (
|
||||||
|
<Section title={t("patientCard.prescriptions.title")}>
|
||||||
|
{prescriptions.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t("patientCard.prescriptions.empty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{prescriptions.map((rx) => (
|
||||||
|
<Row
|
||||||
|
key={rx.id}
|
||||||
|
label={rx.medication}
|
||||||
|
value={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{`${rx.dose} · ${rx.frequency}`}
|
||||||
|
<Badge variant="outline">
|
||||||
|
{t(`prescriptions.status.${rx.status}`)}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{invoices && (
|
||||||
|
<Section title={t("patientCard.invoices.title")}>
|
||||||
|
{invoices.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t("patientCard.invoices.empty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{invoices.map((inv) => (
|
||||||
|
<Row
|
||||||
|
key={inv.id}
|
||||||
|
label={inv.number}
|
||||||
|
value={
|
||||||
|
<span className="flex items-center gap-2 tabular-nums">
|
||||||
|
{formatMoney(invoiceTotal(inv))}
|
||||||
|
<Badge variant="outline">
|
||||||
|
{t(`invoices.status.${inv.status}`)}
|
||||||
|
</Badge>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,6 +206,16 @@
|
|||||||
"successBody": "{{name}} is now with {{provider}}.",
|
"successBody": "{{name}} is now with {{provider}}.",
|
||||||
"errorTitle": "Couldn't transfer patient",
|
"errorTitle": "Couldn't transfer patient",
|
||||||
"error": "Please try again."
|
"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": {
|
"appointments": {
|
||||||
@@ -1022,6 +1032,23 @@
|
|||||||
"recent": "{{count}} recent",
|
"recent": "{{count}} recent",
|
||||||
"empty": "No visits yet."
|
"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": {
|
"trend": {
|
||||||
"empty": "No trend data yet.",
|
"empty": "No trend data yet.",
|
||||||
"last": "{{label}} · last {{count}}",
|
"last": "{{label}} · last {{count}}",
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
await apiFetch<void>(
|
||||||
|
`/api/patients/${encodeURIComponent(fileNumber.trim())}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Reassign a patient to another clinician (sets their primary provider + PCP).
|
// Reassign a patient to another clinician (sets their primary provider + PCP).
|
||||||
export async function transferPatient(
|
export async function transferPatient(
|
||||||
fileNumber: string,
|
fileNumber: string,
|
||||||
|
|||||||
Generated
+2
@@ -22,6 +22,7 @@
|
|||||||
"@tiptap/pm": "^3.25.0",
|
"@tiptap/pm": "^3.25.0",
|
||||||
"@tiptap/react": "^3.25.0",
|
"@tiptap/react": "^3.25.0",
|
||||||
"@tiptap/starter-kit": "^3.25.0",
|
"@tiptap/starter-kit": "^3.25.0",
|
||||||
|
"@types/d3-force": "^3.0.10",
|
||||||
"@visx/curve": "^4.0.1-alpha.0",
|
"@visx/curve": "^4.0.1-alpha.0",
|
||||||
"@visx/event": "^4.0.1-alpha.0",
|
"@visx/event": "^4.0.1-alpha.0",
|
||||||
"@visx/gradient": "^4.0.1-alpha.0",
|
"@visx/gradient": "^4.0.1-alpha.0",
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"d3-array": "^3.2.4",
|
"d3-array": "^3.2.4",
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^12.40.0",
|
"framer-motion": "^12.40.0",
|
||||||
"i18next": "^26.3.1",
|
"i18next": "^26.3.1",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"@tiptap/pm": "^3.25.0",
|
"@tiptap/pm": "^3.25.0",
|
||||||
"@tiptap/react": "^3.25.0",
|
"@tiptap/react": "^3.25.0",
|
||||||
"@tiptap/starter-kit": "^3.25.0",
|
"@tiptap/starter-kit": "^3.25.0",
|
||||||
|
"@types/d3-force": "^3.0.10",
|
||||||
"@visx/curve": "^4.0.1-alpha.0",
|
"@visx/curve": "^4.0.1-alpha.0",
|
||||||
"@visx/event": "^4.0.1-alpha.0",
|
"@visx/event": "^4.0.1-alpha.0",
|
||||||
"@visx/gradient": "^4.0.1-alpha.0",
|
"@visx/gradient": "^4.0.1-alpha.0",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"d3-array": "^3.2.4",
|
"d3-array": "^3.2.4",
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^12.40.0",
|
"framer-motion": "^12.40.0",
|
||||||
"i18next": "^26.3.1",
|
"i18next": "^26.3.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user