mirror of
https://github.com/temetro/temetro.git
synced 2026-08-27 19:06:52 +00:00
feat: patient & lab file attachments (real backend storage)
Add a real file-storage layer to the backend and wire upload UI into the frontend. backend: - new `attachments` table (org-scoped, links to a patient file number and optionally a lab result) + Drizzle migration - `/api/attachments` route: upload (multer → disk under UPLOAD_DIR), list, stream/download, delete; gated by patient:write OR lab:write via a new requireAnyPermission helper so lab staff can attach analyses - UPLOAD_DIR env (default ./uploads) + a persistent docker volume frontend: - lib/attachments.ts client (multipart upload, list, delete, preview URL) - staged file picker in the patient Add/Edit dialog (uploaded after save) and the lab Add-result dialog (linked to the result) - a Files section in the patient sheet that lists attachments and opens them in a preview dialog (images inline, others via download) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { CalendarIcon, Plus, RefreshCw, X } from "lucide-react";
|
||||
import { type FormEvent, type ReactNode, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { StagedFilesField } from "@/components/patients/patient-files";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
type Patient,
|
||||
updatePatient,
|
||||
} from "@/lib/patients";
|
||||
import { uploadAttachment } from "@/lib/attachments";
|
||||
import { hasClinicalAccess, useActiveRole } from "@/lib/roles";
|
||||
import { listProviders, type Provider } from "@/lib/staff";
|
||||
import { notify } from "@/lib/toast";
|
||||
@@ -217,6 +219,9 @@ export function PatientFormDialog({
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Files staged in the form, uploaded once the patient record is saved (so the
|
||||
// attachment can be linked to the file number).
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
|
||||
const [fileNumber, setFileNumber] = useState(() =>
|
||||
isEdit && patient ? patient.fileNumber : generateFileNumber()
|
||||
@@ -334,6 +339,21 @@ export function PatientFormDialog({
|
||||
const saved = isEdit
|
||||
? await updatePatient(built)
|
||||
: await createPatient(built);
|
||||
// Upload any staged files now that we have a saved file number.
|
||||
if (files.length > 0) {
|
||||
const results = await Promise.allSettled(
|
||||
files.map((file) =>
|
||||
uploadAttachment({ file, fileNumber: saved.fileNumber }),
|
||||
),
|
||||
);
|
||||
if (results.some((r) => r.status === "rejected")) {
|
||||
notify.error(
|
||||
t("patientFiles.uploadFailedTitle"),
|
||||
t("patientFiles.uploadFailedBody"),
|
||||
);
|
||||
}
|
||||
setFiles([]);
|
||||
}
|
||||
if (isEdit) {
|
||||
onSaved?.(saved);
|
||||
notify.success(
|
||||
@@ -669,6 +689,8 @@ export function PatientFormDialog({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<StagedFilesField onChange={setFiles} value={files} />
|
||||
</DialogPanel>
|
||||
|
||||
<DialogFooter className="flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { StagedFilesField } from "@/components/patients/patient-files";
|
||||
import { uploadAttachment } from "@/lib/attachments";
|
||||
import { LAB_ANALYSES, LAB_ANALYSIS_UNITS } from "@/lib/lab-analyses";
|
||||
import {
|
||||
type Lab,
|
||||
@@ -173,6 +175,8 @@ function AddResultDialog({
|
||||
const [advanced, setAdvanced] = useState(false);
|
||||
const [refRange, setRefRange] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
// Analysis files (PDF/image) attached to this result.
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
|
||||
const reset = () => {
|
||||
setPatient(null);
|
||||
@@ -184,6 +188,7 @@ function AddResultDialog({
|
||||
setTakenAt(today());
|
||||
setAdvanced(false);
|
||||
setRefRange("");
|
||||
setFiles([]);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
@@ -250,6 +255,25 @@ function AddResultDialog({
|
||||
setSaving(true);
|
||||
try {
|
||||
await appendLabs(patient.fileNumber, [lab]);
|
||||
// Attach any analysis files to this result (best-effort).
|
||||
if (files.length > 0) {
|
||||
const labKey = `${lab.name} · ${lab.takenAt}`;
|
||||
const results = await Promise.allSettled(
|
||||
files.map((file) =>
|
||||
uploadAttachment({
|
||||
file,
|
||||
fileNumber: patient.fileNumber,
|
||||
labKey,
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (results.some((r) => r.status === "rejected")) {
|
||||
notify.error(
|
||||
t("patientFiles.uploadFailedTitle"),
|
||||
t("patientFiles.uploadFailedBody"),
|
||||
);
|
||||
}
|
||||
}
|
||||
notify.success(
|
||||
t("lab.addResult.addedTitle"),
|
||||
t("lab.addResult.addedBody", { test: lab.name, name: patient.name }),
|
||||
@@ -439,6 +463,12 @@ function AddResultDialog({
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<StagedFilesField
|
||||
label={t("lab.addResult.files")}
|
||||
onChange={setFiles}
|
||||
value={files}
|
||||
/>
|
||||
</DialogPanel>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { type ReactNode, 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 { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -285,6 +286,8 @@ export function PatientDetail({
|
||||
</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} />
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import { FileText, Paperclip, Trash2, Upload, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogPanel,
|
||||
DialogPopup,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
type Attachment,
|
||||
attachmentUrl,
|
||||
deleteAttachment,
|
||||
formatBytes,
|
||||
listAttachments,
|
||||
} from "@/lib/attachments";
|
||||
import { notify } from "@/lib/toast";
|
||||
|
||||
// A pick-and-stage field: files chosen here are held in `value` until the
|
||||
// parent uploads them (after the patient/lab record is saved). Used in the
|
||||
// patient form and the lab "add result" dialog.
|
||||
export function StagedFilesField({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
value: File[];
|
||||
onChange: (files: File[]) => void;
|
||||
label?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{label ?? t("patientFiles.title")}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => inputRef.current?.click()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t("patientFiles.add")}
|
||||
</Button>
|
||||
<input
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const picked = Array.from(e.target.files ?? []);
|
||||
if (picked.length) onChange([...value, ...picked]);
|
||||
e.target.value = "";
|
||||
}}
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
{value.length > 0 && (
|
||||
<div className="divide-y divide-border overflow-hidden rounded-xl border bg-card/30">
|
||||
{value.map((file, index) => (
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-3 py-2"
|
||||
key={`${file.name}-${index}`}
|
||||
>
|
||||
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-foreground text-sm">
|
||||
{file.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground text-xs">
|
||||
{formatBytes(file.size)}
|
||||
</span>
|
||||
<button
|
||||
aria-label={t("patientFiles.remove")}
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => onChange(value.filter((_, i) => i !== index))}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A dialog that previews an attachment: images inline, everything else as a
|
||||
// download link.
|
||||
function FilePreviewDialog({
|
||||
attachment,
|
||||
onClose,
|
||||
}: {
|
||||
attachment: Attachment | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isImage = attachment?.mimeType.startsWith("image/");
|
||||
return (
|
||||
<Dialog onOpenChange={(o) => !o && onClose()} open={attachment !== null}>
|
||||
<DialogPopup className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="truncate">{attachment?.filename}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogPanel className="flex flex-col items-center gap-3">
|
||||
{attachment && isImage ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
alt={attachment.filename}
|
||||
className="max-h-[60vh] w-auto rounded-lg border object-contain"
|
||||
src={attachmentUrl(attachment.id)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<FileText className="size-10 text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("patientFiles.noPreview")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</DialogPanel>
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||
{t("patientFiles.close")}
|
||||
</DialogClose>
|
||||
{attachment && (
|
||||
<Button
|
||||
render={
|
||||
<a
|
||||
href={attachmentUrl(attachment.id)}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t("patientFiles.open")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// The sheet's "Files" section: lists a patient's uploaded attachments, opens
|
||||
// one in a preview dialog, and lets a clinician delete it. `reloadKey` bumps to
|
||||
// refetch after a new upload elsewhere.
|
||||
export function AttachmentsSection({
|
||||
fileNumber,
|
||||
reloadKey = 0,
|
||||
canDelete = true,
|
||||
}: {
|
||||
fileNumber: string;
|
||||
reloadKey?: number;
|
||||
canDelete?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [items, setItems] = useState<Attachment[]>([]);
|
||||
const [preview, setPreview] = useState<Attachment | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
listAttachments(fileNumber)
|
||||
.then((rows) => active && setItems(rows))
|
||||
.catch(() => {
|
||||
/* missing permission / none — leave empty */
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [fileNumber, reloadKey]);
|
||||
|
||||
const remove = async (attachment: Attachment) => {
|
||||
try {
|
||||
await deleteAttachment(attachment.id);
|
||||
setItems((prev) => prev.filter((a) => a.id !== attachment.id));
|
||||
notify.success(t("patientFiles.deletedTitle"), attachment.filename);
|
||||
} catch {
|
||||
notify.error(
|
||||
t("patientFiles.deleteFailedTitle"),
|
||||
t("patientFiles.deleteFailedBody"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border bg-card/30 p-4">
|
||||
<h3 className="mb-3 font-medium text-foreground text-sm">
|
||||
{t("patientFiles.title")}
|
||||
</h3>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("patientFiles.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border overflow-hidden rounded-xl border bg-background/40">
|
||||
{items.map((attachment) => (
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-3 py-2"
|
||||
key={attachment.id}
|
||||
>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
|
||||
onClick={() => setPreview(attachment)}
|
||||
type="button"
|
||||
>
|
||||
<Paperclip className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-foreground text-sm">
|
||||
{attachment.filename}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground text-xs">
|
||||
{formatBytes(attachment.sizeBytes)}
|
||||
</span>
|
||||
</button>
|
||||
{canDelete && (
|
||||
<button
|
||||
aria-label={t("patientFiles.remove")}
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-destructive-foreground"
|
||||
onClick={() => remove(attachment)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<FilePreviewDialog attachment={preview} onClose={() => setPreview(null)} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Client for the backend attachments API (patient/lab file uploads). Uploads
|
||||
// use multipart/form-data so they bypass apiFetch (which forces a JSON body).
|
||||
|
||||
import { API_BASE_URL, ApiError, apiFetch } from "@/lib/api-client";
|
||||
|
||||
export type Attachment = {
|
||||
id: string;
|
||||
fileNumber: string | null;
|
||||
labKey: string | null;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
uploadedByName: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export function listAttachments(fileNumber: string): Promise<Attachment[]> {
|
||||
return apiFetch<Attachment[]>(
|
||||
`/api/attachments?fileNumber=${encodeURIComponent(fileNumber)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadAttachment(opts: {
|
||||
file: File;
|
||||
fileNumber: string;
|
||||
labKey?: string;
|
||||
}): Promise<Attachment> {
|
||||
const form = new FormData();
|
||||
form.append("file", opts.file);
|
||||
form.append("fileNumber", opts.fileNumber);
|
||||
if (opts.labKey) form.append("labKey", opts.labKey);
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/attachments`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
if (typeof window !== "undefined") window.location.href = "/login";
|
||||
throw new ApiError(401, "Not authenticated.");
|
||||
}
|
||||
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
| (Attachment & { error?: string })
|
||||
| null;
|
||||
if (!res.ok) {
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
body?.error ?? `Upload failed with status ${res.status}.`,
|
||||
);
|
||||
}
|
||||
return body as Attachment;
|
||||
}
|
||||
|
||||
export function deleteAttachment(id: string): Promise<void> {
|
||||
return apiFetch<void>(`/api/attachments/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Direct URL to stream/preview a stored file (auth via the session cookie).
|
||||
export function attachmentUrl(id: string): string {
|
||||
return `${API_BASE_URL}/api/attachments/${id}`;
|
||||
}
|
||||
|
||||
// "1.2 MB" / "734 KB" — compact size for display.
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
@@ -591,6 +591,7 @@
|
||||
"advancedHint": "Record a custom analysis with a reference range.",
|
||||
"refRange": "Reference range",
|
||||
"refRangePlaceholder": "e.g. 13.0–17.0 g/dL",
|
||||
"files": "Analysis files",
|
||||
"cancel": "Cancel",
|
||||
"submit": "Add result",
|
||||
"needPatientTitle": "Pick a patient",
|
||||
@@ -1088,6 +1089,20 @@
|
||||
"dismiss": "Dismiss"
|
||||
}
|
||||
},
|
||||
"patientFiles": {
|
||||
"title": "Files",
|
||||
"add": "Add files",
|
||||
"empty": "No files uploaded.",
|
||||
"remove": "Remove",
|
||||
"open": "Open",
|
||||
"close": "Close",
|
||||
"noPreview": "No preview available for this file type.",
|
||||
"deletedTitle": "File removed",
|
||||
"deleteFailedTitle": "Couldn't remove file",
|
||||
"deleteFailedBody": "Something went wrong, or you don't have permission. Please try again.",
|
||||
"uploadFailedTitle": "Some files didn't upload",
|
||||
"uploadFailedBody": "The record was saved, but one or more files failed to upload. Try adding them again from the record."
|
||||
},
|
||||
"patientCard": {
|
||||
"notFound": "No patient found for file #{{number}}.",
|
||||
"overview": "Overview",
|
||||
|
||||
Reference in New Issue
Block a user