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
@@ -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",
}