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">