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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
Reference in New Issue
Block a user