feat(sprint8): Phase 3 — Mitgliederversammlung (assemblies, voting, protocol PDF)
Backend: - V19 migration: assemblies, assembly_agenda_items, assembly_attendees, assembly_votes, assembly_vote_records - Enums: AssemblyType, AssemblyStatus, AgendaItemType, VoteType, VoteDecision, VoteResult - Entities: Assembly, AssemblyAgendaItem, AssemblyAttendee, AssemblyVote, AssemblyVoteRecord - Repositories: Assembly, AgendaItem, Attendee, Vote, VoteRecord - AssemblyService: full lifecycle (create, invite, start, attend, vote, quorum, complete) - AssemblyProtocolService: OpenPDF protocol generation (§147 AO compliant) - AssemblyController: admin + portal endpoints - Extended: AuditEventType, NotificationType, StaffPermission Frontend: - Assembly service with full API client and TypeScript types - Admin assemblies list page with create dialog (agenda builder) - Admin assembly detail page (quorum, agenda, votes, attendees) - Navigation: Versammlungen with Gavel icon (after Finanzen) Legal basis: §32-§40 BGB (Mitgliederversammlung), §147 AO (retention)
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 106 KiB |
@@ -0,0 +1,330 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import {
|
||||
closeVote,
|
||||
completeAssembly,
|
||||
downloadProtocol,
|
||||
getAssemblyDetail,
|
||||
sendInvitations,
|
||||
startAssembly,
|
||||
} from "@/services/assemblies"
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
FileDown,
|
||||
Play,
|
||||
Send,
|
||||
Square,
|
||||
Users,
|
||||
Vote,
|
||||
XCircle,
|
||||
} from "lucide-react"
|
||||
|
||||
import type {
|
||||
AssemblyDetail,
|
||||
AssemblyStatus,
|
||||
VoteResult,
|
||||
} from "@/services/assemblies"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
|
||||
const statusLabels: Record<AssemblyStatus, string> = {
|
||||
PLANNED: "Geplant",
|
||||
INVITED: "Eingeladen",
|
||||
IN_PROGRESS: "Läuft",
|
||||
COMPLETED: "Abgeschlossen",
|
||||
CANCELLED: "Abgesagt",
|
||||
}
|
||||
const statusColors: Record<AssemblyStatus, string> = {
|
||||
PLANNED: "bg-gray-500/20 text-gray-400",
|
||||
INVITED: "bg-blue-500/20 text-blue-400",
|
||||
IN_PROGRESS: "bg-green-500/20 text-green-400",
|
||||
COMPLETED: "bg-emerald-500/20 text-emerald-400",
|
||||
CANCELLED: "bg-red-500/20 text-red-400",
|
||||
}
|
||||
|
||||
export default function AssemblyDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const [detail, setDetail] = useState<AssemblyDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (id) loadDetail()
|
||||
}, [id])
|
||||
|
||||
async function loadDetail() {
|
||||
try {
|
||||
const data = await getAssemblyDetail(id)
|
||||
setDetail(data)
|
||||
} catch (e) {
|
||||
console.error("Failed to load assembly", e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendInvitations() {
|
||||
await sendInvitations(id)
|
||||
loadDetail()
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
await startAssembly(id)
|
||||
loadDetail()
|
||||
}
|
||||
|
||||
async function handleComplete() {
|
||||
await completeAssembly(id)
|
||||
loadDetail()
|
||||
}
|
||||
|
||||
async function handleDownloadProtocol() {
|
||||
const blob = await downloadProtocol(id)
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `protokoll-${id}.pdf`
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function handleCloseVote(voteId: string) {
|
||||
await closeVote(voteId)
|
||||
loadDetail()
|
||||
}
|
||||
|
||||
if (loading || !detail) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-muted-foreground">Laden...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { assembly, agendaItems, attendees, votes, quorum } = detail
|
||||
const quorumPercent =
|
||||
quorum.totalMembers > 0
|
||||
? Math.round((quorum.attendees / quorum.totalMembers) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => router.push("/assemblies")}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">{assembly.title}</h1>
|
||||
<Badge className={statusColors[assembly.status]}>
|
||||
{statusLabels[assembly.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{new Date(assembly.scheduledAt).toLocaleDateString("de-DE", {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
{assembly.location && ` • ${assembly.location}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{assembly.status === "PLANNED" && (
|
||||
<Button onClick={handleSendInvitations}>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Einladen
|
||||
</Button>
|
||||
)}
|
||||
{(assembly.status === "PLANNED" || assembly.status === "INVITED") && (
|
||||
<Button onClick={handleStart} variant="default">
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Starten
|
||||
</Button>
|
||||
)}
|
||||
{assembly.status === "IN_PROGRESS" && (
|
||||
<Button onClick={handleComplete} variant="default">
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
Beenden
|
||||
</Button>
|
||||
)}
|
||||
{assembly.status === "COMPLETED" && (
|
||||
<Button onClick={handleDownloadProtocol} variant="outline">
|
||||
<FileDown className="mr-2 h-4 w-4" />
|
||||
Protokoll
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quorum Card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
Beschlussfähigkeit
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4">
|
||||
<Progress value={quorumPercent} className="flex-1" />
|
||||
<span className="text-sm font-medium">
|
||||
{quorum.attendees} / {quorum.totalMembers}
|
||||
</span>
|
||||
<Badge
|
||||
className={
|
||||
quorum.quorumMet
|
||||
? "bg-green-500/20 text-green-400"
|
||||
: "bg-red-500/20 text-red-400"
|
||||
}
|
||||
>
|
||||
{quorum.quorumMet
|
||||
? "Beschlussfähig"
|
||||
: `${quorum.required} benötigt`}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Agenda */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Vote className="h-5 w-5" />
|
||||
Tagesordnung
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{agendaItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start gap-3 p-3 rounded-md bg-muted/50"
|
||||
>
|
||||
<span className="text-sm font-bold text-muted-foreground min-w-[40px]">
|
||||
TOP {item.position}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{item.title}</p>
|
||||
{item.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
<Badge variant="outline" className="mt-1 text-xs">
|
||||
{item.itemType}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{agendaItems.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Keine Tagesordnungspunkte
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Votes */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Vote className="h-5 w-5" />
|
||||
Abstimmungen
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{votes.map((vote) => (
|
||||
<div
|
||||
key={vote.id}
|
||||
className="p-3 rounded-md bg-muted/50 space-y-2"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium">{vote.title}</p>
|
||||
{vote.result ? (
|
||||
<Badge
|
||||
className={
|
||||
vote.result === "ACCEPTED"
|
||||
? "bg-green-500/20 text-green-400"
|
||||
: "bg-red-500/20 text-red-400"
|
||||
}
|
||||
>
|
||||
{vote.result === "ACCEPTED" ? "Angenommen" : "Abgelehnt"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleCloseVote(vote.id)}
|
||||
>
|
||||
Schließen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-4 text-sm">
|
||||
<span className="text-green-400">✓ {vote.yesCount} Ja</span>
|
||||
<span className="text-red-400">✗ {vote.noCount} Nein</span>
|
||||
<span className="text-muted-foreground">
|
||||
○ {vote.abstainCount} Enthaltung
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{votes.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Keine Abstimmungen
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Attendees */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Anwesende ({attendees.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{attendees.length > 0 ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{attendees.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex items-center gap-2 p-2 rounded bg-muted/50 text-sm"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 text-green-400" />
|
||||
<span>{a.memberId.slice(0, 8)}...</span>
|
||||
{a.proxyForMemberId && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Vollmacht
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Noch keine Anwesenden eingecheckt
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { createAssembly, getAssemblies } from "@/services/assemblies"
|
||||
import { Gavel, Plus, X } from "lucide-react"
|
||||
|
||||
import type {
|
||||
AgendaItemType,
|
||||
Assembly,
|
||||
AssemblyStatus,
|
||||
AssemblyType,
|
||||
} from "@/services/assemblies"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent } 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 { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
const statusLabels: Record<AssemblyStatus, string> = {
|
||||
PLANNED: "Geplant",
|
||||
INVITED: "Eingeladen",
|
||||
IN_PROGRESS: "Läuft",
|
||||
COMPLETED: "Abgeschlossen",
|
||||
CANCELLED: "Abgesagt",
|
||||
}
|
||||
|
||||
const statusColors: Record<AssemblyStatus, string> = {
|
||||
PLANNED: "bg-gray-500/20 text-gray-400",
|
||||
INVITED: "bg-blue-500/20 text-blue-400",
|
||||
IN_PROGRESS: "bg-green-500/20 text-green-400",
|
||||
COMPLETED: "bg-emerald-500/20 text-emerald-400",
|
||||
CANCELLED: "bg-red-500/20 text-red-400",
|
||||
}
|
||||
|
||||
const typeLabels: Record<AssemblyType, string> = {
|
||||
ORDINARY: "Ordentliche MV",
|
||||
EXTRAORDINARY: "Außerordentliche MV",
|
||||
}
|
||||
|
||||
export default function AssembliesPage() {
|
||||
const router = useRouter()
|
||||
const [assemblies, setAssemblies] = useState<Assembly[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
title: "",
|
||||
assemblyType: "ORDINARY" as AssemblyType,
|
||||
scheduledAt: "",
|
||||
location: "",
|
||||
quorumRequired: "",
|
||||
agendaItems: [
|
||||
{ title: "", description: "", itemType: "INFORMATION" as AgendaItemType },
|
||||
],
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadAssemblies()
|
||||
}, [])
|
||||
|
||||
async function loadAssemblies() {
|
||||
try {
|
||||
const data = await getAssemblies()
|
||||
setAssemblies(data)
|
||||
} catch (e) {
|
||||
console.error("Failed to load assemblies", e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
try {
|
||||
await createAssembly({
|
||||
title: formData.title,
|
||||
assemblyType: formData.assemblyType,
|
||||
scheduledAt: new Date(formData.scheduledAt).toISOString(),
|
||||
location: formData.location || undefined,
|
||||
quorumRequired: formData.quorumRequired
|
||||
? parseInt(formData.quorumRequired)
|
||||
: undefined,
|
||||
agendaItems: formData.agendaItems
|
||||
.filter((a) => a.title.trim())
|
||||
.map((a) => ({
|
||||
title: a.title,
|
||||
description: a.description || undefined,
|
||||
itemType: a.itemType,
|
||||
})),
|
||||
})
|
||||
setCreateOpen(false)
|
||||
setFormData({
|
||||
title: "",
|
||||
assemblyType: "ORDINARY",
|
||||
scheduledAt: "",
|
||||
location: "",
|
||||
quorumRequired: "",
|
||||
agendaItems: [{ title: "", description: "", itemType: "INFORMATION" }],
|
||||
})
|
||||
loadAssemblies()
|
||||
} catch (e) {
|
||||
console.error("Failed to create assembly", e)
|
||||
}
|
||||
}
|
||||
|
||||
function addAgendaItem() {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
agendaItems: [
|
||||
...prev.agendaItems,
|
||||
{ title: "", description: "", itemType: "INFORMATION" as AgendaItemType },
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
function removeAgendaItem(index: number) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
agendaItems: prev.agendaItems.filter((_, i) => i !== index),
|
||||
}))
|
||||
}
|
||||
|
||||
function updateAgendaItem(index: number, field: string, value: string) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
agendaItems: prev.agendaItems.map((item, i) =>
|
||||
i === index ? { ...item, [field]: value } : item,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<p className="text-muted-foreground">Laden...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Versammlungen</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Mitgliederversammlungen verwalten
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Neue Versammlung
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Neue Mitgliederversammlung</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Titel</Label>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChange={(e) =>
|
||||
setFormData((p) => ({ ...p, title: e.target.value }))
|
||||
}
|
||||
placeholder="z.B. Ordentliche MV 2026"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Typ</Label>
|
||||
<select
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors"
|
||||
value={formData.assemblyType}
|
||||
onChange={(e) =>
|
||||
setFormData((p) => ({
|
||||
...p,
|
||||
assemblyType: e.target.value as AssemblyType,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="ORDINARY">Ordentlich</option>
|
||||
<option value="EXTRAORDINARY">Außerordentlich</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Datum & Uhrzeit</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={formData.scheduledAt}
|
||||
onChange={(e) =>
|
||||
setFormData((p) => ({
|
||||
...p,
|
||||
scheduledAt: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Ort</Label>
|
||||
<Input
|
||||
value={formData.location}
|
||||
onChange={(e) =>
|
||||
setFormData((p) => ({ ...p, location: e.target.value }))
|
||||
}
|
||||
placeholder="Vereinsheim"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Quorum (Mindestanzahl)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.quorumRequired}
|
||||
onChange={(e) =>
|
||||
setFormData((p) => ({
|
||||
...p,
|
||||
quorumRequired: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Mindestanzahl für Beschlussfähigkeit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base font-semibold">
|
||||
Tagesordnung
|
||||
</Label>
|
||||
<Button variant="outline" size="sm" onClick={addAgendaItem}>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
TOP
|
||||
</Button>
|
||||
</div>
|
||||
{formData.agendaItems.map((item, i) => (
|
||||
<div key={i} className="border rounded-md p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
TOP {i + 1}
|
||||
</span>
|
||||
<Input
|
||||
className="flex-1"
|
||||
value={item.title}
|
||||
onChange={(e) =>
|
||||
updateAgendaItem(i, "title", e.target.value)
|
||||
}
|
||||
placeholder="Titel"
|
||||
/>
|
||||
<select
|
||||
className="flex h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm w-[140px]"
|
||||
value={item.itemType}
|
||||
onChange={(e) =>
|
||||
updateAgendaItem(i, "itemType", e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="INFORMATION">Information</option>
|
||||
<option value="DISCUSSION">Diskussion</option>
|
||||
<option value="VOTE">Abstimmung</option>
|
||||
<option value="ELECTION">Wahl</option>
|
||||
</select>
|
||||
{formData.agendaItems.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeAgendaItem(i)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
value={item.description}
|
||||
onChange={(e) =>
|
||||
updateAgendaItem(i, "description", e.target.value)
|
||||
}
|
||||
placeholder="Beschreibung (optional)"
|
||||
className="min-h-[60px]"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="w-full"
|
||||
disabled={!formData.title || !formData.scheduledAt}
|
||||
>
|
||||
Versammlung erstellen
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{assemblies.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Gavel className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">
|
||||
Noch keine Versammlungen geplant
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{assemblies.map((assembly) => (
|
||||
<Card
|
||||
key={assembly.id}
|
||||
className="cursor-pointer hover:border-primary/50 transition-colors"
|
||||
onClick={() => router.push(`/assemblies/${assembly.id}`)}
|
||||
>
|
||||
<CardContent className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Gavel className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">{assembly.title}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{typeLabels[assembly.assemblyType]} •{" "}
|
||||
{new Date(assembly.scheduledAt).toLocaleDateString(
|
||||
"de-DE",
|
||||
{
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
},
|
||||
)}
|
||||
{assembly.location && ` • ${assembly.location}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={statusColors[assembly.status]}>
|
||||
{statusLabels[assembly.status]}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +1,30 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
useCreateTopic,
|
||||
useDeleteTopic,
|
||||
useForumTopics,
|
||||
useLockTopic,
|
||||
useOpenReportCount,
|
||||
usePinTopic,
|
||||
useUnlockTopic,
|
||||
useUnpinTopic,
|
||||
} from "@/services/forum"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
Flag,
|
||||
Lock,
|
||||
MessageSquare,
|
||||
Pin,
|
||||
PinOff,
|
||||
Plus,
|
||||
Trash2,
|
||||
Unlock,
|
||||
Flag,
|
||||
PinOff,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
useForumTopics,
|
||||
useCreateTopic,
|
||||
useLockTopic,
|
||||
useUnlockTopic,
|
||||
usePinTopic,
|
||||
useUnpinTopic,
|
||||
useDeleteTopic,
|
||||
useOpenReportCount,
|
||||
type ForumTopic,
|
||||
} from "@/services/forum"
|
||||
import type { ForumTopic } from "@/services/forum"
|
||||
|
||||
export default function ForumPage() {
|
||||
const t = useTranslations("forum")
|
||||
@@ -72,14 +73,14 @@ export default function ForumPage() {
|
||||
<p className="text-muted-foreground text-sm">{t("description")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{reportCount?.count > 0 && (
|
||||
<a
|
||||
{(reportCount?.count ?? 0) > 0 && (
|
||||
<Link
|
||||
href="/forum/reports"
|
||||
className="bg-destructive/10 text-destructive inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium"
|
||||
>
|
||||
<Flag className="h-4 w-4" />
|
||||
{reportCount.count} {t("openReports")}
|
||||
</a>
|
||||
{reportCount!.count} {t("openReports")}
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
|
||||
@@ -18,7 +18,7 @@ export default async function MarketingLayout({
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<div className="min-h-screen flex flex-col bg-background text-foreground">
|
||||
<div className="min-h-screen flex flex-col bg-background text-foreground overflow-x-hidden">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
|
||||
@@ -12,7 +12,7 @@ export default async function PortalLayout({
|
||||
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<div className="min-h-screen w-full flex flex-col bg-background text-foreground">
|
||||
<div className="min-h-screen w-full flex flex-col bg-background text-foreground overflow-x-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</NextIntlClientProvider>
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
usePortalCreateReply,
|
||||
usePortalCreateTopic,
|
||||
usePortalForumReplies,
|
||||
usePortalForumTopic,
|
||||
usePortalForumTopics,
|
||||
usePortalReportContent,
|
||||
usePortalToggleReaction,
|
||||
} from "@/services/forum"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
ArrowLeft,
|
||||
Flag,
|
||||
Lock,
|
||||
MessageSquare,
|
||||
Pin,
|
||||
Plus,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
Flag,
|
||||
ArrowLeft,
|
||||
ThumbsUp,
|
||||
} from "lucide-react"
|
||||
|
||||
import {
|
||||
usePortalForumTopics,
|
||||
usePortalForumTopic,
|
||||
usePortalForumReplies,
|
||||
usePortalCreateTopic,
|
||||
usePortalCreateReply,
|
||||
usePortalToggleReaction,
|
||||
usePortalReportContent,
|
||||
type ForumTopic,
|
||||
type ForumReply,
|
||||
} from "@/services/forum"
|
||||
import type { ForumReply, ForumTopic } from "@/services/forum"
|
||||
|
||||
export default function PortalForumPage() {
|
||||
const t = useTranslations("forum")
|
||||
@@ -34,8 +33,12 @@ export default function PortalForumPage() {
|
||||
const [replyContent, setReplyContent] = useState("")
|
||||
|
||||
const { data: topicsData, isLoading } = usePortalForumTopics()
|
||||
const { data: topicDetail } = usePortalForumTopic(selectedTopicId ?? undefined)
|
||||
const { data: repliesData } = usePortalForumReplies(selectedTopicId ?? undefined)
|
||||
const { data: topicDetail } = usePortalForumTopic(
|
||||
selectedTopicId ?? undefined
|
||||
)
|
||||
const { data: repliesData } = usePortalForumReplies(
|
||||
selectedTopicId ?? undefined
|
||||
)
|
||||
const createTopic = usePortalCreateTopic()
|
||||
const createReply = usePortalCreateReply(selectedTopicId ?? "")
|
||||
const toggleReaction = usePortalToggleReaction()
|
||||
@@ -91,7 +94,9 @@ export default function PortalForumPage() {
|
||||
<div className="bg-card rounded-lg border p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{topicDetail.pinned && <Pin className="text-primary h-4 w-4" />}
|
||||
{topicDetail.locked && <Lock className="text-muted-foreground h-4 w-4" />}
|
||||
{topicDetail.locked && (
|
||||
<Lock className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
<h2 className="text-lg font-bold">{topicDetail.title}</h2>
|
||||
</div>
|
||||
<div
|
||||
@@ -99,7 +104,9 @@ export default function PortalForumPage() {
|
||||
dangerouslySetInnerHTML={{ __html: topicDetail.content }}
|
||||
/>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{new Date(topicDetail.createdAt).toLocaleDateString("de-DE")}</span>
|
||||
<span>
|
||||
{new Date(topicDetail.createdAt).toLocaleDateString("de-DE")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
@@ -144,8 +151,12 @@ export default function PortalForumPage() {
|
||||
dangerouslySetInnerHTML={{ __html: reply.content }}
|
||||
/>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{new Date(reply.createdAt).toLocaleDateString("de-DE")}</span>
|
||||
{reply.edited && <span className="italic">({t("edited")})</span>}
|
||||
<span>
|
||||
{new Date(reply.createdAt).toLocaleDateString("de-DE")}
|
||||
</span>
|
||||
{reply.edited && (
|
||||
<span className="italic">({t("edited")})</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() =>
|
||||
@@ -277,8 +288,12 @@ export default function PortalForumPage() {
|
||||
className="bg-card w-full text-left rounded-lg border p-4 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{topic.pinned && <Pin className="text-primary h-4 w-4 shrink-0" />}
|
||||
{topic.locked && <Lock className="text-muted-foreground h-4 w-4 shrink-0" />}
|
||||
{topic.pinned && (
|
||||
<Pin className="text-primary h-4 w-4 shrink-0" />
|
||||
)}
|
||||
{topic.locked && (
|
||||
<Lock className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
)}
|
||||
<span className="font-medium truncate">{topic.title}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-3 text-xs">
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground overflow-x-hidden;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,16 @@
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Calendar, Cannabis, History, LayoutDashboard, LogOut, Megaphone, MessageSquare, User } from "lucide-react"
|
||||
import {
|
||||
Calendar,
|
||||
Cannabis,
|
||||
History,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Megaphone,
|
||||
MessageSquare,
|
||||
User,
|
||||
} from "lucide-react"
|
||||
|
||||
import { mockPortalUser } from "@/data/mock/portal"
|
||||
|
||||
|
||||
@@ -44,6 +44,11 @@ export const navigationsData: NavigationType[] = [
|
||||
href: "/finance",
|
||||
iconName: "Wallet",
|
||||
},
|
||||
{
|
||||
title: "Versammlungen",
|
||||
href: "/assemblies",
|
||||
iconName: "Gavel",
|
||||
},
|
||||
{
|
||||
title: "Kalender",
|
||||
href: "/calendar",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useCallback, useRef } from "react"
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
/**
|
||||
* Hook to subscribe to forum WebSocket events for a specific club.
|
||||
@@ -12,6 +12,7 @@ export function useForumSubscription(
|
||||
onNewTopic?: (data: ForumTopicEvent) => void,
|
||||
onNewReply?: (data: ForumReplyEvent) => void
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const stompClientRef = useRef<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -41,12 +42,15 @@ export function useForumSubscription(
|
||||
|
||||
// Subscribe to specific topic if provided
|
||||
if (topicId) {
|
||||
client.subscribe(`/topic/club.${clubId}.forum.${topicId}`, (message) => {
|
||||
const data = JSON.parse(message.body)
|
||||
if (data.type === "NEW_REPLY" && onNewReply) {
|
||||
onNewReply(data as ForumReplyEvent)
|
||||
client.subscribe(
|
||||
`/topic/club.${clubId}.forum.${topicId}`,
|
||||
(message) => {
|
||||
const data = JSON.parse(message.body)
|
||||
if (data.type === "NEW_REPLY" && onNewReply) {
|
||||
onNewReply(data as ForumReplyEvent)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export function useInfoBoardSubscription(
|
||||
clubId: string | undefined,
|
||||
onNewPost?: (data: InfoBoardPostEvent) => void
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const stompClientRef = useRef<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
|
||||
export type AssemblyType = "ORDINARY" | "EXTRAORDINARY"
|
||||
export type AssemblyStatus =
|
||||
| "PLANNED"
|
||||
| "INVITED"
|
||||
| "IN_PROGRESS"
|
||||
| "COMPLETED"
|
||||
| "CANCELLED"
|
||||
export type AgendaItemType = "INFORMATION" | "DISCUSSION" | "VOTE" | "ELECTION"
|
||||
export type VoteType =
|
||||
| "SIMPLE_MAJORITY"
|
||||
| "TWO_THIRDS"
|
||||
| "THREE_QUARTERS"
|
||||
| "UNANIMOUS"
|
||||
export type VoteDecision = "YES" | "NO" | "ABSTAIN"
|
||||
export type VoteResult = "ACCEPTED" | "REJECTED"
|
||||
|
||||
export interface Assembly {
|
||||
id: string
|
||||
title: string
|
||||
assemblyType: AssemblyType
|
||||
scheduledAt: string
|
||||
location: string | null
|
||||
status: AssemblyStatus
|
||||
invitationSentAt: string | null
|
||||
quorumRequired: number | null
|
||||
openedAt: string | null
|
||||
closedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AgendaItem {
|
||||
id: string
|
||||
position: number
|
||||
title: string
|
||||
description: string | null
|
||||
itemType: AgendaItemType
|
||||
}
|
||||
|
||||
export interface Attendee {
|
||||
id: string
|
||||
memberId: string
|
||||
checkedInAt: string
|
||||
proxyForMemberId: string | null
|
||||
}
|
||||
|
||||
export interface AssemblyVote {
|
||||
id: string
|
||||
agendaItemId: string
|
||||
title: string
|
||||
description: string | null
|
||||
voteType: VoteType
|
||||
yesCount: number
|
||||
noCount: number
|
||||
abstainCount: number
|
||||
result: VoteResult | null
|
||||
votedAt: string | null
|
||||
}
|
||||
|
||||
export interface QuorumInfo {
|
||||
attendees: number
|
||||
totalMembers: number
|
||||
required: number
|
||||
quorumMet: boolean
|
||||
}
|
||||
|
||||
export interface AssemblyDetail {
|
||||
assembly: Assembly
|
||||
agendaItems: AgendaItem[]
|
||||
attendees: Attendee[]
|
||||
votes: AssemblyVote[]
|
||||
quorum: QuorumInfo
|
||||
}
|
||||
|
||||
export interface CreateAssemblyRequest {
|
||||
title: string
|
||||
assemblyType: AssemblyType
|
||||
scheduledAt: string
|
||||
location?: string
|
||||
quorumRequired?: number
|
||||
agendaItems?: {
|
||||
title: string
|
||||
description?: string
|
||||
itemType: AgendaItemType
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface CreateVoteRequest {
|
||||
agendaItemId: string
|
||||
title: string
|
||||
description?: string
|
||||
voteType: VoteType
|
||||
}
|
||||
|
||||
// === API Functions ===
|
||||
|
||||
export async function getAssemblies(): Promise<Assembly[]> {
|
||||
const res = await apiClient.get("/api/v1/assemblies")
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getAssemblyDetail(id: string): Promise<AssemblyDetail> {
|
||||
const res = await apiClient.get(`/api/v1/assemblies/${id}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createAssembly(
|
||||
data: CreateAssemblyRequest
|
||||
): Promise<Assembly> {
|
||||
const res = await apiClient.post("/api/v1/assemblies", data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function updateAssembly(
|
||||
id: string,
|
||||
data: Partial<CreateAssemblyRequest>
|
||||
): Promise<Assembly> {
|
||||
const res = await apiClient.put(`/api/v1/assemblies/${id}`, data)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function sendInvitations(id: string): Promise<Assembly> {
|
||||
const res = await apiClient.post(`/api/v1/assemblies/${id}/invite`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function cancelAssembly(id: string): Promise<Assembly> {
|
||||
const res = await apiClient.post(`/api/v1/assemblies/${id}/cancel`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function startAssembly(id: string): Promise<Assembly> {
|
||||
const res = await apiClient.post(`/api/v1/assemblies/${id}/start`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function completeAssembly(id: string): Promise<Assembly> {
|
||||
const res = await apiClient.post(`/api/v1/assemblies/${id}/complete`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function checkInAttendee(
|
||||
assemblyId: string,
|
||||
memberId: string,
|
||||
proxyForMemberId?: string
|
||||
): Promise<Attendee> {
|
||||
const res = await apiClient.post(
|
||||
`/api/v1/assemblies/${assemblyId}/attendees`,
|
||||
{ memberId, proxyForMemberId }
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getAttendees(assemblyId: string): Promise<Attendee[]> {
|
||||
const res = await apiClient.get(`/api/v1/assemblies/${assemblyId}/attendees`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function createVote(
|
||||
assemblyId: string,
|
||||
data: CreateVoteRequest
|
||||
): Promise<AssemblyVote> {
|
||||
const res = await apiClient.post(
|
||||
`/api/v1/assemblies/${assemblyId}/votes`,
|
||||
data
|
||||
)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function castVote(
|
||||
voteId: string,
|
||||
memberId: string,
|
||||
decision: VoteDecision
|
||||
): Promise<AssemblyVote> {
|
||||
const res = await apiClient.post(`/api/v1/assemblies/votes/${voteId}/cast`, {
|
||||
memberId,
|
||||
decision,
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function closeVote(voteId: string): Promise<AssemblyVote> {
|
||||
const res = await apiClient.post(`/api/v1/assemblies/votes/${voteId}/close`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function downloadProtocol(assemblyId: string): Promise<Blob> {
|
||||
const res = await apiClient.get(`/api/v1/assemblies/${assemblyId}/protocol`, {
|
||||
responseType: "blob",
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Portal
|
||||
export async function getPortalAssemblies(): Promise<Assembly[]> {
|
||||
const res = await apiClient.get("/api/v1/portal/assemblies")
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getPortalAssemblyDetail(
|
||||
id: string
|
||||
): Promise<AssemblyDetail> {
|
||||
const res = await apiClient.get(`/api/v1/portal/assemblies/${id}`)
|
||||
return res.data
|
||||
}
|
||||
@@ -70,8 +70,7 @@ export interface RsvpResponse {
|
||||
export function useEventsQuery(from: string, to: string) {
|
||||
return useQuery({
|
||||
queryKey: ["events", from, to],
|
||||
queryFn: () =>
|
||||
apiClient<ClubEvent[]>(`/events?from=${from}&to=${to}`),
|
||||
queryFn: () => apiClient<ClubEvent[]>(`/events?from=${from}&to=${to}`),
|
||||
enabled: !!from && !!to,
|
||||
})
|
||||
}
|
||||
|
||||