feat(sprint8): Phase 4 — Dokumentenarchiv + Vorstandsverwaltung

Backend:
- V20 migration: documents table with category, access_level, file storage
- V21 migration: board_positions + board_members with term tracking
- Document entity + DocumentCategory/DocumentAccessLevel enums
- BoardPosition + BoardMember entities
- Extended AuditEventType (DOCUMENT_UPLOADED/DELETED, BOARD_MEMBER_ELECTED/REMOVED)
- Extended StaffPermission (MANAGE_DOCUMENTS)
- Extended NotificationType (BOARD_TERM_EXPIRING)
- DocumentService: upload, list, download, delete, storage usage
- BoardService: positions CRUD, elect/remove members, current/history
- DocumentController: multipart upload, filtered list, download, delete, portal
- BoardController: positions, elect, remove, current board, history, portal

Frontend:
- documents.ts + board.ts service layers
- Admin /documents page: grouped by category, upload dialog, filter, download/delete
- Admin /board page: current board cards, position management, elect member dialog
- Navigation: added Dokumente + Vorstand to sidebar
- i18n: documents.* + board.* keys in de.json + en.json
This commit is contained in:
Patrick Plate
2026-06-15 08:53:38 +02:00
parent b22702317a
commit e4698827ee
24 changed files with 1812 additions and 5 deletions
+56 -1
View File
@@ -949,5 +949,60 @@
"paymentHistory": "Zahlungshistorie",
"noPayments": "Noch keine Zahlungen vorhanden"
}
},
"documents": {
"title": "Dokumentenarchiv",
"description": "Vereinsdokumente verwalten und archivieren",
"upload": "Hochladen",
"uploadDocument": "Dokument hochladen",
"documentTitle": "Titel",
"titlePlaceholder": "z.B. Vereinssatzung 2024",
"category": "Kategorie",
"selectCategory": "Kategorie wählen",
"accessLevel": "Zugriff",
"allMembers": "Alle Mitglieder",
"boardOnly": "Nur Vorstand",
"descriptionLabel": "Beschreibung",
"descriptionPlaceholder": "Optionale Beschreibung...",
"file": "Datei",
"fileHint": "PDF, DOCX, XLSX, PNG, JPG — max. 10 MB",
"uploadButton": "Dokument hochladen",
"allCategories": "Alle Kategorien",
"documentsCount": "Dokumente",
"name": "Name",
"access": "Zugriff",
"size": "Größe",
"date": "Datum",
"actions": "Aktionen"
},
"board": {
"title": "Vorstand",
"description": "Vorstandspositionen und -mitglieder verwalten",
"addPosition": "Position anlegen",
"electMember": "Mitglied wählen",
"createPosition": "Position erstellen",
"positionTitle": "Titel",
"positionTitlePlaceholder": "z.B. 1. Vorsitzender",
"positionDescription": "Beschreibung",
"positionDescPlaceholder": "z.B. Gesetzlicher Vertreter gem. §26 BGB",
"sortOrder": "Reihenfolge",
"save": "Speichern",
"electBoardMember": "Vorstandsmitglied wählen",
"position": "Position",
"selectPosition": "Position wählen",
"member": "Mitglied",
"selectMember": "Mitglied wählen",
"electedAt": "Gewählt am",
"termStart": "Amtszeit Beginn",
"termEnd": "Amtszeit Ende",
"confirmElection": "Wahl bestätigen",
"elected": "Gewählt",
"term": "Amtszeit",
"unlimited": "unbefristet",
"termExpiringSoon": "Läuft bald ab",
"termActive": "Aktiv",
"positions": "Positionen",
"active": "Aktiv",
"inactive": "Inaktiv"
}
}
}
+56 -1
View File
@@ -949,5 +949,60 @@
"paymentHistory": "Payment History",
"noPayments": "No payments yet"
}
},
"documents": {
"title": "Document Archive",
"description": "Manage and archive club documents",
"upload": "Upload",
"uploadDocument": "Upload Document",
"documentTitle": "Title",
"titlePlaceholder": "e.g. Club Bylaws 2024",
"category": "Category",
"selectCategory": "Select category",
"accessLevel": "Access",
"allMembers": "All Members",
"boardOnly": "Board Only",
"descriptionLabel": "Description",
"descriptionPlaceholder": "Optional description...",
"file": "File",
"fileHint": "PDF, DOCX, XLSX, PNG, JPG — max. 10 MB",
"uploadButton": "Upload Document",
"allCategories": "All Categories",
"documentsCount": "documents",
"name": "Name",
"access": "Access",
"size": "Size",
"date": "Date",
"actions": "Actions"
},
"board": {
"title": "Board",
"description": "Manage board positions and members",
"addPosition": "Add Position",
"electMember": "Elect Member",
"createPosition": "Create Position",
"positionTitle": "Title",
"positionTitlePlaceholder": "e.g. President",
"positionDescription": "Description",
"positionDescPlaceholder": "e.g. Legal representative",
"sortOrder": "Sort Order",
"save": "Save",
"electBoardMember": "Elect Board Member",
"position": "Position",
"selectPosition": "Select position",
"member": "Member",
"selectMember": "Select member",
"electedAt": "Elected on",
"termStart": "Term Start",
"termEnd": "Term End",
"confirmElection": "Confirm Election",
"elected": "Elected",
"term": "Term",
"unlimited": "unlimited",
"termExpiringSoon": "Expiring soon",
"termActive": "Active",
"positions": "Positions",
"active": "Active",
"inactive": "Inactive"
}
}
}
@@ -0,0 +1,348 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { Calendar, Edit, Plus, Shield, UserMinus, UserPlus } from "lucide-react"
import type { BoardMember, BoardPosition } from "@/services/board"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select } from "@/components/ui/select"
// Mock data
const mockPositions: BoardPosition[] = [
{
id: "1",
title: "1. Vorsitzender",
description: "Gesetzlicher Vertreter gem. §26 BGB",
sortOrder: 1,
isActive: true,
createdAt: "2024-01-01T00:00:00Z",
},
{
id: "2",
title: "2. Vorsitzender",
description: "Stellvertreter",
sortOrder: 2,
isActive: true,
createdAt: "2024-01-01T00:00:00Z",
},
{
id: "3",
title: "Kassenwart",
description: "Finanzverwaltung gem. §259 BGB",
sortOrder: 3,
isActive: true,
createdAt: "2024-01-01T00:00:00Z",
},
{
id: "4",
title: "Schriftführer",
description: "Protokollführung",
sortOrder: 4,
isActive: true,
createdAt: "2024-01-01T00:00:00Z",
},
{
id: "5",
title: "Präventionsbeauftragter",
description: "Gem. §23 KCanG",
sortOrder: 5,
isActive: true,
createdAt: "2024-01-01T00:00:00Z",
},
]
const mockBoardMembers: (BoardMember & {
memberName?: string
positionTitle?: string
})[] = [
{
id: "1",
clubId: "c1",
positionId: "1",
memberId: "m1",
electedAt: "2024-03-15",
termStart: "2024-04-01",
termEnd: "2026-03-31",
isCurrent: true,
electedInAssemblyId: "a1",
createdAt: "2024-03-15T00:00:00Z",
memberName: "Max Mustermann",
positionTitle: "1. Vorsitzender",
},
{
id: "2",
clubId: "c1",
positionId: "2",
memberId: "m2",
electedAt: "2024-03-15",
termStart: "2024-04-01",
termEnd: "2026-03-31",
isCurrent: true,
electedInAssemblyId: "a1",
createdAt: "2024-03-15T00:00:00Z",
memberName: "Anna Schmidt",
positionTitle: "2. Vorsitzender",
},
{
id: "3",
clubId: "c1",
positionId: "3",
memberId: "m3",
electedAt: "2024-03-15",
termStart: "2024-04-01",
termEnd: "2026-03-31",
isCurrent: true,
electedInAssemblyId: "a1",
createdAt: "2024-03-15T00:00:00Z",
memberName: "Peter Weber",
positionTitle: "Kassenwart",
},
{
id: "4",
clubId: "c1",
positionId: "4",
memberId: "m4",
electedAt: "2024-03-15",
termStart: "2024-04-01",
termEnd: "2026-03-31",
isCurrent: true,
electedInAssemblyId: "a1",
createdAt: "2024-03-15T00:00:00Z",
memberName: "Lisa Müller",
positionTitle: "Schriftführer",
},
{
id: "5",
clubId: "c1",
positionId: "5",
memberId: "m5",
electedAt: "2024-03-15",
termStart: "2024-04-01",
termEnd: null,
isCurrent: true,
electedInAssemblyId: "a1",
createdAt: "2024-03-15T00:00:00Z",
memberName: "Thomas Braun",
positionTitle: "Präventionsbeauftragter",
},
]
export default function BoardPage() {
const t = useTranslations("board")
const [positionDialogOpen, setPositionDialogOpen] = useState(false)
const [electDialogOpen, setElectDialogOpen] = useState(false)
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 className="flex gap-2">
<Dialog
open={positionDialogOpen}
onOpenChange={setPositionDialogOpen}
>
<DialogTrigger asChild>
<Button variant="outline">
<Plus className="mr-2 h-4 w-4" />
{t("addPosition")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("createPosition")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="posTitle">{t("positionTitle")}</Label>
<Input
id="posTitle"
placeholder={t("positionTitlePlaceholder")}
/>
</div>
<div>
<Label htmlFor="posDesc">{t("positionDescription")}</Label>
<Input
id="posDesc"
placeholder={t("positionDescPlaceholder")}
/>
</div>
<div>
<Label htmlFor="sortOrder">{t("sortOrder")}</Label>
<Input id="sortOrder" type="number" defaultValue={0} />
</div>
<Button
className="w-full"
onClick={() => setPositionDialogOpen(false)}
>
{t("save")}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={electDialogOpen} onOpenChange={setElectDialogOpen}>
<DialogTrigger asChild>
<Button>
<UserPlus className="mr-2 h-4 w-4" />
{t("electMember")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("electBoardMember")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>{t("position")}</Label>
<Select>
<option value="">{t("selectPosition")}</option>
{mockPositions.map((pos) => (
<option key={pos.id} value={pos.id}>
{pos.title}
</option>
))}
</Select>
</div>
<div>
<Label>{t("member")}</Label>
<Select>
<option value="">{t("selectMember")}</option>
<option value="m1">Max Mustermann</option>
<option value="m2">Anna Schmidt</option>
<option value="m3">Peter Weber</option>
</Select>
</div>
<div>
<Label htmlFor="electedAt">{t("electedAt")}</Label>
<Input id="electedAt" type="date" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="termStart">{t("termStart")}</Label>
<Input id="termStart" type="date" />
</div>
<div>
<Label htmlFor="termEnd">{t("termEnd")}</Label>
<Input id="termEnd" type="date" />
</div>
</div>
<Button
className="w-full"
onClick={() => setElectDialogOpen(false)}
>
{t("confirmElection")}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
</div>
{/* 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}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
<CardTitle className="text-base">
{bm.positionTitle}
</CardTitle>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
>
<UserMinus className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<p className="font-medium">{bm.memberName}</p>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Calendar className="h-3 w-3" />
<span>
{t("elected")}:{" "}
{new Date(bm.electedAt).toLocaleDateString("de-DE")}
</span>
</div>
<div className="text-sm text-muted-foreground">
{t("term")}:{" "}
{new Date(bm.termStart).toLocaleDateString("de-DE")}
{bm.termEnd
? ` ${new Date(bm.termEnd).toLocaleDateString("de-DE")}`
: ` ${t("unlimited")}`}
</div>
{bm.termEnd && (
<Badge
variant={
new Date(bm.termEnd) <
new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)
? "destructive"
: "secondary"
}
>
{new Date(bm.termEnd) <
new Date(Date.now() + 90 * 24 * 60 * 60 * 1000)
? t("termExpiringSoon")
: t("termActive")}
</Badge>
)}
</div>
</CardContent>
</Card>
))}
</div>
{/* Positions overview */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Edit className="h-5 w-5" />
{t("positions")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{mockPositions.map((pos) => (
<div
key={pos.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div>
<p className="font-medium">{pos.title}</p>
{pos.description && (
<p className="text-sm text-muted-foreground">
{pos.description}
</p>
)}
</div>
<Badge variant={pos.isActive ? "default" : "secondary"}>
{pos.isActive ? t("active") : t("inactive")}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</div>
)
}
@@ -0,0 +1,328 @@
"use client"
import { useState } from "react"
import { categoryLabels, formatFileSize } from "@/services/documents"
import { useTranslations } from "next-intl"
import {
Download,
File,
FileSpreadsheet,
FileText,
Filter,
Image,
Trash2,
Upload,
} from "lucide-react"
import type { ClubDocument, DocumentCategory } from "@/services/documents"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select } from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Textarea } from "@/components/ui/textarea"
// Mock data for development
const mockDocuments: ClubDocument[] = [
{
id: "1",
title: "Vereinssatzung 2024",
category: "SATZUNG",
filename: "satzung-2024.pdf",
contentType: "application/pdf",
fileSize: 245000,
accessLevel: "ALL_MEMBERS",
description: "Aktuelle Satzung des Vereins",
uploadedBy: "admin-1",
createdAt: "2024-01-15T10:00:00Z",
updatedAt: null,
},
{
id: "2",
title: "Protokoll MV März 2024",
category: "PROTOKOLL",
filename: "mv-protokoll-2024-03.pdf",
contentType: "application/pdf",
fileSize: 180000,
accessLevel: "ALL_MEMBERS",
description: "Protokoll der ordentlichen Mitgliederversammlung",
uploadedBy: "admin-1",
createdAt: "2024-03-20T14:00:00Z",
updatedAt: null,
},
{
id: "3",
title: "Mietvertrag Vereinsräume",
category: "VERTRAG",
filename: "mietvertrag-2023.pdf",
contentType: "application/pdf",
fileSize: 520000,
accessLevel: "BOARD_ONLY",
description: "Mietvertrag für die Vereinsräumlichkeiten",
uploadedBy: "admin-1",
createdAt: "2023-06-01T09:00:00Z",
updatedAt: null,
},
{
id: "4",
title: "KCanG-Genehmigung",
category: "GENEHMIGUNG",
filename: "genehmigung-kcanG.pdf",
contentType: "application/pdf",
fileSize: 310000,
accessLevel: "BOARD_ONLY",
description: "Anbaugenehmigung nach KCanG",
uploadedBy: "admin-1",
createdAt: "2024-04-01T08:00:00Z",
updatedAt: null,
},
{
id: "5",
title: "Haftpflichtversicherung",
category: "VERSICHERUNG",
filename: "haftpflicht-2024.pdf",
contentType: "application/pdf",
fileSize: 150000,
accessLevel: "BOARD_ONLY",
description: "Vereinshaftpflichtversicherung Police Nr. 12345",
uploadedBy: "admin-1",
createdAt: "2024-01-01T10:00:00Z",
updatedAt: null,
},
]
function getFileIcon(contentType: string) {
if (contentType === "application/pdf") return <FileText className="h-4 w-4" />
if (contentType.includes("spreadsheet"))
return <FileSpreadsheet className="h-4 w-4" />
if (contentType.startsWith("image/")) return <Image 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() {
const t = useTranslations("documents")
const [documents] = useState<ClubDocument[]>(mockDocuments)
const [uploadOpen, setUploadOpen] = useState(false)
const [filterCategory, setFilterCategory] = useState<string>("ALL")
const filteredDocuments =
filterCategory === "ALL"
? documents
: documents.filter((d) => d.category === filterCategory)
// Group by category
const grouped = filteredDocuments.reduce(
(acc, doc) => {
const cat = doc.category
if (!acc[cat]) acc[cat] = []
acc[cat].push(doc)
return acc
},
{} as Record<string, ClubDocument[]>
)
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>
<Dialog open={uploadOpen} onOpenChange={setUploadOpen}>
<DialogTrigger asChild>
<Button>
<Upload className="mr-2 h-4 w-4" />
{t("upload")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("uploadDocument")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="title">{t("documentTitle")}</Label>
<Input id="title" placeholder={t("titlePlaceholder")} />
</div>
<div>
<Label htmlFor="category">{t("category")}</Label>
<Select id="category">
<option value="">{t("selectCategory")}</option>
{Object.entries(categoryLabels).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</Select>
</div>
<div>
<Label htmlFor="accessLevel">{t("accessLevel")}</Label>
<Select id="accessLevel" defaultValue="ALL_MEMBERS">
<option value="ALL_MEMBERS">{t("allMembers")}</option>
<option value="BOARD_ONLY">{t("boardOnly")}</option>
</Select>
</div>
<div>
<Label htmlFor="description">{t("descriptionLabel")}</Label>
<Textarea
id="description"
placeholder={t("descriptionPlaceholder")}
/>
</div>
<div>
<Label htmlFor="file">{t("file")}</Label>
<Input
id="file"
type="file"
accept=".pdf,.docx,.xlsx,.png,.jpg,.jpeg"
/>
<p className="mt-1 text-xs text-muted-foreground">
{t("fileHint")}
</p>
</div>
<Button className="w-full" onClick={() => setUploadOpen(false)}>
<Upload className="mr-2 h-4 w-4" />
{t("uploadButton")}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{/* Filter */}
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<Select
className="w-48"
value={filterCategory}
onChange={(e) => setFilterCategory(e.target.value)}
>
<option value="ALL">{t("allCategories")}</option>
{Object.entries(categoryLabels).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</Select>
<span className="text-sm text-muted-foreground">
{filteredDocuments.length} {t("documentsCount")}
</span>
</div>
{/* Documents grouped by category */}
{Object.entries(grouped).map(([category, docs]) => (
<Card key={category}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Badge
variant={getCategoryBadgeVariant(category as DocumentCategory)}
>
{categoryLabels[category as DocumentCategory]}
</Badge>
<span className="text-sm text-muted-foreground">
({docs.length})
</span>
</CardTitle>
</CardHeader>
<CardContent>
<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>
</TableRow>
</TableHeader>
<TableBody>
{docs.map((doc) => (
<TableRow key={doc.id}>
<TableCell>
<div className="flex items-center gap-2">
{getFileIcon(doc.contentType)}
<div>
<p className="font-medium">{doc.title}</p>
{doc.description && (
<p className="text-xs text-muted-foreground">
{doc.description}
</p>
)}
</div>
</div>
</TableCell>
<TableCell>
<Badge
variant={
doc.accessLevel === "BOARD_ONLY"
? "destructive"
: "secondary"
}
>
{doc.accessLevel === "BOARD_ONLY"
? t("boardOnly")
: t("allMembers")}
</Badge>
</TableCell>
<TableCell>{formatFileSize(doc.fileSize)}</TableCell>
<TableCell>
{new Date(doc.createdAt).toLocaleDateString("de-DE")}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon">
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
))}
</div>
)
}
@@ -49,6 +49,16 @@ export const navigationsData: NavigationType[] = [
href: "/assemblies",
iconName: "Gavel",
},
{
title: "Dokumente",
href: "/documents",
iconName: "FileArchive",
},
{
title: "Vorstand",
href: "/board",
iconName: "Shield",
},
{
title: "Kalender",
href: "/calendar",
@@ -0,0 +1,90 @@
import { apiClient } from "@/lib/api-client"
export interface BoardPosition {
id: string
title: string
description: string | null
sortOrder: number
isActive: boolean
createdAt: string
}
export interface BoardMember {
id: string
clubId: string
positionId: string
memberId: string
electedAt: string
termStart: string
termEnd: string | null
isCurrent: boolean
electedInAssemblyId: string | null
createdAt: string
}
export interface CreatePositionRequest {
title: string
description?: string
sortOrder?: number
}
export interface ElectBoardMemberRequest {
positionId: string
memberId: string
electedAt: string
termStart: string
termEnd?: string
assemblyId?: string
}
export function createPosition(
clubId: string,
data: CreatePositionRequest
): Promise<BoardPosition> {
return apiClient<BoardPosition>(`/board/positions?clubId=${clubId}`, {
method: "POST",
body: data,
})
}
export function getPositions(clubId: string): Promise<BoardPosition[]> {
return apiClient<BoardPosition[]>(`/board/positions?clubId=${clubId}`)
}
export function updatePosition(
id: string,
data: Partial<CreatePositionRequest & { isActive: boolean }>
): Promise<BoardPosition> {
return apiClient<BoardPosition>(`/board/positions/${id}`, {
method: "PUT",
body: data,
})
}
export function electBoardMember(
clubId: string,
data: ElectBoardMemberRequest
): Promise<BoardMember> {
return apiClient<BoardMember>(`/board/members?clubId=${clubId}`, {
method: "POST",
body: data,
})
}
export function getCurrentBoard(clubId: string): Promise<BoardMember[]> {
return apiClient<BoardMember[]>(`/board?clubId=${clubId}`)
}
export function getBoardHistory(clubId: string): Promise<BoardMember[]> {
return apiClient<BoardMember[]>(`/board/history?clubId=${clubId}`)
}
export function removeBoardMember(id: string, clubId: string): Promise<void> {
return apiClient<void>(`/board/members/${id}?clubId=${clubId}`, {
method: "DELETE",
})
}
export function getPortalBoard(clubId: string): Promise<BoardMember[]> {
return apiClient<BoardMember[]>(`/portal/board?clubId=${clubId}`)
}
@@ -0,0 +1,108 @@
import { apiClient } from "@/lib/api-client"
export type DocumentCategory =
| "SATZUNG"
| "PROTOKOLL"
| "VERTRAG"
| "VERSICHERUNG"
| "GENEHMIGUNG"
| "SONSTIGES"
export type DocumentAccessLevel = "ALL_MEMBERS" | "BOARD_ONLY"
export interface ClubDocument {
id: string
title: string
category: DocumentCategory
filename: string
contentType: string
fileSize: number
accessLevel: DocumentAccessLevel
description: string | null
uploadedBy: string
createdAt: string
updatedAt: string | null
}
export interface StorageUsage {
bytesUsed: number
}
export async function uploadDocument(
clubId: string,
title: string,
category: DocumentCategory,
accessLevel: DocumentAccessLevel,
description: string | null,
file: File
): Promise<ClubDocument> {
const formData = new FormData()
formData.append("file", file)
const params = new URLSearchParams({
clubId,
title,
category,
accessLevel,
})
if (description) params.append("description", description)
// Multipart upload — use raw fetch since apiClient assumes JSON
const res = await fetch(
`/api/backend/documents/upload?${params.toString()}`,
{
method: "POST",
body: formData,
}
)
if (!res.ok) throw new Error("Upload failed")
return res.json()
}
export function listDocuments(
clubId: string,
category?: DocumentCategory,
accessLevel?: DocumentAccessLevel
): Promise<ClubDocument[]> {
const params = new URLSearchParams({ clubId })
if (category) params.append("category", category)
if (accessLevel) params.append("accessLevel", accessLevel)
return apiClient<ClubDocument[]>(`/documents?${params.toString()}`)
}
export async function downloadDocument(id: string): Promise<Blob> {
const res = await fetch(`/api/backend/documents/${id}/download`)
if (!res.ok) throw new Error("Download failed")
return res.blob()
}
export function deleteDocument(id: string, clubId: string): Promise<void> {
return apiClient<void>(`/documents/${id}?clubId=${clubId}`, {
method: "DELETE",
})
}
export function getStorageUsage(clubId: string): Promise<StorageUsage> {
return apiClient<StorageUsage>(`/documents/usage?clubId=${clubId}`)
}
export function getPortalDocuments(clubId: string): Promise<ClubDocument[]> {
return apiClient<ClubDocument[]>(`/portal/documents?clubId=${clubId}`)
}
// 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
export const categoryLabels: Record<DocumentCategory, string> = {
SATZUNG: "Satzung",
PROTOKOLL: "Protokoll",
VERTRAG: "Vertrag",
VERSICHERUNG: "Versicherung",
GENEHMIGUNG: "Genehmigung",
SONSTIGES: "Sonstiges",
}