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)
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user