From 6e259140745d49dfc23c953e3ea4b406d74e2add Mon Sep 17 00:00:00 2001 From: Patrick Plate Date: Thu, 18 Jun 2026 14:43:00 +0200 Subject: [PATCH] feat: wire Documents + Board page buttons, add mock-mode dual operation Sprint 12 Phase 1: Golden Test Standard - Documents: React Query, upload/download/delete wired, category colors+icons, table min-widths, data-testid - Board: React Query, create position/elect/remove wired, confirmation dialogs, data-testid - Both pages: mock-mode fallback (works without backend) --- .../src/app/(dashboard-layout)/board/page.tsx | 293 +++++++++++++- .../app/(dashboard-layout)/documents/page.tsx | 374 +++++++++++++++--- cannamanage-frontend/src/services/board.ts | 58 +++ .../src/services/documents.ts | 61 ++- 4 files changed, 716 insertions(+), 70 deletions(-) diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/board/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/board/page.tsx index df45984..5914584 100644 --- a/cannamanage-frontend/src/app/(dashboard-layout)/board/page.tsx +++ b/cannamanage-frontend/src/app/(dashboard-layout)/board/page.tsx @@ -1,11 +1,29 @@ "use client" import { useState } from "react" +import { + useBoardQuery, + useCreatePositionMutation, + useElectBoardMemberMutation, + usePositionsQuery, + useRemoveBoardMemberMutation, +} from "@/services/board" import { useTranslations } from "next-intl" +import { toast } from "sonner" import { Calendar, Edit, Plus, Shield, UserMinus, UserPlus } from "lucide-react" import type { BoardMember, BoardPosition } from "@/services/board" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -20,7 +38,7 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Select } from "@/components/ui/select" -// Mock data +// Mock data (fallback) const mockPositions: BoardPosition[] = [ { id: "1", @@ -142,8 +160,186 @@ const mockBoardMembers: (BoardMember & { export default function BoardPage() { const t = useTranslations("board") + + // --- React Query --- + const { data: boardData } = useBoardQuery() + const { data: positionsData } = usePositionsQuery() + const createPositionMutation = useCreatePositionMutation() + const electMutation = useElectBoardMemberMutation() + const removeMutation = useRemoveBoardMemberMutation() + + // Dual mode: detect if backend is unavailable (mock mode) + const isMockMode = !boardData && !positionsData + const [localPositions, setLocalPositions] = + useState(mockPositions) + const [localBoardMembers, setLocalBoardMembers] = + useState(mockBoardMembers) + + // Use API data or local state (for mock mode operations) + const positions = positionsData ?? localPositions + const boardMembers = + (boardData as typeof mockBoardMembers) ?? localBoardMembers + + // --- UI state --- const [positionDialogOpen, setPositionDialogOpen] = useState(false) const [electDialogOpen, setElectDialogOpen] = useState(false) + const [removeTarget, setRemoveTarget] = useState< + (typeof mockBoardMembers)[0] | null + >(null) + + // Position form state + const [posTitle, setPosTitle] = useState("") + const [posDesc, setPosDesc] = useState("") + const [sortOrder, setSortOrder] = useState(0) + + // Elect form state + const [electPositionId, setElectPositionId] = useState("") + const [electMemberId, setElectMemberId] = useState("") + const [electedAt, setElectedAt] = useState("") + const [termStart, setTermStart] = useState("") + const [termEnd, setTermEnd] = useState("") + + // --- Handlers --- + + function handleCreatePosition() { + if (!posTitle.trim()) { + toast.error("Bitte einen Positionstitel angeben.") + return + } + + if (isMockMode) { + const newPosition: BoardPosition = { + id: crypto.randomUUID(), + title: posTitle.trim(), + description: posDesc.trim() || null, + sortOrder: sortOrder || positions.length + 1, + isActive: true, + createdAt: new Date().toISOString(), + } + setLocalPositions((prev) => [...prev, newPosition]) + toast.success("Position erfolgreich erstellt.") + setPositionDialogOpen(false) + setPosTitle("") + setPosDesc("") + setSortOrder(0) + return + } + + createPositionMutation.mutate( + { + title: posTitle.trim(), + description: posDesc.trim() || undefined, + sortOrder: sortOrder || undefined, + }, + { + onSuccess: () => { + toast.success("Position erfolgreich erstellt.") + setPositionDialogOpen(false) + setPosTitle("") + setPosDesc("") + setSortOrder(0) + }, + onError: () => { + toast.error("Fehler beim Erstellen der Position.") + }, + } + ) + } + + function handleElectMember() { + if (!electPositionId || !electMemberId || !electedAt || !termStart) { + toast.error("Bitte alle Pflichtfelder ausfüllen.") + return + } + + if (isMockMode) { + const position = positions.find((p) => p.id === electPositionId) + const memberNames: Record = { + m1: "Max Mustermann", + m2: "Anna Schmidt", + m3: "Peter Weber", + } + const newMember = { + id: crypto.randomUUID(), + clubId: "c1", + positionId: electPositionId, + memberId: electMemberId, + electedAt, + termStart, + termEnd: termEnd || null, + isCurrent: true, + electedInAssemblyId: null, + createdAt: new Date().toISOString(), + memberName: memberNames[electMemberId] ?? electMemberId, + positionTitle: position?.title ?? electPositionId, + } + setLocalBoardMembers((prev) => [...prev, newMember]) + toast.success("Vorstandsmitglied erfolgreich gewählt.") + setElectDialogOpen(false) + setElectPositionId("") + setElectMemberId("") + setElectedAt("") + setTermStart("") + setTermEnd("") + return + } + + electMutation.mutate( + { + positionId: electPositionId, + memberId: electMemberId, + electedAt, + termStart, + termEnd: termEnd || undefined, + }, + { + onSuccess: () => { + toast.success("Vorstandsmitglied erfolgreich gewählt.") + setElectDialogOpen(false) + setElectPositionId("") + setElectMemberId("") + setElectedAt("") + setTermStart("") + setTermEnd("") + }, + onError: () => { + toast.error("Fehler bei der Wahl des Vorstandsmitglieds.") + }, + } + ) + } + + function handleRemove(bm: (typeof mockBoardMembers)[0]) { + setRemoveTarget(bm) + } + + function confirmRemove() { + if (!removeTarget) return + + if (isMockMode) { + setLocalBoardMembers((prev) => + prev.filter((m) => m.id !== removeTarget.id) + ) + toast.success( + `${removeTarget.memberName ?? "Mitglied"} wurde aus dem Vorstand entfernt.` + ) + setRemoveTarget(null) + return + } + + removeMutation.mutate(removeTarget.id, { + onSuccess: () => { + toast.success( + `${removeTarget.memberName ?? "Mitglied"} wurde aus dem Vorstand entfernt.` + ) + setRemoveTarget(null) + }, + onError: () => { + toast.error("Fehler beim Entfernen des Vorstandsmitglieds.") + setRemoveTarget(null) + }, + }) + } return (
@@ -158,7 +354,7 @@ export default function BoardPage() { onOpenChange={setPositionDialogOpen} > - @@ -173,6 +369,8 @@ export default function BoardPage() { setPosTitle(e.target.value)} />
@@ -180,24 +378,34 @@ export default function BoardPage() { setPosDesc(e.target.value)} />
- + setSortOrder(Number(e.target.value))} + />
- @@ -209,9 +417,12 @@ export default function BoardPage() {
- setElectPositionId(e.target.value)} + > - {mockPositions.map((pos) => ( + {positions.map((pos) => ( @@ -220,7 +431,10 @@ export default function BoardPage() {
- setElectMemberId(e.target.value)} + > @@ -229,23 +443,41 @@ export default function BoardPage() {
- + setElectedAt(e.target.value)} + />
- + setTermStart(e.target.value)} + />
- + setTermEnd(e.target.value)} + />
@@ -255,8 +487,8 @@ export default function BoardPage() { {/* Current Board Members as cards */}
- {mockBoardMembers.map((bm) => ( - + {boardMembers.map((bm) => ( +
@@ -269,6 +501,8 @@ export default function BoardPage() { variant="ghost" size="icon" className="h-8 w-8 text-destructive" + data-testid={`board-remove-${bm.id}`} + onClick={() => handleRemove(bm)} > @@ -322,7 +556,7 @@ export default function BoardPage() {
- {mockPositions.map((pos) => ( + {positions.map((pos) => (
+ + {/* Remove confirmation dialog */} + !open && setRemoveTarget(null)} + > + + + Vorstandsmitglied entfernen? + + Möchtest du {removeTarget?.memberName ?? "dieses Mitglied"} als{" "} + {removeTarget?.positionTitle} wirklich aus dem Vorstand entfernen? + + + + Abbrechen + + {removeMutation.isPending ? "Entfernen..." : "Entfernen"} + + + +
) } diff --git a/cannamanage-frontend/src/app/(dashboard-layout)/documents/page.tsx b/cannamanage-frontend/src/app/(dashboard-layout)/documents/page.tsx index d51b51e..c6f7b6f 100644 --- a/cannamanage-frontend/src/app/(dashboard-layout)/documents/page.tsx +++ b/cannamanage-frontend/src/app/(dashboard-layout)/documents/page.tsx @@ -1,24 +1,50 @@ "use client" import { useState } from "react" -import { categoryLabels, formatFileSize } from "@/services/documents" -import { useTranslations } from "next-intl" import { + categoryLabels, + downloadDocument, + formatFileSize, + useDeleteDocumentMutation, + useDocumentsQuery, + useUploadDocumentMutation, +} from "@/services/documents" +import { useTranslations } from "next-intl" +import { toast } from "sonner" +import { + BookOpen, + CheckCircle, Download, File, FileSpreadsheet, FileText, Filter, Image, + Shield, Trash2, Upload, } from "lucide-react" -import type { ClubDocument, DocumentCategory } from "@/services/documents" +import type { + ClubDocument, + DocumentAccessLevel, + DocumentCategory, +} from "@/services/documents" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { TableSkeleton } from "@/components/ui/data-skeleton" import { Dialog, DialogContent, @@ -39,7 +65,7 @@ import { } from "@/components/ui/table" import { Textarea } from "@/components/ui/textarea" -// Mock data for development +// Mock data for development (fallback when API is unavailable) const mockDocuments: ClubDocument[] = [ { id: "1", @@ -108,6 +134,56 @@ const mockDocuments: ClubDocument[] = [ }, ] +// --- Category styling --- + +const categoryStyles: Record< + DocumentCategory, + { bg: string; text: string; icon: React.ReactNode } +> = { + SATZUNG: { + bg: "bg-blue-100 dark:bg-blue-900/30", + text: "text-blue-700 dark:text-blue-300", + icon: , + }, + PROTOKOLL: { + bg: "bg-purple-100 dark:bg-purple-900/30", + text: "text-purple-700 dark:text-purple-300", + icon: , + }, + VERTRAG: { + bg: "bg-amber-100 dark:bg-amber-900/30", + text: "text-amber-700 dark:text-amber-300", + icon: , + }, + VERSICHERUNG: { + bg: "bg-cyan-100 dark:bg-cyan-900/30", + text: "text-cyan-700 dark:text-cyan-300", + icon: , + }, + GENEHMIGUNG: { + bg: "bg-green-100 dark:bg-green-900/30", + text: "text-green-700 dark:text-green-300", + icon: , + }, + SONSTIGES: { + bg: "bg-gray-100 dark:bg-gray-900/30", + text: "text-gray-700 dark:text-gray-300", + icon: , + }, +} + +function CategoryBadge({ category }: { category: DocumentCategory }) { + const style = categoryStyles[category] + return ( + + {style.icon} + {categoryLabels[category]} + + ) +} + function getFileIcon(contentType: string) { if (contentType === "application/pdf") return if (contentType.includes("spreadsheet")) @@ -116,29 +192,36 @@ function getFileIcon(contentType: string) { return } -function getCategoryBadgeVariant( - category: DocumentCategory -): "default" | "secondary" | "destructive" | "outline" { - const variants: Record< - DocumentCategory, - "default" | "secondary" | "destructive" | "outline" - > = { - SATZUNG: "default", - PROTOKOLL: "secondary", - VERTRAG: "outline", - VERSICHERUNG: "outline", - GENEHMIGUNG: "destructive", - SONSTIGES: "secondary", - } - return variants[category] -} - export default function DocumentsPage() { const t = useTranslations("documents") - const [documents] = useState(mockDocuments) + + // --- React Query --- + const { data, isLoading } = useDocumentsQuery() + const uploadMutation = useUploadDocumentMutation() + const deleteMutation = useDeleteDocumentMutation() + + // Dual mode: detect if backend is unavailable (mock mode) + const isMockMode = !data + const [localDocuments, setLocalDocuments] = + useState(mockDocuments) + + // Use API data or local state (for mock mode operations) + const documents = data ?? localDocuments + + // --- UI state --- const [uploadOpen, setUploadOpen] = useState(false) const [filterCategory, setFilterCategory] = useState("ALL") + const [deleteTarget, setDeleteTarget] = useState(null) + // Upload form state + const [title, setTitle] = useState("") + const [category, setCategory] = useState("") + const [accessLevel, setAccessLevel] = + useState("ALL_MEMBERS") + const [description, setDescription] = useState("") + const [file, setFile] = useState(null) + + // --- Filtering --- const filteredDocuments = filterCategory === "ALL" ? documents @@ -155,6 +238,126 @@ export default function DocumentsPage() { {} as Record ) + // --- Handlers --- + + function resetUploadForm() { + setTitle("") + setCategory("") + setAccessLevel("ALL_MEMBERS") + setDescription("") + setFile(null) + } + + function handleUpload() { + if (!title.trim() || !category || !file) { + toast.error("Bitte Titel, Kategorie und Datei ausfüllen.") + return + } + + if (isMockMode) { + const newDoc: ClubDocument = { + id: crypto.randomUUID(), + title: title.trim(), + category: category as DocumentCategory, + filename: file.name, + contentType: file.type || "application/octet-stream", + fileSize: file.size, + accessLevel, + description: description.trim() || null, + uploadedBy: "current-user", + createdAt: new Date().toISOString(), + updatedAt: null, + } + setLocalDocuments((prev) => [newDoc, ...prev]) + toast.success("Dokument erfolgreich hochgeladen.") + setUploadOpen(false) + resetUploadForm() + return + } + + uploadMutation.mutate( + { + title: title.trim(), + category: category as DocumentCategory, + accessLevel, + description: description.trim() || null, + file, + }, + { + onSuccess: () => { + toast.success("Dokument erfolgreich hochgeladen.") + setUploadOpen(false) + resetUploadForm() + }, + onError: () => { + toast.error("Fehler beim Hochladen des Dokuments.") + }, + } + ) + } + + async function handleDownload(id: string, filename: string) { + if (isMockMode) { + toast.info("Demo-Modus: Download nicht verfügbar.") + return + } + + try { + const blob = await downloadDocument(id) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + } catch { + toast.error("Fehler beim Herunterladen.") + } + } + + function handleDelete(doc: ClubDocument) { + setDeleteTarget(doc) + } + + function confirmDelete() { + if (!deleteTarget) return + + if (isMockMode) { + setLocalDocuments((prev) => prev.filter((d) => d.id !== deleteTarget.id)) + toast.success(`"${deleteTarget.title}" wurde gelöscht.`) + setDeleteTarget(null) + return + } + + deleteMutation.mutate(deleteTarget.id, { + onSuccess: () => { + toast.success(`"${deleteTarget.title}" wurde gelöscht.`) + setDeleteTarget(null) + }, + onError: () => { + toast.error("Fehler beim Löschen des Dokuments.") + setDeleteTarget(null) + }, + }) + } + + // --- Loading state --- + if (isLoading) { + return ( +
+
+
+

{t("title")}

+

{t("description")}

+
+
+ +
+ ) + } + return (
@@ -164,23 +367,39 @@ export default function DocumentsPage() {
- - + {t("uploadDocument")}
- + setTitle(e.target.value)} + />
- + setCategory(e.target.value as DocumentCategory | "") + } + > {Object.entries(categoryLabels).map(([key, label]) => (
- + setAccessLevel(e.target.value as DocumentAccessLevel) + } + > @@ -201,22 +426,33 @@ export default function DocumentsPage() {