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"
|
"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 "{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>
|
</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"] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user