diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.tsx index 3ed7324..961dbb1 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/page.tsx @@ -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() { + + ); } diff --git a/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/page.tsx index 1d0b8d2..23a0602 100644 --- a/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/honey-batches/[id]/page.tsx @@ -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} + + ); } diff --git a/ApiCore-Frontend/src/app/(app)/queens/[id]/page.tsx b/ApiCore-Frontend/src/app/(app)/queens/[id]/page.tsx index 3a226c4..0867fca 100644 --- a/ApiCore-Frontend/src/app/(app)/queens/[id]/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/queens/[id]/page.tsx @@ -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() { + ); } diff --git a/ApiCore-Frontend/src/components/document-upload-card.tsx b/ApiCore-Frontend/src/components/document-upload-card.tsx new file mode 100644 index 0000000..8f0aebb --- /dev/null +++ b/ApiCore-Frontend/src/components/document-upload-card.tsx @@ -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/") ? ( + + ) : ( + + ); +} + +interface DocumentUploadCardProps { + entityType: DocumentEntityType; + entityId: string; + title?: string; +} + +export function DocumentUploadCard({ + entityType, + entityId, + title = "Dokumente", +}: DocumentUploadCardProps) { + const queryClient = useQueryClient(); + const fileInputRef = useRef(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) { + 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 ( + + + {title} + fileInputRef.current?.click()} + > + {uploadMutation.isPending ? "Wird hochgeladen…" : "Datei hochladen"} + + + + + {isLoading ? Lädt… : null} + {!isLoading && (!documents || documents.length === 0) ? ( + + ) : null} + {documents?.map((document) => ( + + + + + {document.fileName} + + {formatFileSize(document.sizeBytes)} ·{" "} + {new Date(document.createdAt).toLocaleDateString("de-DE")} + + + + + void downloadDocument(document.id, document.fileName)} + > + Herunterladen + + deleteMutation.mutate(document.id)} + /> + + + ))} + + + ); +} diff --git a/ApiCore-Frontend/src/lib/auth/api-client.ts b/ApiCore-Frontend/src/lib/auth/api-client.ts index e5247ee..cb2ceef 100644 --- a/ApiCore-Frontend/src/lib/auth/api-client.ts +++ b/ApiCore-Frontend/src/lib/auth/api-client.ts @@ -58,6 +58,41 @@ export async function apiFetchBlob(path: string, fallbackFilename: string): Prom return { blob, filename }; } +async function rawFetchFormData(path: string, formData: FormData): Promise { + const token = getAccessToken(); + return fetch(`${API_URL}${path}`, { + method: 'POST', + body: formData, + credentials: 'include', + headers: { + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); +} + +export async function apiFetchFormData(path: string, formData: FormData): Promise { + 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; +} + export async function apiFetch(path: string, init: RequestInit = {}): Promise { let response = await rawFetch(path, init); diff --git a/ApiCore-Frontend/src/lib/documents/api.ts b/ApiCore-Frontend/src/lib/documents/api.ts new file mode 100644 index 0000000..05b4505 --- /dev/null +++ b/ApiCore-Frontend/src/lib/documents/api.ts @@ -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 { + const params = new URLSearchParams({ entityType, entityId }); + return apiFetch(`/documents?${params.toString()}`); +} + +export function uploadDocument( + entityType: DocumentEntityType, + entityId: string, + file: File, +): Promise { + const formData = new FormData(); + formData.append("file", file); + formData.append("entityType", entityType); + formData.append("entityId", entityId); + return apiFetchFormData("/documents", formData); +} + +export function deleteDocument(id: string): Promise { + return apiFetch(`/documents/${id}`, { method: "DELETE" }); +} + +export async function downloadDocument(id: string, fileName: string): Promise { + const { blob, filename } = await apiFetchBlob(`/documents/${id}/download`, fileName); + downloadBlob(blob, filename); +} diff --git a/ApiCore-Frontend/src/lib/documents/types.ts b/ApiCore-Frontend/src/lib/documents/types.ts new file mode 100644 index 0000000..e1e520a --- /dev/null +++ b/ApiCore-Frontend/src/lib/documents/types.ts @@ -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; +} diff --git a/TASKS.md b/TASKS.md index a6c875c..aff4970 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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
Lädt…
{document.fileName}
+ {formatFileSize(document.sizeBytes)} ·{" "} + {new Date(document.createdAt).toLocaleDateString("de-DE")} +