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
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -171,7 +171,7 @@ Reihenfolge = Bau-Reihenfolge. Gleiche Haken-Regel wie MVP: erst setzen, wenn Li
|
||||
|
||||
- [x] Design-Tokens: warme Honig-/Wachs-Farbpalette (Amber-Primär, Salbeigrün-Sekundär, Warnung/Erfolg), Space Grotesk (Überschrift) + Inter (Fließtext) + IBM Plex Mono (Codes/IDs)
|
||||
- [x] Navigation, Dashboard, Login neu gestaltet (Hexagon-Markenzeichen, aktiver Link-Zustand, Waben-Signaturmuster)
|
||||
- [ ] Verbleibende Bestandsseiten (Listen/Details aller MVP-Module) auf einheitlichen Karten-/Badge-/Icon-Stil durchgezogen
|
||||
- [x] Verbleibende Bestandsseiten (Listen/Details aller MVP-Module) auf einheitlichen Karten-/Badge-/Icon-Stil durchgezogen
|
||||
- [ ] Alle neuen v1.x-Module folgen dem Redesign von Anfang an (kein Nacharbeiten nötig)
|
||||
|
||||
### 22. QR-Code-System
|
||||
|
||||
Reference in new issue
Block a user