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)
This commit is contained in:
Patrick Plate
2026-06-18 14:43:00 +02:00
parent 90cdac7468
commit 6e25914074
4 changed files with 716 additions and 70 deletions
@@ -1,11 +1,29 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import {
useBoardQuery,
useCreatePositionMutation,
useElectBoardMemberMutation,
usePositionsQuery,
useRemoveBoardMemberMutation,
} from "@/services/board"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Calendar, Edit, Plus, Shield, UserMinus, UserPlus } from "lucide-react" import { Calendar, Edit, Plus, Shield, UserMinus, UserPlus } from "lucide-react"
import type { BoardMember, BoardPosition } from "@/services/board" 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 { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" 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 { Label } from "@/components/ui/label"
import { Select } from "@/components/ui/select" import { Select } from "@/components/ui/select"
// Mock data // Mock data (fallback)
const mockPositions: BoardPosition[] = [ const mockPositions: BoardPosition[] = [
{ {
id: "1", id: "1",
@@ -142,8 +160,186 @@ const mockBoardMembers: (BoardMember & {
export default function BoardPage() { export default function BoardPage() {
const t = useTranslations("board") 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<BoardPosition[]>(mockPositions)
const [localBoardMembers, setLocalBoardMembers] =
useState<typeof mockBoardMembers>(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 [positionDialogOpen, setPositionDialogOpen] = useState(false)
const [electDialogOpen, setElectDialogOpen] = 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<string, string> = {
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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -158,7 +354,7 @@ export default function BoardPage() {
onOpenChange={setPositionDialogOpen} onOpenChange={setPositionDialogOpen}
> >
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline"> <Button variant="outline" data-testid="board-create-position">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
{t("addPosition")} {t("addPosition")}
</Button> </Button>
@@ -173,6 +369,8 @@ export default function BoardPage() {
<Input <Input
id="posTitle" id="posTitle"
placeholder={t("positionTitlePlaceholder")} placeholder={t("positionTitlePlaceholder")}
value={posTitle}
onChange={(e) => setPosTitle(e.target.value)}
/> />
</div> </div>
<div> <div>
@@ -180,24 +378,34 @@ export default function BoardPage() {
<Input <Input
id="posDesc" id="posDesc"
placeholder={t("positionDescPlaceholder")} placeholder={t("positionDescPlaceholder")}
value={posDesc}
onChange={(e) => setPosDesc(e.target.value)}
/> />
</div> </div>
<div> <div>
<Label htmlFor="sortOrder">{t("sortOrder")}</Label> <Label htmlFor="sortOrder">{t("sortOrder")}</Label>
<Input id="sortOrder" type="number" defaultValue={0} /> <Input
id="sortOrder"
type="number"
value={sortOrder}
onChange={(e) => setSortOrder(Number(e.target.value))}
/>
</div> </div>
<Button <Button
className="w-full" className="w-full"
onClick={() => setPositionDialogOpen(false)} onClick={handleCreatePosition}
disabled={createPositionMutation.isPending}
> >
{t("save")} {createPositionMutation.isPending
? "Wird gespeichert..."
: t("save")}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={electDialogOpen} onOpenChange={setElectDialogOpen}> <Dialog open={electDialogOpen} onOpenChange={setElectDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button> <Button data-testid="board-elect-member">
<UserPlus className="mr-2 h-4 w-4" /> <UserPlus className="mr-2 h-4 w-4" />
{t("electMember")} {t("electMember")}
</Button> </Button>
@@ -209,9 +417,12 @@ export default function BoardPage() {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label>{t("position")}</Label> <Label>{t("position")}</Label>
<Select> <Select
value={electPositionId}
onChange={(e) => setElectPositionId(e.target.value)}
>
<option value="">{t("selectPosition")}</option> <option value="">{t("selectPosition")}</option>
{mockPositions.map((pos) => ( {positions.map((pos) => (
<option key={pos.id} value={pos.id}> <option key={pos.id} value={pos.id}>
{pos.title} {pos.title}
</option> </option>
@@ -220,7 +431,10 @@ export default function BoardPage() {
</div> </div>
<div> <div>
<Label>{t("member")}</Label> <Label>{t("member")}</Label>
<Select> <Select
value={electMemberId}
onChange={(e) => setElectMemberId(e.target.value)}
>
<option value="">{t("selectMember")}</option> <option value="">{t("selectMember")}</option>
<option value="m1">Max Mustermann</option> <option value="m1">Max Mustermann</option>
<option value="m2">Anna Schmidt</option> <option value="m2">Anna Schmidt</option>
@@ -229,23 +443,41 @@ export default function BoardPage() {
</div> </div>
<div> <div>
<Label htmlFor="electedAt">{t("electedAt")}</Label> <Label htmlFor="electedAt">{t("electedAt")}</Label>
<Input id="electedAt" type="date" /> <Input
id="electedAt"
type="date"
value={electedAt}
onChange={(e) => setElectedAt(e.target.value)}
/>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label htmlFor="termStart">{t("termStart")}</Label> <Label htmlFor="termStart">{t("termStart")}</Label>
<Input id="termStart" type="date" /> <Input
id="termStart"
type="date"
value={termStart}
onChange={(e) => setTermStart(e.target.value)}
/>
</div> </div>
<div> <div>
<Label htmlFor="termEnd">{t("termEnd")}</Label> <Label htmlFor="termEnd">{t("termEnd")}</Label>
<Input id="termEnd" type="date" /> <Input
id="termEnd"
type="date"
value={termEnd}
onChange={(e) => setTermEnd(e.target.value)}
/>
</div> </div>
</div> </div>
<Button <Button
className="w-full" className="w-full"
onClick={() => setElectDialogOpen(false)} onClick={handleElectMember}
disabled={electMutation.isPending}
> >
{t("confirmElection")} {electMutation.isPending
? "Wird gespeichert..."
: t("confirmElection")}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
@@ -255,8 +487,8 @@ export default function BoardPage() {
{/* Current Board Members as cards */} {/* Current Board Members as cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{mockBoardMembers.map((bm) => ( {boardMembers.map((bm) => (
<Card key={bm.id}> <Card key={bm.id} data-testid={`board-position-${bm.id}`}>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -269,6 +501,8 @@ export default function BoardPage() {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-destructive" className="h-8 w-8 text-destructive"
data-testid={`board-remove-${bm.id}`}
onClick={() => handleRemove(bm)}
> >
<UserMinus className="h-4 w-4" /> <UserMinus className="h-4 w-4" />
</Button> </Button>
@@ -322,7 +556,7 @@ export default function BoardPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-2"> <div className="space-y-2">
{mockPositions.map((pos) => ( {positions.map((pos) => (
<div <div
key={pos.id} key={pos.id}
className="flex items-center justify-between rounded-lg border p-3" className="flex items-center justify-between rounded-lg border p-3"
@@ -343,6 +577,31 @@ export default function BoardPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Remove confirmation dialog */}
<AlertDialog
open={!!removeTarget}
onOpenChange={(open) => !open && setRemoveTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Vorstandsmitglied entfernen?</AlertDialogTitle>
<AlertDialogDescription>
Möchtest du {removeTarget?.memberName ?? "dieses Mitglied"} als{" "}
{removeTarget?.positionTitle} wirklich aus dem Vorstand entfernen?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction
onClick={confirmRemove}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{removeMutation.isPending ? "Entfernen..." : "Entfernen"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
) )
} }
@@ -1,24 +1,50 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import { categoryLabels, formatFileSize } from "@/services/documents"
import { useTranslations } from "next-intl"
import { import {
categoryLabels,
downloadDocument,
formatFileSize,
useDeleteDocumentMutation,
useDocumentsQuery,
useUploadDocumentMutation,
} from "@/services/documents"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import {
BookOpen,
CheckCircle,
Download, Download,
File, File,
FileSpreadsheet, FileSpreadsheet,
FileText, FileText,
Filter, Filter,
Image, Image,
Shield,
Trash2, Trash2,
Upload, Upload,
} from "lucide-react" } 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 { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { TableSkeleton } from "@/components/ui/data-skeleton"
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -39,7 +65,7 @@ import {
} from "@/components/ui/table" } from "@/components/ui/table"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
// Mock data for development // Mock data for development (fallback when API is unavailable)
const mockDocuments: ClubDocument[] = [ const mockDocuments: ClubDocument[] = [
{ {
id: "1", 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: <BookOpen className="h-3 w-3" />,
},
PROTOKOLL: {
bg: "bg-purple-100 dark:bg-purple-900/30",
text: "text-purple-700 dark:text-purple-300",
icon: <FileText className="h-3 w-3" />,
},
VERTRAG: {
bg: "bg-amber-100 dark:bg-amber-900/30",
text: "text-amber-700 dark:text-amber-300",
icon: <FileSpreadsheet className="h-3 w-3" />,
},
VERSICHERUNG: {
bg: "bg-cyan-100 dark:bg-cyan-900/30",
text: "text-cyan-700 dark:text-cyan-300",
icon: <Shield className="h-3 w-3" />,
},
GENEHMIGUNG: {
bg: "bg-green-100 dark:bg-green-900/30",
text: "text-green-700 dark:text-green-300",
icon: <CheckCircle className="h-3 w-3" />,
},
SONSTIGES: {
bg: "bg-gray-100 dark:bg-gray-900/30",
text: "text-gray-700 dark:text-gray-300",
icon: <File className="h-3 w-3" />,
},
}
function CategoryBadge({ category }: { category: DocumentCategory }) {
const style = categoryStyles[category]
return (
<span
className={`inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium ${style.bg} ${style.text}`}
>
{style.icon}
{categoryLabels[category]}
</span>
)
}
function getFileIcon(contentType: string) { function getFileIcon(contentType: string) {
if (contentType === "application/pdf") return <FileText className="h-4 w-4" /> if (contentType === "application/pdf") return <FileText className="h-4 w-4" />
if (contentType.includes("spreadsheet")) if (contentType.includes("spreadsheet"))
@@ -116,29 +192,36 @@ function getFileIcon(contentType: string) {
return <File className="h-4 w-4" /> return <File className="h-4 w-4" />
} }
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() { export default function DocumentsPage() {
const t = useTranslations("documents") const t = useTranslations("documents")
const [documents] = useState<ClubDocument[]>(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<ClubDocument[]>(mockDocuments)
// Use API data or local state (for mock mode operations)
const documents = data ?? localDocuments
// --- UI state ---
const [uploadOpen, setUploadOpen] = useState(false) const [uploadOpen, setUploadOpen] = useState(false)
const [filterCategory, setFilterCategory] = useState<string>("ALL") const [filterCategory, setFilterCategory] = useState<string>("ALL")
const [deleteTarget, setDeleteTarget] = useState<ClubDocument | null>(null)
// Upload form state
const [title, setTitle] = useState("")
const [category, setCategory] = useState<DocumentCategory | "">("")
const [accessLevel, setAccessLevel] =
useState<DocumentAccessLevel>("ALL_MEMBERS")
const [description, setDescription] = useState("")
const [file, setFile] = useState<File | null>(null)
// --- Filtering ---
const filteredDocuments = const filteredDocuments =
filterCategory === "ALL" filterCategory === "ALL"
? documents ? documents
@@ -155,6 +238,126 @@ export default function DocumentsPage() {
{} as Record<string, ClubDocument[]> {} as Record<string, ClubDocument[]>
) )
// --- 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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{t("title")}</h1>
<p className="text-muted-foreground">{t("description")}</p>
</div>
</div>
<TableSkeleton rows={5} columns={5} />
</div>
)
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -164,23 +367,39 @@ export default function DocumentsPage() {
</div> </div>
<Dialog open={uploadOpen} onOpenChange={setUploadOpen}> <Dialog open={uploadOpen} onOpenChange={setUploadOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button> <Button data-testid="documents-upload-button">
<Upload className="mr-2 h-4 w-4" /> <Upload className="mr-2 h-4 w-4" />
{t("upload")} {t("upload")}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-md"> <DialogContent
className="max-w-md"
data-testid="documents-upload-dialog"
>
<DialogHeader> <DialogHeader>
<DialogTitle>{t("uploadDocument")}</DialogTitle> <DialogTitle>{t("uploadDocument")}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label htmlFor="title">{t("documentTitle")}</Label> <Label htmlFor="title">{t("documentTitle")}</Label>
<Input id="title" placeholder={t("titlePlaceholder")} /> <Input
id="title"
data-testid="documents-title-input"
placeholder={t("titlePlaceholder")}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div> </div>
<div> <div>
<Label htmlFor="category">{t("category")}</Label> <Label htmlFor="category">{t("category")}</Label>
<Select id="category"> <Select
id="category"
data-testid="documents-category-select"
value={category}
onChange={(e) =>
setCategory(e.target.value as DocumentCategory | "")
}
>
<option value="">{t("selectCategory")}</option> <option value="">{t("selectCategory")}</option>
{Object.entries(categoryLabels).map(([key, label]) => ( {Object.entries(categoryLabels).map(([key, label]) => (
<option key={key} value={key}> <option key={key} value={key}>
@@ -191,7 +410,13 @@ export default function DocumentsPage() {
</div> </div>
<div> <div>
<Label htmlFor="accessLevel">{t("accessLevel")}</Label> <Label htmlFor="accessLevel">{t("accessLevel")}</Label>
<Select id="accessLevel" defaultValue="ALL_MEMBERS"> <Select
id="accessLevel"
value={accessLevel}
onChange={(e) =>
setAccessLevel(e.target.value as DocumentAccessLevel)
}
>
<option value="ALL_MEMBERS">{t("allMembers")}</option> <option value="ALL_MEMBERS">{t("allMembers")}</option>
<option value="BOARD_ONLY">{t("boardOnly")}</option> <option value="BOARD_ONLY">{t("boardOnly")}</option>
</Select> </Select>
@@ -201,22 +426,33 @@ export default function DocumentsPage() {
<Textarea <Textarea
id="description" id="description"
placeholder={t("descriptionPlaceholder")} placeholder={t("descriptionPlaceholder")}
value={description}
onChange={(e) => setDescription(e.target.value)}
/> />
</div> </div>
<div> <div>
<Label htmlFor="file">{t("file")}</Label> <Label htmlFor="file">{t("file")}</Label>
<Input <Input
id="file" id="file"
data-testid="documents-file-input"
type="file" type="file"
accept=".pdf,.docx,.xlsx,.png,.jpg,.jpeg" accept=".pdf,.docx,.xlsx,.png,.jpg,.jpeg"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/> />
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{t("fileHint")} {t("fileHint")}
</p> </p>
</div> </div>
<Button className="w-full" onClick={() => setUploadOpen(false)}> <Button
className="w-full"
data-testid="documents-submit-upload"
onClick={handleUpload}
disabled={uploadMutation.isPending}
>
<Upload className="mr-2 h-4 w-4" /> <Upload className="mr-2 h-4 w-4" />
{t("uploadButton")} {uploadMutation.isPending
? "Wird hochgeladen..."
: t("uploadButton")}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
@@ -244,15 +480,11 @@ export default function DocumentsPage() {
</div> </div>
{/* Documents grouped by category */} {/* Documents grouped by category */}
{Object.entries(grouped).map(([category, docs]) => ( {Object.entries(grouped).map(([cat, docs]) => (
<Card key={category}> <Card key={cat}>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg"> <CardTitle className="flex items-center gap-2 text-lg">
<Badge <CategoryBadge category={cat as DocumentCategory} />
variant={getCategoryBadgeVariant(category as DocumentCategory)}
>
{categoryLabels[category as DocumentCategory]}
</Badge>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
({docs.length}) ({docs.length})
</span> </span>
@@ -262,30 +494,35 @@ export default function DocumentsPage() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t("name")}</TableHead> <TableHead className="max-w-[300px]">{t("name")}</TableHead>
<TableHead>{t("access")}</TableHead> <TableHead className="w-[120px]">{t("access")}</TableHead>
<TableHead>{t("size")}</TableHead> <TableHead className="w-[80px]">{t("size")}</TableHead>
<TableHead>{t("date")}</TableHead> <TableHead className="w-[100px]">{t("date")}</TableHead>
<TableHead className="text-right">{t("actions")}</TableHead> <TableHead className="w-[80px] text-right">
{t("actions")}
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{docs.map((doc) => ( {docs.map((doc) => (
<TableRow key={doc.id}> <TableRow
<TableCell> key={doc.id}
data-testid={`documents-row-${doc.id}`}
>
<TableCell className="max-w-[300px]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{getFileIcon(doc.contentType)} {getFileIcon(doc.contentType)}
<div> <div className="min-w-0">
<p className="font-medium">{doc.title}</p> <p className="truncate font-medium">{doc.title}</p>
{doc.description && ( {doc.description && (
<p className="text-xs text-muted-foreground"> <p className="truncate text-xs text-muted-foreground">
{doc.description} {doc.description}
</p> </p>
)} )}
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell className="w-[120px]">
<Badge <Badge
variant={ variant={
doc.accessLevel === "BOARD_ONLY" doc.accessLevel === "BOARD_ONLY"
@@ -298,19 +535,28 @@ export default function DocumentsPage() {
: t("allMembers")} : t("allMembers")}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell>{formatFileSize(doc.fileSize)}</TableCell> <TableCell className="w-[80px]">
<TableCell> {formatFileSize(doc.fileSize)}
</TableCell>
<TableCell className="w-[100px]">
{new Date(doc.createdAt).toLocaleDateString("de-DE")} {new Date(doc.createdAt).toLocaleDateString("de-DE")}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="w-[80px] text-right">
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
<Button variant="ghost" size="icon"> <Button
variant="ghost"
size="icon"
data-testid={`documents-download-${doc.id}`}
onClick={() => handleDownload(doc.id, doc.filename)}
>
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-destructive" className="text-destructive"
data-testid={`documents-delete-${doc.id}`}
onClick={() => handleDelete(doc)}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@@ -323,6 +569,32 @@ export default function DocumentsPage() {
</CardContent> </CardContent>
</Card> </Card>
))} ))}
{/* Delete confirmation dialog */}
<AlertDialog
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Dokument löschen?</AlertDialogTitle>
<AlertDialogDescription>
Möchtest du &quot;{deleteTarget?.title}&quot; wirklich löschen?
Diese Aktion kann nicht rückgängig gemacht werden.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction
data-testid="documents-delete-confirm"
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteMutation.isPending ? "Löschen..." : "Löschen"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
) )
} }
@@ -1,5 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "@/lib/api-client" import { apiClient } from "@/lib/api-client"
// --- Constants ---
const CLUB_ID = "00000000-0000-0000-0000-000000000001"
// --- Types ---
export interface BoardPosition { export interface BoardPosition {
id: string id: string
title: string title: string
@@ -37,6 +45,8 @@ export interface ElectBoardMemberRequest {
assemblyId?: string assemblyId?: string
} }
// --- Raw API functions ---
export function createPosition( export function createPosition(
clubId: string, clubId: string,
data: CreatePositionRequest data: CreatePositionRequest
@@ -88,3 +98,51 @@ export function removeBoardMember(id: string, clubId: string): Promise<void> {
export function getPortalBoard(clubId: string): Promise<BoardMember[]> { export function getPortalBoard(clubId: string): Promise<BoardMember[]> {
return apiClient<BoardMember[]>(`/portal/board?clubId=${clubId}`) return apiClient<BoardMember[]>(`/portal/board?clubId=${clubId}`)
} }
// --- React Query Hooks ---
export function useBoardQuery() {
return useQuery({
queryKey: ["board", CLUB_ID],
queryFn: () => getCurrentBoard(CLUB_ID),
})
}
export function usePositionsQuery() {
return useQuery({
queryKey: ["board-positions", CLUB_ID],
queryFn: () => getPositions(CLUB_ID),
})
}
export function useCreatePositionMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CreatePositionRequest) => createPosition(CLUB_ID, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["board-positions"] })
queryClient.invalidateQueries({ queryKey: ["board"] })
},
})
}
export function useElectBoardMemberMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: ElectBoardMemberRequest) =>
electBoardMember(CLUB_ID, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["board"] })
},
})
}
export function useRemoveBoardMemberMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => removeBoardMember(id, CLUB_ID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["board"] })
},
})
}
+59 -2
View File
@@ -1,5 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "@/lib/api-client" import { apiClient } from "@/lib/api-client"
// --- Constants ---
const CLUB_ID = "00000000-0000-0000-0000-000000000001"
// --- Types ---
export type DocumentCategory = export type DocumentCategory =
| "SATZUNG" | "SATZUNG"
| "PROTOKOLL" | "PROTOKOLL"
@@ -28,6 +36,16 @@ export interface StorageUsage {
bytesUsed: number bytesUsed: number
} }
export interface UploadDocumentRequest {
title: string
category: DocumentCategory
accessLevel: DocumentAccessLevel
description: string | null
file: File
}
// --- Raw API functions ---
export async function uploadDocument( export async function uploadDocument(
clubId: string, clubId: string,
title: string, title: string,
@@ -90,14 +108,53 @@ export function getPortalDocuments(clubId: string): Promise<ClubDocument[]> {
return apiClient<ClubDocument[]>(`/portal/documents?clubId=${clubId}`) return apiClient<ClubDocument[]>(`/portal/documents?clubId=${clubId}`)
} }
// Helper: format file size // --- React Query Hooks ---
export function useDocumentsQuery(category?: DocumentCategory) {
return useQuery({
queryKey: ["documents", CLUB_ID, category],
queryFn: () => listDocuments(CLUB_ID, category),
})
}
export function useUploadDocumentMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: UploadDocumentRequest) =>
uploadDocument(
CLUB_ID,
data.title,
data.category,
data.accessLevel,
data.description,
data.file
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["documents"] })
},
})
}
export function useDeleteDocumentMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (id: string) => deleteDocument(id, CLUB_ID),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["documents"] })
},
})
}
// --- Helper: format file size ---
export function formatFileSize(bytes: number): string { export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B` if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB` return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
} }
// Category labels // --- Category labels ---
export const categoryLabels: Record<DocumentCategory, string> = { export const categoryLabels: Record<DocumentCategory, string> = {
SATZUNG: "Satzung", SATZUNG: "Satzung",
PROTOKOLL: "Protokoll", PROTOKOLL: "Protokoll",