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:
Khalid Abdi
2026-06-18 20:04:03 +03:00
parent 67cafdac3d
commit b1abb29108
21 changed files with 4574 additions and 14 deletions
@@ -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">
+30
View File
@@ -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>
);
}
+70
View File
@@ -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.017.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",