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

@@ -5,6 +5,7 @@ import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { fetchApiary } from "@/lib/apiaries/api";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { Button } from "@/components/ui/button";
import { ApiaryEditCard } from "./apiary-edit-card";
@@ -32,6 +33,7 @@ export default function ApiaryDetailPage() {
<QrCodeDialog entityType="apiaries" id={apiary.id} />
</div>
<ApiaryEditCard key={apiary.id} apiary={apiary} />
<DocumentUploadCard entityType="APIARY" entityId={apiary.id} />
<Button
className="w-fit"
nativeButton={false}
@@ -4,6 +4,7 @@ import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { fetchColony } from "@/lib/colonies/api";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { ColonyEditCard } from "./colony-edit-card";
import { ColonyActions } from "./colony-actions";
import { ColonyHistoryCard } from "./colony-history";
@@ -42,6 +43,7 @@ export default function ColonyDetailPage() {
<ColonyTreatmentsCard colonyId={colony.id} />
<ColonyFeedingsCard colonyId={colony.id} />
<ColonyHistoryCard colonyId={colony.id} />
<DocumentUploadCard entityType="COLONY" entityId={colony.id} />
</main>
);
}
@@ -7,6 +7,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import { fetchHoneyBatch, fetchHoneyBatchTraceability } from "@/lib/honey-batches/api";
import { exportHoneyBatchTraceabilityPdf } from "@/lib/export/api";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { EmptyState } from "@/components/empty-state";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -134,6 +135,8 @@ export default function HoneyBatchDetailPage() {
) : null}
</CardContent>
</Card>
<DocumentUploadCard entityType="HONEY_BATCH" entityId={honeyBatch.id} />
</main>
);
}
@@ -4,6 +4,7 @@ import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { fetchQueen } from "@/lib/queens/api";
import { QrCodeDialog } from "@/components/qr-code-dialog";
import { DocumentUploadCard } from "@/components/document-upload-card";
import { QueenEditCard } from "./queen-edit-card";
import { AssignColonyDialog } from "./assign-colony-dialog";
import { QueenHistoryCard } from "./queen-history";
@@ -36,6 +37,7 @@ export default function QueenDetailPage() {
<AssignColonyDialog queenId={queen.id} />
<QueenEvaluationsCard queenId={queen.id} />
<QueenHistoryCard queenId={queen.id} />
<DocumentUploadCard entityType="QUEEN" entityId={queen.id} />
</main>
);
}
@@ -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>
);
}
@@ -58,6 +58,41 @@ export async function apiFetchBlob(path: string, fallbackFilename: string): Prom
return { blob, filename };
}
async function rawFetchFormData(path: string, formData: FormData): Promise<Response> {
const token = getAccessToken();
return fetch(`${API_URL}${path}`, {
method: 'POST',
body: formData,
credentials: 'include',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
}
export async function apiFetchFormData<T>(path: string, formData: FormData): Promise<T> {
let response = await rawFetchFormData(path, formData);
if (response.status === 401) {
const refreshed = await refreshRequest();
if (refreshed) {
setAccessToken(refreshed.accessToken);
response = await rawFetchFormData(path, formData);
}
}
if (!response.ok) {
const body: unknown = await response.json().catch(() => ({}));
const message =
typeof body === 'object' && body !== null && 'message' in body
? String((body as { message: unknown }).message)
: 'Request failed';
throw new ApiError(response.status, message);
}
return response.json() as Promise<T>;
}
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
let response = await rawFetch(path, init);
+32
View File
@@ -0,0 +1,32 @@
import { apiFetch, apiFetchBlob, apiFetchFormData } from "@/lib/auth/api-client";
import { downloadBlob } from "@/lib/export/api";
import { DocumentEntityType, DocumentSummary } from "./types";
export function fetchDocuments(
entityType: DocumentEntityType,
entityId: string,
): Promise<DocumentSummary[]> {
const params = new URLSearchParams({ entityType, entityId });
return apiFetch<DocumentSummary[]>(`/documents?${params.toString()}`);
}
export function uploadDocument(
entityType: DocumentEntityType,
entityId: string,
file: File,
): Promise<DocumentSummary> {
const formData = new FormData();
formData.append("file", file);
formData.append("entityType", entityType);
formData.append("entityId", entityId);
return apiFetchFormData<DocumentSummary>("/documents", formData);
}
export function deleteDocument(id: string): Promise<void> {
return apiFetch<void>(`/documents/${id}`, { method: "DELETE" });
}
export async function downloadDocument(id: string, fileName: string): Promise<void> {
const { blob, filename } = await apiFetchBlob(`/documents/${id}/download`, fileName);
downloadBlob(blob, filename);
}
@@ -0,0 +1,12 @@
export type DocumentEntityType = "APIARY" | "COLONY" | "QUEEN" | "INSPECTION" | "HONEY_BATCH";
export interface DocumentSummary {
id: string;
entityType: DocumentEntityType;
entityId: string;
fileName: string;
mimeType: string;
sizeBytes: number;
uploadedByUserId: string | null;
createdAt: string;
}