mirror of
https://github.com/temetro/temetro.git
synced 2026-08-11 19:18:03 +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:
@@ -25,3 +25,18 @@ activityRouter.get("/", async (req, res, next) => {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// A single patient's record history (who added/changed what, when). Any clinic
|
||||
// member can read it — it's the audit trail for that chart.
|
||||
activityRouter.get("/patient/:fileNumber", async (req, res, next) => {
|
||||
try {
|
||||
res.json(
|
||||
await service.listPatientActivity(
|
||||
req.organizationId!,
|
||||
req.params.fileNumber as string,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -54,6 +54,28 @@ export async function recordActivity(params: {
|
||||
}
|
||||
}
|
||||
|
||||
// Lists every audit entry tied to a single patient (by file number), newest
|
||||
// first. Unlike the clinic feed this is NOT scoped to one actor: a patient's
|
||||
// record history should show every clinician who added or changed data on it.
|
||||
export async function listPatientActivity(
|
||||
orgId: string,
|
||||
fileNumber: string,
|
||||
limit = 100,
|
||||
): Promise<ActivityEntry[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(activityLog)
|
||||
.where(
|
||||
and(
|
||||
eq(activityLog.organizationId, orgId),
|
||||
eq(activityLog.patientFileNumber, fileNumber),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(activityLog.createdAt))
|
||||
.limit(limit);
|
||||
return rows.map(toEntry);
|
||||
}
|
||||
|
||||
// Lists the clinic's audit feed. When `actorId` is given, only that user's own
|
||||
// actions are returned (each employee sees their own activity); admins/owners
|
||||
// call without it to see the whole clinic.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -25,3 +25,12 @@ export type ActivityEntry = {
|
||||
export function listActivity(): Promise<ActivityEntry[]> {
|
||||
return apiFetch<ActivityEntry[]>("/api/activity");
|
||||
}
|
||||
|
||||
// A single patient's record history (every clinician's adds/changes on it).
|
||||
export function listPatientActivity(
|
||||
fileNumber: string,
|
||||
): Promise<ActivityEntry[]> {
|
||||
return apiFetch<ActivityEntry[]>(
|
||||
`/api/activity/patient/${encodeURIComponent(fileNumber)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import type { Patient } from "@/lib/patients";
|
||||
|
||||
// Build and print a clean, one-page clinical summary for a patient. We render an
|
||||
// isolated HTML document in a new window and trigger the browser's print dialog
|
||||
// (where the user picks "Save as PDF") — no PDF library needed, and the output
|
||||
// follows the user's locale via the passed `t`.
|
||||
|
||||
function esc(value: unknown): string {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function rows(items: { label: string; value: string }[]): string {
|
||||
return items
|
||||
.filter((r) => r.value)
|
||||
.map(
|
||||
(r) =>
|
||||
`<tr><td class="l">${esc(r.label)}</td><td class="v">${esc(r.value)}</td></tr>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function printPatientSummary(patient: Patient, t: TFunction): void {
|
||||
const sexLabel = t(`patientCard.sex.${patient.sex}`);
|
||||
const generated = new Date().toLocaleString();
|
||||
|
||||
const section = (title: string, body: string, empty: string): string =>
|
||||
`<section><h2>${esc(title)}</h2>${body || `<p class="empty">${esc(empty)}</p>`}</section>`;
|
||||
|
||||
const list = (items: string[]): string =>
|
||||
items.length
|
||||
? `<ul>${items.map((i) => `<li>${esc(i)}</li>`).join("")}</ul>`
|
||||
: "";
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${esc(patient.name)} — ${esc(t("patientCard.pdf.title"))}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||
color: #111; margin: 32px; font-size: 12px; line-height: 1.5; }
|
||||
header { border-bottom: 2px solid #111; padding-bottom: 12px; margin-bottom: 16px; }
|
||||
h1 { font-size: 20px; margin: 0 0 4px; }
|
||||
.sub { color: #555; font-size: 12px; }
|
||||
.gen { color: #888; font-size: 10px; margin-top: 6px; }
|
||||
section { margin-bottom: 16px; break-inside: avoid; }
|
||||
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .04em;
|
||||
border-bottom: 1px solid #ddd; padding-bottom: 4px; margin: 0 0 8px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
td { padding: 2px 0; vertical-align: top; }
|
||||
td.l { color: #555; width: 38%; padding-right: 12px; }
|
||||
td.v { color: #111; }
|
||||
ul { margin: 0; padding-left: 18px; }
|
||||
li { margin: 1px 0; }
|
||||
.empty { color: #999; font-style: italic; margin: 0; }
|
||||
@media print { body { margin: 16px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>${esc(patient.name)}</h1>
|
||||
<div class="sub">${esc(`${patient.age} · ${sexLabel} · ${t("patientCard.summary.mrn")} ${patient.fileNumber}`)}</div>
|
||||
<div class="gen">${esc(t("patientCard.pdf.generated", { date: generated }))}</div>
|
||||
</header>
|
||||
|
||||
${section(
|
||||
t("patientCard.overview"),
|
||||
`<table>${rows([
|
||||
{ label: t("patientCard.summary.primaryCare"), value: patient.pcp },
|
||||
{ label: t("patientCard.summary.lastSeen"), value: patient.encounters[0]?.date ?? "" },
|
||||
{ label: t("patientCard.summary.status"), value: t(`patients.status.${patient.status}`) },
|
||||
])}</table>`,
|
||||
"",
|
||||
)}
|
||||
|
||||
${section(
|
||||
t("patientCard.allergies.title"),
|
||||
list(
|
||||
patient.allergies.map((a) =>
|
||||
[a.substance, a.reaction, t(`patientCard.severity.${a.severity}`)]
|
||||
.filter(Boolean)
|
||||
.join(" — "),
|
||||
),
|
||||
),
|
||||
t("patientCard.allergies.none"),
|
||||
)}
|
||||
|
||||
${section(
|
||||
t("patientCard.problems.title"),
|
||||
list(
|
||||
patient.problems.map((p) =>
|
||||
p.since ? `${p.label} (${p.since})` : p.label,
|
||||
),
|
||||
),
|
||||
t("patientCard.problems.empty"),
|
||||
)}
|
||||
|
||||
${section(
|
||||
t("patientCard.medications.title"),
|
||||
list(
|
||||
patient.medications.map((m) =>
|
||||
[m.name, m.dose, m.frequency].filter(Boolean).join(" · "),
|
||||
),
|
||||
),
|
||||
t("patientCard.medications.empty"),
|
||||
)}
|
||||
|
||||
${section(
|
||||
t("patientCard.vitals.title"),
|
||||
`<table>${rows([
|
||||
{ label: t("patientCard.vitals.bp"), value: patient.vitals.bp },
|
||||
{ label: t("patientCard.vitals.hr"), value: patient.vitals.hr },
|
||||
{ label: t("patientCard.vitals.temp"), value: patient.vitals.temp },
|
||||
{ label: t("patientCard.vitals.spo2"), value: patient.vitals.spo2 },
|
||||
])}</table>`,
|
||||
"",
|
||||
)}
|
||||
|
||||
${section(
|
||||
t("patientCard.labs.title"),
|
||||
list(
|
||||
patient.labs.map((l) =>
|
||||
`${l.name}: ${l.value} (${t(`patientCard.labFlag.${l.flag}`)})`,
|
||||
),
|
||||
),
|
||||
t("patientCard.labs.empty"),
|
||||
)}
|
||||
|
||||
${section(
|
||||
t("patientCard.visits.title"),
|
||||
patient.encounters
|
||||
.map(
|
||||
(e) =>
|
||||
`<div style="margin-bottom:6px"><strong>${esc(e.type)}</strong> <span style="color:#888">${esc(e.date)} ${esc(e.provider)}</span><br/>${esc(e.summary)}</div>`,
|
||||
)
|
||||
.join(""),
|
||||
t("patientCard.visits.empty"),
|
||||
)}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const win = window.open("", "_blank", "width=820,height=1000");
|
||||
if (!win) return;
|
||||
win.document.open();
|
||||
win.document.write(html);
|
||||
win.document.close();
|
||||
win.focus();
|
||||
// Let layout settle before invoking print.
|
||||
win.setTimeout(() => win.print(), 250);
|
||||
}
|
||||
Reference in New Issue
Block a user