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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user