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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user