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:
@@ -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<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 [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 (
|
||||
<div className="space-y-6">
|
||||
@@ -158,7 +354,7 @@ export default function BoardPage() {
|
||||
onOpenChange={setPositionDialogOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Button variant="outline" data-testid="board-create-position">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("addPosition")}
|
||||
</Button>
|
||||
@@ -173,6 +369,8 @@ export default function BoardPage() {
|
||||
<Input
|
||||
id="posTitle"
|
||||
placeholder={t("positionTitlePlaceholder")}
|
||||
value={posTitle}
|
||||
onChange={(e) => setPosTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -180,24 +378,34 @@ export default function BoardPage() {
|
||||
<Input
|
||||
id="posDesc"
|
||||
placeholder={t("positionDescPlaceholder")}
|
||||
value={posDesc}
|
||||
onChange={(e) => setPosDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => setPositionDialogOpen(false)}
|
||||
onClick={handleCreatePosition}
|
||||
disabled={createPositionMutation.isPending}
|
||||
>
|
||||
{t("save")}
|
||||
{createPositionMutation.isPending
|
||||
? "Wird gespeichert..."
|
||||
: t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog open={electDialogOpen} onOpenChange={setElectDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Button data-testid="board-elect-member">
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t("electMember")}
|
||||
</Button>
|
||||
@@ -209,9 +417,12 @@ export default function BoardPage() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>{t("position")}</Label>
|
||||
<Select>
|
||||
<Select
|
||||
value={electPositionId}
|
||||
onChange={(e) => setElectPositionId(e.target.value)}
|
||||
>
|
||||
<option value="">{t("selectPosition")}</option>
|
||||
{mockPositions.map((pos) => (
|
||||
{positions.map((pos) => (
|
||||
<option key={pos.id} value={pos.id}>
|
||||
{pos.title}
|
||||
</option>
|
||||
@@ -220,7 +431,10 @@ export default function BoardPage() {
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("member")}</Label>
|
||||
<Select>
|
||||
<Select
|
||||
value={electMemberId}
|
||||
onChange={(e) => setElectMemberId(e.target.value)}
|
||||
>
|
||||
<option value="">{t("selectMember")}</option>
|
||||
<option value="m1">Max Mustermann</option>
|
||||
<option value="m2">Anna Schmidt</option>
|
||||
@@ -229,23 +443,41 @@ export default function BoardPage() {
|
||||
</div>
|
||||
<div>
|
||||
<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 className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => setElectDialogOpen(false)}
|
||||
onClick={handleElectMember}
|
||||
disabled={electMutation.isPending}
|
||||
>
|
||||
{t("confirmElection")}
|
||||
{electMutation.isPending
|
||||
? "Wird gespeichert..."
|
||||
: t("confirmElection")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -255,8 +487,8 @@ export default function BoardPage() {
|
||||
|
||||
{/* Current Board Members as cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{mockBoardMembers.map((bm) => (
|
||||
<Card key={bm.id}>
|
||||
{boardMembers.map((bm) => (
|
||||
<Card key={bm.id} data-testid={`board-position-${bm.id}`}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -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)}
|
||||
>
|
||||
<UserMinus className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -322,7 +556,7 @@ export default function BoardPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{mockPositions.map((pos) => (
|
||||
{positions.map((pos) => (
|
||||
<div
|
||||
key={pos.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3"
|
||||
@@ -343,6 +577,31 @@ export default function BoardPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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: <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) {
|
||||
if (contentType === "application/pdf") return <FileText className="h-4 w-4" />
|
||||
if (contentType.includes("spreadsheet"))
|
||||
@@ -116,29 +192,36 @@ function getFileIcon(contentType: string) {
|
||||
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() {
|
||||
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 [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 =
|
||||
filterCategory === "ALL"
|
||||
? documents
|
||||
@@ -155,6 +238,126 @@ export default function DocumentsPage() {
|
||||
{} 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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -164,23 +367,39 @@ export default function DocumentsPage() {
|
||||
</div>
|
||||
<Dialog open={uploadOpen} onOpenChange={setUploadOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Button data-testid="documents-upload-button">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t("upload")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
data-testid="documents-upload-dialog"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("uploadDocument")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
{Object.entries(categoryLabels).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
@@ -191,7 +410,13 @@ export default function DocumentsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<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="BOARD_ONLY">{t("boardOnly")}</option>
|
||||
</Select>
|
||||
@@ -201,22 +426,33 @@ export default function DocumentsPage() {
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder={t("descriptionPlaceholder")}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="file">{t("file")}</Label>
|
||||
<Input
|
||||
id="file"
|
||||
data-testid="documents-file-input"
|
||||
type="file"
|
||||
accept=".pdf,.docx,.xlsx,.png,.jpg,.jpeg"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t("fileHint")}
|
||||
</p>
|
||||
</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" />
|
||||
{t("uploadButton")}
|
||||
{uploadMutation.isPending
|
||||
? "Wird hochgeladen..."
|
||||
: t("uploadButton")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -244,15 +480,11 @@ export default function DocumentsPage() {
|
||||
</div>
|
||||
|
||||
{/* Documents grouped by category */}
|
||||
{Object.entries(grouped).map(([category, docs]) => (
|
||||
<Card key={category}>
|
||||
{Object.entries(grouped).map(([cat, docs]) => (
|
||||
<Card key={cat}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Badge
|
||||
variant={getCategoryBadgeVariant(category as DocumentCategory)}
|
||||
>
|
||||
{categoryLabels[category as DocumentCategory]}
|
||||
</Badge>
|
||||
<CategoryBadge category={cat as DocumentCategory} />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({docs.length})
|
||||
</span>
|
||||
@@ -262,30 +494,35 @@ export default function DocumentsPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("access")}</TableHead>
|
||||
<TableHead>{t("size")}</TableHead>
|
||||
<TableHead>{t("date")}</TableHead>
|
||||
<TableHead className="text-right">{t("actions")}</TableHead>
|
||||
<TableHead className="max-w-[300px]">{t("name")}</TableHead>
|
||||
<TableHead className="w-[120px]">{t("access")}</TableHead>
|
||||
<TableHead className="w-[80px]">{t("size")}</TableHead>
|
||||
<TableHead className="w-[100px]">{t("date")}</TableHead>
|
||||
<TableHead className="w-[80px] text-right">
|
||||
{t("actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{docs.map((doc) => (
|
||||
<TableRow key={doc.id}>
|
||||
<TableCell>
|
||||
<TableRow
|
||||
key={doc.id}
|
||||
data-testid={`documents-row-${doc.id}`}
|
||||
>
|
||||
<TableCell className="max-w-[300px]">
|
||||
<div className="flex items-center gap-2">
|
||||
{getFileIcon(doc.contentType)}
|
||||
<div>
|
||||
<p className="font-medium">{doc.title}</p>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{doc.title}</p>
|
||||
{doc.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{doc.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="w-[120px]">
|
||||
<Badge
|
||||
variant={
|
||||
doc.accessLevel === "BOARD_ONLY"
|
||||
@@ -298,19 +535,28 @@ export default function DocumentsPage() {
|
||||
: t("allMembers")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatFileSize(doc.fileSize)}</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="w-[80px]">
|
||||
{formatFileSize(doc.fileSize)}
|
||||
</TableCell>
|
||||
<TableCell className="w-[100px]">
|
||||
{new Date(doc.createdAt).toLocaleDateString("de-DE")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TableCell className="w-[80px] text-right">
|
||||
<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" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive"
|
||||
data-testid={`documents-delete-${doc.id}`}
|
||||
onClick={() => handleDelete(doc)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -323,6 +569,32 @@ export default function DocumentsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<AlertDialog
|
||||
open={!!deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Dokument löschen?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Möchtest du "{deleteTarget?.title}" 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
const CLUB_ID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface BoardPosition {
|
||||
id: string
|
||||
title: string
|
||||
@@ -37,6 +45,8 @@ export interface ElectBoardMemberRequest {
|
||||
assemblyId?: string
|
||||
}
|
||||
|
||||
// --- Raw API functions ---
|
||||
|
||||
export function createPosition(
|
||||
clubId: string,
|
||||
data: CreatePositionRequest
|
||||
@@ -88,3 +98,51 @@ export function removeBoardMember(id: string, clubId: string): Promise<void> {
|
||||
export function getPortalBoard(clubId: string): Promise<BoardMember[]> {
|
||||
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"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
const CLUB_ID = "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type DocumentCategory =
|
||||
| "SATZUNG"
|
||||
| "PROTOKOLL"
|
||||
@@ -28,6 +36,16 @@ export interface StorageUsage {
|
||||
bytesUsed: number
|
||||
}
|
||||
|
||||
export interface UploadDocumentRequest {
|
||||
title: string
|
||||
category: DocumentCategory
|
||||
accessLevel: DocumentAccessLevel
|
||||
description: string | null
|
||||
file: File
|
||||
}
|
||||
|
||||
// --- Raw API functions ---
|
||||
|
||||
export async function uploadDocument(
|
||||
clubId: string,
|
||||
title: string,
|
||||
@@ -90,14 +108,53 @@ export function getPortalDocuments(clubId: string): Promise<ClubDocument[]> {
|
||||
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 {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
// Category labels
|
||||
// --- Category labels ---
|
||||
|
||||
export const categoryLabels: Record<DocumentCategory, string> = {
|
||||
SATZUNG: "Satzung",
|
||||
PROTOKOLL: "Protokoll",
|
||||
|
||||
Reference in New Issue
Block a user