From 0ab2e8b38351fdf86e9c340010b8b6887c3f9934 Mon Sep 17 00:00:00 2001 From: m Date: Wed, 25 Mar 2026 13:59:48 +0100 Subject: [PATCH] feat: add document management frontend (Phase 2N) - DocumentUpload: dropzone with multi-file support, upload via POST /api/cases/{id}/documents, progress feedback with toast - DocumentList: type badges, file size, upload date, download links, delete with inline confirmation - Integrated as Dokumente tab in case detail page with count badge - Eagerly fetches document count for tab badge display --- frontend/src/app/(app)/cases/[id]/page.tsx | 55 ++----- .../src/components/documents/DocumentList.tsx | 144 ++++++++++++++++++ .../components/documents/DocumentUpload.tsx | 144 ++++++++++++++++++ 3 files changed, 300 insertions(+), 43 deletions(-) create mode 100644 frontend/src/components/documents/DocumentList.tsx create mode 100644 frontend/src/components/documents/DocumentUpload.tsx diff --git a/frontend/src/app/(app)/cases/[id]/page.tsx b/frontend/src/app/(app)/cases/[id]/page.tsx index 6fc710a..1e31a77 100644 --- a/frontend/src/app/(app)/cases/[id]/page.tsx +++ b/frontend/src/app/(app)/cases/[id]/page.tsx @@ -6,6 +6,8 @@ import { api } from "@/lib/api"; import type { Case, CaseEvent, Party, Deadline, Document } from "@/lib/types"; import { CaseTimeline } from "@/components/cases/CaseTimeline"; import { PartyList } from "@/components/cases/PartyList"; +import { DocumentUpload } from "@/components/documents/DocumentUpload"; +import { DocumentList } from "@/components/documents/DocumentList"; import { ArrowLeft, Clock, FileText, Users, Activity } from "lucide-react"; import { format } from "date-fns"; import { de } from "date-fns/locale"; @@ -55,7 +57,7 @@ export default function CaseDetailPage() { const { data: documentsData } = useQuery({ queryKey: ["case-documents", id], queryFn: () => api.get(`/cases/${id}/documents`), - enabled: activeTab === "documents", + enabled: activeTab === "documents" || activeTab === "timeline", }); if (isLoading) { @@ -147,6 +149,11 @@ export default function CaseDetailPage() { {caseDetail.deadlines_count} )} + {tab.key === "documents" && documents.length > 0 && ( + + {documents.length} + + )} {tab.key === "parties" && caseDetail.parties.length > 0 && ( {caseDetail.parties.length} @@ -167,7 +174,10 @@ export default function CaseDetailPage() { )} {activeTab === "documents" && ( - +
+ + +
)} {activeTab === "parties" && ( @@ -224,44 +234,3 @@ function DeadlinesList({ deadlines }: { deadlines: Deadline[] }) { ); } -function DocumentsList({ documents }: { documents: Document[] }) { - if (documents.length === 0) { - return ( -

- Keine Dokumente vorhanden. -

- ); - } - - return ( -
- {documents.map((doc) => ( -
-
- -
-

- {doc.title} -

-
- {doc.doc_type && {doc.doc_type}} - {doc.file_size && ( - {(doc.file_size / 1024).toFixed(0)} KB - )} -
-
-
- - Herunterladen - -
- ))} -
- ); -} diff --git a/frontend/src/components/documents/DocumentList.tsx b/frontend/src/components/documents/DocumentList.tsx new file mode 100644 index 0000000..b3746cd --- /dev/null +++ b/frontend/src/components/documents/DocumentList.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { FileText, Download, Trash2, Loader2 } from "lucide-react"; +import { format } from "date-fns"; +import { de } from "date-fns/locale"; +import { toast } from "sonner"; +import { api } from "@/lib/api"; +import type { Document } from "@/lib/types"; + +const DOC_TYPE_BADGE: Record = { + schriftsatz: "bg-blue-50 text-blue-700", + beschluss: "bg-violet-50 text-violet-700", + urteil: "bg-emerald-50 text-emerald-700", + gutachten: "bg-amber-50 text-amber-700", + vertrag: "bg-cyan-50 text-cyan-700", + korrespondenz: "bg-neutral-100 text-neutral-600", +}; + +interface DocumentListProps { + documents: Document[]; + caseId: string; +} + +export function DocumentList({ documents, caseId }: DocumentListProps) { + const [deleteId, setDeleteId] = useState(null); + const queryClient = useQueryClient(); + + const deleteMutation = useMutation({ + mutationFn: (docId: string) => api.delete(`/documents/${docId}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["case-documents", caseId] }); + queryClient.invalidateQueries({ queryKey: ["case", caseId] }); + toast.success("Dokument geloescht"); + setDeleteId(null); + }, + onError: (err) => { + const msg = + err && typeof err === "object" && "error" in err + ? (err as { error: string }).error + : "Unbekannter Fehler"; + toast.error(`Fehler beim Loeschen: ${msg}`); + setDeleteId(null); + }, + }); + + if (documents.length === 0) { + return ( +

+ Keine Dokumente vorhanden. +

+ ); + } + + return ( +
+ {documents.map((doc) => ( +
+
+ +
+

+ {doc.title} +

+
+ {doc.doc_type && ( + + {doc.doc_type} + + )} + {doc.file_size != null && ( + {formatFileSize(doc.file_size)} + )} + + {format(new Date(doc.created_at), "d. MMM yyyy", { + locale: de, + })} + +
+
+
+ +
+ + + + + {deleteId === doc.id ? ( +
+ + +
+ ) : ( + + )} +
+
+ ))} +
+ ); +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/frontend/src/components/documents/DocumentUpload.tsx b/frontend/src/components/documents/DocumentUpload.tsx new file mode 100644 index 0000000..068c5e4 --- /dev/null +++ b/frontend/src/components/documents/DocumentUpload.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useDropzone } from "react-dropzone"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Upload, FileText, X, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { api } from "@/lib/api"; +import type { Document } from "@/lib/types"; + +interface DocumentUploadProps { + caseId: string; +} + +export function DocumentUpload({ caseId }: DocumentUploadProps) { + const [files, setFiles] = useState([]); + const queryClient = useQueryClient(); + + const uploadMutation = useMutation({ + mutationFn: async (file: File) => { + const formData = new FormData(); + formData.append("file", file); + formData.append("title", file.name); + return api.postFormData(`/cases/${caseId}/documents`, formData); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["case-documents", caseId] }); + queryClient.invalidateQueries({ queryKey: ["case", caseId] }); + }, + }); + + const onDrop = useCallback((acceptedFiles: File[]) => { + setFiles((prev) => [...prev, ...acceptedFiles]); + }, []); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + disabled: uploadMutation.isPending, + }); + + function removeFile(index: number) { + setFiles((prev) => prev.filter((_, i) => i !== index)); + } + + async function handleUpload() { + if (files.length === 0) return; + + let successCount = 0; + for (const file of files) { + try { + await uploadMutation.mutateAsync(file); + successCount++; + } catch (err) { + const msg = + err && typeof err === "object" && "error" in err + ? (err as { error: string }).error + : file.name; + toast.error(`Fehler beim Hochladen: ${msg}`); + } + } + + if (successCount > 0) { + toast.success( + successCount === 1 + ? "Dokument hochgeladen" + : `${successCount} Dokumente hochgeladen`, + ); + setFiles([]); + } + } + + return ( +
+
+ + +

+ Dateien hierher ziehen oder{" "} + durchsuchen +

+

Max. 50 MB pro Datei

+
+ + {files.length > 0 && ( +
+ {files.map((file, i) => ( +
+ +
+

{file.name}

+

+ {formatFileSize(file.size)} +

+
+ +
+ ))} + + +
+ )} +
+ ); +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +}