feat(frontend): Dokumente-Upload-Widget (Modul 29 Frontend)

Backend war bereits fertig, das Frontend fehlte bislang. Neue
gemeinsame DocumentUploadCard-Komponente (Liste hochgeladener Dateien,
Upload per Dateiauswahl, Download, Löschen mit Bestätigungsdialog da
unumkehrbar) plus dazugehöriges src/lib/documents/ API-Modul.

apiFetch erzwingt bislang immer Content-Type: application/json, was
multipart-Uploads verhindert hätte – daher neue apiFetchFormData-
Hilfsfunktion in api-client.ts ergänzt (kein manuell gesetzter
Content-Type, damit der Browser die multipart-Boundary selbst setzt).

Eingebunden auf allen Detailseiten, die laut TASKS.md Modul 29 vorsehen
und tatsächlich eine eigene Route haben: Bienenstand, Volk, Königin,
Honigcharge. Durchsicht hat keine eigene Detailseite (nur Einträge in
der Volk-Unterliste) und wird daher vorerst ausgelassen.

Live verifiziert: Upload, Download und Löschen (inkl. Bestätigungsdialog)
funktionieren End-to-End gegen das Backend, zero Konsolenfehler auf
allen vier Detailseiten.
This commit is contained in:
Nick Szaunig committed 2026-08-24 16:43:18 +02:00
1 parent 9a32c6c11d
commit a21563cc76
9 files changed
+242 -1

No files matched your search

@@ -0,0 +1,153 @@
"use client";
import { ChangeEvent, useRef } from "react";
import { FileText, Image as ImageIcon, Paperclip } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { deleteDocument, downloadDocument, fetchDocuments, uploadDocument } from "@/lib/documents/api";
import { DocumentEntityType } from "@/lib/documents/types";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/empty-state";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
const ALLOWED_MIME_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "application/pdf"]);
const MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024;
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function DocumentTypeIcon({ mimeType }: { mimeType: string }) {
return mimeType.startsWith("image/") ? (
<ImageIcon className="size-4 shrink-0 text-muted-foreground" />
) : (
<FileText className="size-4 shrink-0 text-muted-foreground" />
);
}
interface DocumentUploadCardProps {
entityType: DocumentEntityType;
entityId: string;
title?: string;
}
export function DocumentUploadCard({
entityType,
entityId,
title = "Dokumente",
}: DocumentUploadCardProps) {
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const queryKey = ["documents", entityType, entityId];
const { data: documents, isLoading } = useQuery({
queryKey,
queryFn: () => fetchDocuments(entityType, entityId),
});
const uploadMutation = useMutation({
mutationFn: (file: File) => uploadDocument(entityType, entityId, file),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey });
toast.success("Datei hochgeladen");
},
onError: () => toast.error("Datei konnte nicht hochgeladen werden"),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteDocument(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey });
toast.success("Datei gelöscht");
},
onError: () => toast.error("Datei konnte nicht gelöscht werden"),
});
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!ALLOWED_MIME_TYPES.has(file.type)) {
toast.error("Nicht unterstützter Dateityp (nur JPEG, PNG, WebP, PDF)");
return;
}
if (file.size > MAX_FILE_SIZE_BYTES) {
toast.error("Datei zu groß (max. 10 MB)");
return;
}
uploadMutation.mutate(file);
}
return (
<Card className="max-w-lg">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{title}</CardTitle>
<Button
size="sm"
variant="outline"
disabled={uploadMutation.isPending}
onClick={() => fileInputRef.current?.click()}
>
{uploadMutation.isPending ? "Wird hochgeladen…" : "Datei hochladen"}
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,application/pdf"
className="hidden"
onChange={handleFileChange}
/>
</CardHeader>
<CardContent className="flex flex-col gap-3 text-sm">
{isLoading ? <p className="text-muted-foreground">Lädt…</p> : null}
{!isLoading && (!documents || documents.length === 0) ? (
<EmptyState
icon={Paperclip}
title="Noch keine Dateien"
description="Lade Fotos oder Dokumente hoch, um sie hier zu sammeln."
/>
) : null}
{documents?.map((document) => (
<div
key={document.id}
className="flex items-center justify-between gap-3 rounded-md border p-3"
>
<div className="flex min-w-0 items-center gap-2">
<DocumentTypeIcon mimeType={document.mimeType} />
<div className="min-w-0">
<p className="truncate font-medium">{document.fileName}</p>
<p className="text-muted-foreground">
{formatFileSize(document.sizeBytes)} ·{" "}
{new Date(document.createdAt).toLocaleDateString("de-DE")}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void downloadDocument(document.id, document.fileName)}
>
Herunterladen
</Button>
<ConfirmDialog
triggerLabel="Löschen"
triggerVariant="outline"
triggerSize="sm"
destructive
title="Datei löschen?"
description={`„${document.fileName}" wird endgültig gelöscht. Dies kann nicht rückgängig gemacht werden.`}
confirmLabel="Löschen"
isPending={deleteMutation.isPending}
onConfirm={() => deleteMutation.mutate(document.id)}
/>
</div>
</div>
))}
</CardContent>
</Card>
);
}