mirror of
https://github.com/temetro/temetro.git
synced 2026-08-31 04:47:57 +00:00
feat: per-patient record history + summary PDF export
Add GET /api/activity/patient/:fileNumber (every audited change on one chart, newest first, readable by any clinic member) and surface it as a Record history timeline in the patient detail sheet. Add a Download summary action that builds a clean, printable one-page clinical summary in the browser (Save as PDF) — no PDF dependency, nothing leaves the server. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeftRight, Network, Pencil, Trash2 } from "lucide-react";
|
||||
import { ArrowLeftRight, FileDown, Network, Pencil, 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 {
|
||||
@@ -39,7 +41,14 @@ type RecordFile = {
|
||||
rows: { label: string; value: string }[];
|
||||
};
|
||||
|
||||
type BadgeVariant = "default" | "secondary" | "destructive" | "outline";
|
||||
type BadgeVariant =
|
||||
| "default"
|
||||
| "secondary"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "success"
|
||||
| "info"
|
||||
| "warning";
|
||||
|
||||
const severityVariant: Record<AllergySeverity, BadgeVariant> = {
|
||||
mild: "outline",
|
||||
@@ -53,8 +62,8 @@ const labFlagVariant: Record<LabFlag, BadgeVariant> = {
|
||||
critical: "destructive",
|
||||
};
|
||||
const statusVariant: Record<Patient["status"], BadgeVariant> = {
|
||||
active: "secondary",
|
||||
inpatient: "destructive",
|
||||
active: "success",
|
||||
inpatient: "info",
|
||||
discharged: "outline",
|
||||
};
|
||||
|
||||
@@ -105,6 +114,60 @@ function TrendBlock({ trend }: { trend: Trend }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 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({
|
||||
@@ -200,6 +263,15 @@ export function PatientDetail({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 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}
|
||||
@@ -533,6 +605,8 @@ export function PatientDetail({
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<RecordHistory fileNumber={patient.fileNumber} />
|
||||
|
||||
<Dialog
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setOpenFile(null);
|
||||
|
||||
Reference in New Issue
Block a user