mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
36461a5498
Patient record: - Add `bloodType` and `phone` to the patient model (schema, canonical types on both backend + frontend, zod validation). `phone` is a demographic field (reception can read/write); `bloodType` is clinical PHI, redacted for the reception role. Surface both in the record sheet, chat summary card, and the add/edit patient form. Migration 0033. Clinic location: - New org-scoped `clinic_settings` table (address/city/country + optional lat/long), service, and routes: GET /api/clinic/settings (any clinician) and PUT /api/clinic/location (owner/admin). Edited in Settings → Signing → Clinic location. Consumed later by the wallet app. Migration 0034. i18n: - Translate all new keys into every shipped locale (en/de/fr/ar/so) and document the "translate into every locale" rule in frontend/CLAUDE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
699 lines
22 KiB
TypeScript
699 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
ArrowLeftRight,
|
|
FileDown,
|
|
Mic,
|
|
Network,
|
|
Pencil,
|
|
Send,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import { type ReactNode, useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import { Sparkline } from "@/components/chat/sparkline";
|
|
import { AttachmentsSection } from "@/components/patients/patient-files";
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
import { type ActivityEntry, listPatientActivity } from "@/lib/activity";
|
|
import { printPatientSummary } from "@/lib/patient-pdf";
|
|
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 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";
|
|
import { listProviders, type Provider, specialtyLabel } from "@/lib/staff";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
// A record "file" surfaced both in the graph and the sheet's clickable list.
|
|
type RecordFile = {
|
|
id: string;
|
|
kind: "problem" | "visit";
|
|
title: string;
|
|
sub: string;
|
|
rows: { label: string; value: string }[];
|
|
};
|
|
|
|
type BadgeVariant =
|
|
| "default"
|
|
| "secondary"
|
|
| "destructive"
|
|
| "outline"
|
|
| "success"
|
|
| "info"
|
|
| "warning";
|
|
|
|
const severityVariant: Record<AllergySeverity, BadgeVariant> = {
|
|
mild: "outline",
|
|
moderate: "secondary",
|
|
severe: "destructive",
|
|
};
|
|
const labFlagVariant: Record<LabFlag, BadgeVariant> = {
|
|
normal: "outline",
|
|
low: "secondary",
|
|
high: "secondary",
|
|
critical: "destructive",
|
|
};
|
|
const statusVariant: Record<Patient["status"], BadgeVariant> = {
|
|
active: "success",
|
|
inpatient: "info",
|
|
discharged: "outline",
|
|
};
|
|
|
|
function Section({ title, children }: { title: string; children: ReactNode }) {
|
|
return (
|
|
<section className="rounded-2xl border bg-card/30 p-4">
|
|
<h3 className="mb-3 font-medium text-foreground text-sm">{title}</h3>
|
|
{children}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Stat({ label, value }: { label: string; value: ReactNode }) {
|
|
return (
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-muted-foreground text-xs">{label}</span>
|
|
<span className="text-foreground text-sm">{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Row({ label, value }: { label: ReactNode; value: ReactNode }) {
|
|
return (
|
|
<div className="flex items-baseline justify-between gap-3">
|
|
<span className="text-foreground text-sm">{label}</span>
|
|
<span className="shrink-0 text-muted-foreground text-sm">{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TrendBlock({ trend }: { trend: Trend }) {
|
|
if (trend.points.length === 0) return null;
|
|
return (
|
|
<div className="mt-3 flex flex-col gap-1.5">
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<span className="text-muted-foreground text-xs uppercase tracking-wide">
|
|
{trend.label}
|
|
</span>
|
|
<span className="text-foreground text-sm">
|
|
{trend.points.at(-1)}
|
|
<span className="text-muted-foreground"> {trend.unit}</span>
|
|
</span>
|
|
</div>
|
|
<div className="text-primary">
|
|
<Sparkline className="h-12" points={trend.points} unit={trend.unit} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// The patient's record history: every audited add/change on this chart, newest
|
|
// first. Reuses the clinic activity log scoped to this file number.
|
|
function RecordHistory({ fileNumber }: { fileNumber: string }) {
|
|
const { t } = useTranslation();
|
|
const [entries, setEntries] = useState<ActivityEntry[] | null>(null);
|
|
const [error, setError] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
listPatientActivity(fileNumber)
|
|
.then((e) => active && setEntries(e))
|
|
.catch(() => active && setError(true));
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [fileNumber]);
|
|
|
|
return (
|
|
<Section title={t("patientCard.history.title")}>
|
|
{error ? (
|
|
<p className="text-muted-foreground text-sm">
|
|
{t("patientCard.history.loadError")}
|
|
</p>
|
|
) : entries === null ? (
|
|
<p className="text-muted-foreground text-sm">{t("patients.loading")}</p>
|
|
) : entries.length === 0 ? (
|
|
<p className="text-muted-foreground text-sm">
|
|
{t("patientCard.history.empty")}
|
|
</p>
|
|
) : (
|
|
<ol className="flex flex-col gap-3">
|
|
{entries.map((e) => (
|
|
<li className="flex items-start gap-3" key={e.id}>
|
|
<Avatar className="mt-0.5 size-7 shrink-0">
|
|
<AvatarFallback className="text-[11px]">
|
|
{e.actorInitials}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<span className="text-foreground text-sm">
|
|
<span className="font-medium">{e.actorName}</span> {e.action}
|
|
</span>
|
|
<span className="text-muted-foreground text-xs">
|
|
{new Date(e.createdAt).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ol>
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
// Full patient record laid out vertically for the side Sheet — plain full-width
|
|
// sections (no fixed-width cards, no nested click-to-expand dialogs).
|
|
export function PatientDetail({
|
|
patient,
|
|
onEdit,
|
|
onScribe,
|
|
onWalletPush,
|
|
onTransfer,
|
|
onDelete,
|
|
onOpenGraph,
|
|
prescriptions,
|
|
appointments,
|
|
invoices,
|
|
}: {
|
|
patient: Patient;
|
|
onEdit?: () => void;
|
|
// Opens the ambient AI visit scribe (record/transcribe → draft note).
|
|
onScribe?: () => void;
|
|
// Pushes the record to the patient's wallet (only when wallet-linked).
|
|
onWalletPush?: () => void;
|
|
onTransfer?: () => void;
|
|
onDelete?: () => void;
|
|
// Pops the record graph out into its own dialog (closing this sheet).
|
|
onOpenGraph?: () => void;
|
|
prescriptions?: Prescription[];
|
|
appointments?: Appointment[];
|
|
invoices?: Invoice[];
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const sex = t(`patientCard.sex.${patient.sex}`);
|
|
const idLine = `${patient.age} · ${sex} · MRN ${patient.fileNumber}`;
|
|
// The record "file" opened in a detail dialog from the records list.
|
|
const [openFile, setOpenFile] = useState<RecordFile | null>(null);
|
|
|
|
// Resolve the responsible clinician's specialty (set by an admin in Care
|
|
// Team) to show alongside the primary-care provider.
|
|
const [providers, setProviders] = useState<Provider[]>([]);
|
|
useEffect(() => {
|
|
if (!patient.primaryProviderId) return;
|
|
let active = true;
|
|
listProviders()
|
|
.then((p) => active && setProviders(p))
|
|
.catch(() => {});
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [patient.primaryProviderId]);
|
|
const providerSpecialty = specialtyLabel(
|
|
t,
|
|
providers.find((p) => p.userId === patient.primaryProviderId)?.specialty,
|
|
);
|
|
|
|
// The same problems + visits the graph plots, as a clickable list.
|
|
const files: RecordFile[] = [
|
|
...patient.problems.map((p, i) => ({
|
|
id: `prob-${i}`,
|
|
kind: "problem" as const,
|
|
title: p.label,
|
|
sub: p.since,
|
|
rows: [{ label: t("patientCard.graph.fields.since"), value: p.since }],
|
|
})),
|
|
...patient.encounters.map((e, i) => ({
|
|
id: `enc-${i}`,
|
|
kind: "visit" as const,
|
|
title: e.type,
|
|
sub: e.date,
|
|
rows: [
|
|
{ label: t("patientCard.graph.fields.date"), value: e.date },
|
|
{ label: t("patientCard.graph.fields.provider"), value: e.provider },
|
|
{ label: t("patientCard.graph.fields.summary"), value: e.summary },
|
|
].filter((r) => r.value),
|
|
})),
|
|
];
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex flex-col gap-3">
|
|
{/* Identity — full width so the name never gets squeezed by the actions. */}
|
|
<div className="flex items-start gap-3">
|
|
<Avatar className="size-12">
|
|
<AvatarFallback>{patient.initials}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="font-semibold text-base text-foreground">
|
|
{patient.name}
|
|
</span>
|
|
<Badge variant={statusVariant[patient.status]}>
|
|
{t(`patients.status.${patient.status}`)}
|
|
</Badge>
|
|
</div>
|
|
<span className="text-muted-foreground text-sm">{idLine}</span>
|
|
{patient.alerts.length > 0 && (
|
|
<div className="mt-1 flex flex-wrap gap-1.5">
|
|
{patient.alerts.map((alert) => (
|
|
<Badge key={alert} variant="outline">
|
|
{alert}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{/* Actions — their own wrapping row beneath the identity. */}
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
onClick={() => printPatientSummary(patient, t)}
|
|
size="sm"
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
<FileDown className="size-4" />
|
|
{t("patientCard.exportPdf")}
|
|
</Button>
|
|
{onTransfer && (
|
|
<Button
|
|
onClick={onTransfer}
|
|
size="sm"
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
<ArrowLeftRight className="size-4" />
|
|
{t("patients.transfer.action")}
|
|
</Button>
|
|
)}
|
|
{onScribe && (
|
|
<Button onClick={onScribe} size="sm" type="button" variant="outline">
|
|
<Mic className="size-4" />
|
|
{t("scribe.recordVisit")}
|
|
</Button>
|
|
)}
|
|
{onEdit && (
|
|
<Button onClick={onEdit} size="sm" type="button" variant="outline">
|
|
<Pencil className="size-4" />
|
|
{t("patientCard.edit")}
|
|
</Button>
|
|
)}
|
|
{onWalletPush && (
|
|
<Button
|
|
onClick={onWalletPush}
|
|
size="sm"
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
<Send className="size-4" />
|
|
{t("walletPush.action")}
|
|
</Button>
|
|
)}
|
|
{onDelete && (
|
|
<Button
|
|
aria-label={t("patients.delete.action")}
|
|
className="ms-auto"
|
|
onClick={onDelete}
|
|
size="sm"
|
|
type="button"
|
|
variant="destructive"
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Section title={t("patientCard.overview")}>
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
|
<Stat
|
|
label={t("patientCard.summary.primaryCare")}
|
|
value={
|
|
providerSpecialty ? `${patient.pcp} · ${providerSpecialty}` : patient.pcp
|
|
}
|
|
/>
|
|
<Stat
|
|
label={t("patientCard.summary.lastSeen")}
|
|
value={patient.encounters[0]?.date ?? "—"}
|
|
/>
|
|
<Stat
|
|
label={t("patientCard.summary.activeMeds")}
|
|
value={patient.medications.length}
|
|
/>
|
|
<Stat
|
|
label={t("patientCard.summary.openProblems")}
|
|
value={patient.problems.length}
|
|
/>
|
|
<Stat
|
|
label={t("patientCard.summary.bloodType")}
|
|
value={patient.bloodType || "—"}
|
|
/>
|
|
<Stat
|
|
label={t("patientCard.summary.phone")}
|
|
value={patient.phone || "—"}
|
|
/>
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title={t("patientCard.graph.title")}>
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<p className="text-muted-foreground text-xs">
|
|
{t("patientCard.graph.hint")}
|
|
</p>
|
|
{onOpenGraph && (
|
|
<Button
|
|
className="shrink-0"
|
|
onClick={onOpenGraph}
|
|
size="sm"
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
<Network className="size-4" />
|
|
{t("patientCard.graph.open")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{files.length === 0 ? (
|
|
<p className="text-muted-foreground text-sm">
|
|
{t("patientCard.graph.empty")}
|
|
</p>
|
|
) : (
|
|
<div className="divide-y divide-border overflow-hidden rounded-xl border bg-card/30">
|
|
{files.map((file) => (
|
|
<button
|
|
className="flex w-full items-center gap-2.5 px-3 py-2 text-start transition-colors hover:bg-accent"
|
|
key={file.id}
|
|
onClick={() => setOpenFile(file)}
|
|
type="button"
|
|
>
|
|
<span
|
|
className={cn(
|
|
"size-2.5 shrink-0 rounded-full",
|
|
file.kind === "problem"
|
|
? "bg-destructive"
|
|
: "bg-foreground/55",
|
|
)}
|
|
/>
|
|
<span className="min-w-0 flex-1 truncate text-foreground text-sm">
|
|
{file.title}
|
|
</span>
|
|
<span className="shrink-0 text-muted-foreground text-xs">
|
|
{file.sub}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Section>
|
|
|
|
<AttachmentsSection fileNumber={patient.fileNumber} />
|
|
|
|
<Section title={t("patientCard.vitals.title")}>
|
|
<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.hr")} value={patient.vitals.hr} />
|
|
<Stat
|
|
label={t("patientCard.vitals.temp")}
|
|
value={patient.vitals.temp}
|
|
/>
|
|
<Stat
|
|
label={t("patientCard.vitals.spo2")}
|
|
value={patient.vitals.spo2}
|
|
/>
|
|
</div>
|
|
<p className="mt-2 text-muted-foreground text-xs">
|
|
{t("patientCard.vitals.taken", { at: patient.vitals.takenAt })}
|
|
</p>
|
|
<TrendBlock trend={patient.vitalsTrend} />
|
|
</Section>
|
|
|
|
<Section title={t("patientCard.labs.title")}>
|
|
{patient.labs.length === 0 ? (
|
|
<p className="text-muted-foreground text-sm">
|
|
{t("patientCard.labs.empty")}
|
|
</p>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
{patient.labs.map((lab) => (
|
|
<Row
|
|
key={lab.name}
|
|
label={lab.name}
|
|
value={
|
|
<span className="flex items-center gap-2">
|
|
{lab.value}
|
|
<Badge variant={labFlagVariant[lab.flag]}>
|
|
{t(`patientCard.labFlag.${lab.flag}`)}
|
|
</Badge>
|
|
</span>
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
<TrendBlock trend={patient.labTrend} />
|
|
</Section>
|
|
|
|
<Section title={t("patientCard.medications.title")}>
|
|
{patient.medications.length === 0 ? (
|
|
<p className="text-muted-foreground text-sm">
|
|
{t("patientCard.medications.empty")}
|
|
</p>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
{patient.medications.map((med) => (
|
|
<Row
|
|
key={med.name}
|
|
label={med.name}
|
|
value={`${med.dose} · ${med.frequency}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Section>
|
|
|
|
<Section title={t("patientCard.problems.title")}>
|
|
{patient.problems.length === 0 ? (
|
|
<p className="text-muted-foreground text-sm">
|
|
{t("patientCard.problems.empty")}
|
|
</p>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
{patient.problems.map((problem) => (
|
|
<Row
|
|
key={problem.label}
|
|
label={problem.label}
|
|
value={t("patientCard.problems.since", { date: problem.since })}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Section>
|
|
|
|
<Section title={t("patientCard.allergies.title")}>
|
|
{patient.allergies.length === 0 ? (
|
|
<p className="text-muted-foreground text-sm">
|
|
{t("patientCard.allergies.none")}
|
|
</p>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
{patient.allergies.map((allergy) => (
|
|
<Row
|
|
key={allergy.substance}
|
|
label={
|
|
<>
|
|
{allergy.substance}
|
|
<span className="text-muted-foreground">
|
|
{" "}
|
|
— {allergy.reaction}
|
|
</span>
|
|
</>
|
|
}
|
|
value={
|
|
<Badge variant={severityVariant[allergy.severity]}>
|
|
{t(`patientCard.severity.${allergy.severity}`)}
|
|
</Badge>
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Section>
|
|
|
|
<Section title={t("patientCard.visits.title")}>
|
|
{patient.encounters.length === 0 ? (
|
|
<p className="text-muted-foreground text-sm">
|
|
{t("patientCard.visits.empty")}
|
|
</p>
|
|
) : (
|
|
<div className="flex flex-col gap-3">
|
|
{patient.encounters.map((encounter) => (
|
|
<div
|
|
className="flex flex-col gap-0.5"
|
|
key={encounter.date + encounter.type}
|
|
>
|
|
<div className="flex items-baseline justify-between gap-3">
|
|
<span className="text-foreground text-sm">
|
|
{encounter.type}
|
|
</span>
|
|
<span className="shrink-0 text-muted-foreground text-xs">
|
|
{encounter.date}
|
|
</span>
|
|
</div>
|
|
<span className="text-muted-foreground text-sm">
|
|
{encounter.summary}
|
|
</span>
|
|
<span className="text-muted-foreground text-xs">
|
|
{encounter.provider}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</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>
|
|
)}
|
|
|
|
<RecordHistory fileNumber={patient.fileNumber} />
|
|
|
|
<Dialog
|
|
onOpenChange={(o) => {
|
|
if (!o) setOpenFile(null);
|
|
}}
|
|
open={openFile !== null}
|
|
>
|
|
<DialogPopup className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2.5">
|
|
<span
|
|
className={cn(
|
|
"size-2.5 shrink-0 rounded-full",
|
|
openFile?.kind === "problem"
|
|
? "bg-destructive"
|
|
: "bg-foreground/55",
|
|
)}
|
|
/>
|
|
{openFile?.title}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{openFile
|
|
? t(`patientCard.graph.kind.${openFile.kind}`)
|
|
: ""}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogPanel className="flex flex-col gap-3">
|
|
{openFile?.rows.map((row) => (
|
|
<div className="flex flex-col gap-0.5" key={row.label}>
|
|
<span className="text-muted-foreground text-xs">
|
|
{row.label}
|
|
</span>
|
|
<span className="text-foreground text-sm">{row.value}</span>
|
|
</div>
|
|
))}
|
|
</DialogPanel>
|
|
<DialogFooter>
|
|
<DialogClose render={<Button type="button" variant="outline" />}>
|
|
{t("patientCard.graph.close")}
|
|
</DialogClose>
|
|
</DialogFooter>
|
|
</DialogPopup>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|