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 { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchApiary } from "@/lib/apiaries/api";
|
import { fetchApiary } from "@/lib/apiaries/api";
|
||||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||||
|
import { DocumentUploadCard } from "@/components/document-upload-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ApiaryEditCard } from "./apiary-edit-card";
|
import { ApiaryEditCard } from "./apiary-edit-card";
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ export default function ApiaryDetailPage() {
|
|||||||
<QrCodeDialog entityType="apiaries" id={apiary.id} />
|
<QrCodeDialog entityType="apiaries" id={apiary.id} />
|
||||||
</div>
|
</div>
|
||||||
<ApiaryEditCard key={apiary.id} apiary={apiary} />
|
<ApiaryEditCard key={apiary.id} apiary={apiary} />
|
||||||
|
<DocumentUploadCard entityType="APIARY" entityId={apiary.id} />
|
||||||
<Button
|
<Button
|
||||||
className="w-fit"
|
className="w-fit"
|
||||||
nativeButton={false}
|
nativeButton={false}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useParams } from "next/navigation";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchColony } from "@/lib/colonies/api";
|
import { fetchColony } from "@/lib/colonies/api";
|
||||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||||
|
import { DocumentUploadCard } from "@/components/document-upload-card";
|
||||||
import { ColonyEditCard } from "./colony-edit-card";
|
import { ColonyEditCard } from "./colony-edit-card";
|
||||||
import { ColonyActions } from "./colony-actions";
|
import { ColonyActions } from "./colony-actions";
|
||||||
import { ColonyHistoryCard } from "./colony-history";
|
import { ColonyHistoryCard } from "./colony-history";
|
||||||
@@ -42,6 +43,7 @@ export default function ColonyDetailPage() {
|
|||||||
<ColonyTreatmentsCard colonyId={colony.id} />
|
<ColonyTreatmentsCard colonyId={colony.id} />
|
||||||
<ColonyFeedingsCard colonyId={colony.id} />
|
<ColonyFeedingsCard colonyId={colony.id} />
|
||||||
<ColonyHistoryCard colonyId={colony.id} />
|
<ColonyHistoryCard colonyId={colony.id} />
|
||||||
|
<DocumentUploadCard entityType="COLONY" entityId={colony.id} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
|||||||
import { fetchHoneyBatch, fetchHoneyBatchTraceability } from "@/lib/honey-batches/api";
|
import { fetchHoneyBatch, fetchHoneyBatchTraceability } from "@/lib/honey-batches/api";
|
||||||
import { exportHoneyBatchTraceabilityPdf } from "@/lib/export/api";
|
import { exportHoneyBatchTraceabilityPdf } from "@/lib/export/api";
|
||||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||||
|
import { DocumentUploadCard } from "@/components/document-upload-card";
|
||||||
import { EmptyState } from "@/components/empty-state";
|
import { EmptyState } from "@/components/empty-state";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -134,6 +135,8 @@ export default function HoneyBatchDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<DocumentUploadCard entityType="HONEY_BATCH" entityId={honeyBatch.id} />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useParams } from "next/navigation";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { fetchQueen } from "@/lib/queens/api";
|
import { fetchQueen } from "@/lib/queens/api";
|
||||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||||
|
import { DocumentUploadCard } from "@/components/document-upload-card";
|
||||||
import { QueenEditCard } from "./queen-edit-card";
|
import { QueenEditCard } from "./queen-edit-card";
|
||||||
import { AssignColonyDialog } from "./assign-colony-dialog";
|
import { AssignColonyDialog } from "./assign-colony-dialog";
|
||||||
import { QueenHistoryCard } from "./queen-history";
|
import { QueenHistoryCard } from "./queen-history";
|
||||||
@@ -36,6 +37,7 @@ export default function QueenDetailPage() {
|
|||||||
<AssignColonyDialog queenId={queen.id} />
|
<AssignColonyDialog queenId={queen.id} />
|
||||||
<QueenEvaluationsCard queenId={queen.id} />
|
<QueenEvaluationsCard queenId={queen.id} />
|
||||||
<QueenHistoryCard queenId={queen.id} />
|
<QueenHistoryCard queenId={queen.id} />
|
||||||
|
<DocumentUploadCard entityType="QUEEN" entityId={queen.id} />
|
||||||
</main>
|
</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 };
|
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> {
|
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
let response = await rawFetch(path, init);
|
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] 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)
|
- [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)
|
- [ ] Alle neuen v1.x-Module folgen dem Redesign von Anfang an (kein Nacharbeiten nötig)
|
||||||
|
|
||||||
### 22. QR-Code-System
|
### 22. QR-Code-System
|
||||||
|
|||||||
Reference in new issue
Block a user