feat(sprint9): Phase 6 — Compliance dashboard, RetentionService, testing
Backend: - ComplianceDashboardService: traffic-light status per ComplianceArea (KCANG/FINANCE/DSGVO/VEREIN) based on deadlines, payments, board positions - RetentionService: scheduled anonymization of expired member data (KCanG §24, 5 years), with dry-run preview and retention report endpoints - ComplianceDeadlineSeeder: seeds 5 standard recurring deadlines on club creation - ComplianceDashboardController: GET /api/v1/compliance/dashboard, GET /retention, POST /retention/preview - Repository additions: countOverdue, countActive board positions/members Frontend: - /compliance page with traffic-light status cards per area - Overdue deadlines section (highlighted red) with 'days overdue' badges - Upcoming deadlines with 'days until due' badges and 'Complete' buttons - Retention info cards (KCanG §24: 5y, AO §147: 10y, DSGVO: 2y) - Navigation: added 'Compliance-Status' to sidebar under Compliance group - compliance-dashboard.ts service with mock data for dev mode Build verified: pnpm build passes clean.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# CannaManage — Visual Tour (Sprint 4)
|
||||
|
||||
**Generated:** 2026-06-13
|
||||
**Generated:** 2026-06-15
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"13722ad43cd6b8b1aa42-217e273293fc446078f4",
|
||||
"091579150db5ba1d2a73-95090d9911357adecf1f"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
completeDeadline,
|
||||
getComplianceDashboard,
|
||||
} from "@/services/compliance-dashboard"
|
||||
import {
|
||||
AlertTriangle,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
XCircle,
|
||||
} from "lucide-react"
|
||||
|
||||
import type {
|
||||
ComplianceDashboardResponse,
|
||||
ComplianceDeadline,
|
||||
} from "@/services/compliance-dashboard"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
const AREA_LABELS: Record<
|
||||
string,
|
||||
{ label: string; icon: React.ComponentType<{ className?: string }> }
|
||||
> = {
|
||||
KCANG: { label: "KCanG Compliance", icon: Shield },
|
||||
FINANCE: { label: "Finanzen & Steuern", icon: Calendar },
|
||||
DSGVO: { label: "Datenschutz (DSGVO)", icon: ShieldAlert },
|
||||
VEREIN: { label: "Vereinsrecht", icon: CheckCircle2 },
|
||||
}
|
||||
|
||||
function StatusIndicator({ status }: { status: "GREEN" | "YELLOW" | "RED" }) {
|
||||
const colors = {
|
||||
GREEN: "bg-green-500",
|
||||
YELLOW: "bg-yellow-500",
|
||||
RED: "bg-red-500",
|
||||
}
|
||||
const labels = {
|
||||
GREEN: "Konform",
|
||||
YELLOW: "Achtung",
|
||||
RED: "Handlungsbedarf",
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block h-4 w-4 rounded-full ${colors[status]}`}
|
||||
aria-label={labels[status]}
|
||||
/>
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{labels[status]}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function daysUntil(dateStr: string): number {
|
||||
const due = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = due.getTime() - now.getTime()
|
||||
return Math.ceil(diff / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
function DeadlineItem({
|
||||
deadline,
|
||||
isOverdue,
|
||||
onComplete,
|
||||
}: {
|
||||
deadline: ComplianceDeadline
|
||||
isOverdue: boolean
|
||||
onComplete: (id: string) => void
|
||||
}) {
|
||||
const days = daysUntil(deadline.dueDate)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between rounded-lg border p-3 ${
|
||||
isOverdue
|
||||
? "border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950/30"
|
||||
: "border-border"
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{deadline.title}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{deadline.area}
|
||||
</Badge>
|
||||
</div>
|
||||
{deadline.description && (
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{deadline.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Fällig: {new Date(deadline.dueDate).toLocaleDateString("de-DE")}
|
||||
{deadline.isRecurring && " • Wiederkehrend"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isOverdue ? (
|
||||
<Badge variant="destructive" className="whitespace-nowrap">
|
||||
{Math.abs(days)} Tage überfällig
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant={
|
||||
days <= 7 ? "destructive" : days <= 14 ? "secondary" : "outline"
|
||||
}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{days === 0 ? "Heute" : days === 1 ? "Morgen" : `${days} Tage`}
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onComplete(deadline.id)}
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Erledigt
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ComplianceDashboardPage() {
|
||||
const [data, setData] = useState<ComplianceDashboardResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard()
|
||||
}, [])
|
||||
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
const result = await getComplianceDashboard(30)
|
||||
setData(result)
|
||||
} catch (e) {
|
||||
console.error("Failed to load compliance dashboard", e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleComplete(deadlineId: string) {
|
||||
try {
|
||||
await completeDeadline(deadlineId, "current-user")
|
||||
await loadDashboard()
|
||||
} catch (e) {
|
||||
console.error("Failed to complete deadline", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||
Compliance-Dashboard konnte nicht geladen werden.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
Compliance Dashboard
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Übersicht über den Compliance-Status Ihres Vereins in allen relevanten
|
||||
Bereichen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Traffic Light Status Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{(
|
||||
Object.entries(data.status) as [string, "GREEN" | "YELLOW" | "RED"][]
|
||||
).map(([area, status]) => {
|
||||
const areaInfo = AREA_LABELS[area] || { label: area, icon: Shield }
|
||||
const Icon = areaInfo.icon
|
||||
return (
|
||||
<Card key={area} className="relative overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{areaInfo.label}
|
||||
</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StatusIndicator status={status} />
|
||||
</CardContent>
|
||||
{/* Colored top border */}
|
||||
<div
|
||||
className={`absolute left-0 right-0 top-0 h-1 ${
|
||||
status === "GREEN"
|
||||
? "bg-green-500"
|
||||
: status === "YELLOW"
|
||||
? "bg-yellow-500"
|
||||
: "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Overdue Deadlines */}
|
||||
{data.overdueDeadlines.length > 0 && (
|
||||
<Card className="border-red-300 dark:border-red-800">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
<CardTitle className="text-lg text-red-600 dark:text-red-400">
|
||||
Überfällige Fristen ({data.overdueDeadlines.length})
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{data.overdueDeadlines.map((d) => (
|
||||
<DeadlineItem
|
||||
key={d.id}
|
||||
deadline={d}
|
||||
isOverdue={true}
|
||||
onComplete={handleComplete}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Upcoming Deadlines */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-lg">
|
||||
Anstehende Fristen ({data.upcomingDeadlines.length})
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{data.upcomingDeadlines.length > 0 ? (
|
||||
data.upcomingDeadlines.map((d) => (
|
||||
<DeadlineItem
|
||||
key={d.id}
|
||||
deadline={d}
|
||||
isOverdue={false}
|
||||
onComplete={handleComplete}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="py-4 text-center text-muted-foreground">
|
||||
Keine anstehenden Fristen in den nächsten 30 Tagen. 🎉
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Retention Info */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-lg">Aufbewahrungsfristen</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-sm font-medium">KCanG §24</div>
|
||||
<div className="text-2xl font-bold">5 Jahre</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Mitgliederdaten nach Austritt
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-sm font-medium">AO §147</div>
|
||||
<div className="text-2xl font-bold">10 Jahre</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Finanzdaten (Aufbewahrungspflicht)
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="text-sm font-medium">DSGVO</div>
|
||||
<div className="text-2xl font-bold">2 Jahre</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Kommunikationsdaten (inaktiv)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Die automatische Datenbereinigung wird täglich um 02:00 Uhr
|
||||
durchgeführt. Personenbezogene Daten ausgetretener Mitglieder werden
|
||||
nach Ablauf der Aufbewahrungsfrist anonymisiert.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -84,6 +84,11 @@ export const navigationsData: NavigationType[] = [
|
||||
{
|
||||
title: "Compliance",
|
||||
items: [
|
||||
{
|
||||
title: "Compliance-Status",
|
||||
href: "/compliance",
|
||||
iconName: "ShieldCheck",
|
||||
},
|
||||
{
|
||||
title: "Berichtszentrale",
|
||||
href: "/reports-center",
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { apiClient } from "@/lib/api-client"
|
||||
|
||||
export interface ComplianceStatus {
|
||||
KCANG: "GREEN" | "YELLOW" | "RED"
|
||||
FINANCE: "GREEN" | "YELLOW" | "RED"
|
||||
DSGVO: "GREEN" | "YELLOW" | "RED"
|
||||
VEREIN: "GREEN" | "YELLOW" | "RED"
|
||||
}
|
||||
|
||||
export interface ComplianceDeadline {
|
||||
id: string
|
||||
clubId: string
|
||||
area: "KCANG" | "FINANCE" | "DSGVO" | "VEREIN"
|
||||
title: string
|
||||
description: string | null
|
||||
dueDate: string
|
||||
isRecurring: boolean
|
||||
recurrenceRule: string | null
|
||||
completedAt: string | null
|
||||
completedBy: string | null
|
||||
}
|
||||
|
||||
export interface ComplianceDashboardResponse {
|
||||
status: ComplianceStatus
|
||||
upcomingDeadlines: ComplianceDeadline[]
|
||||
overdueDeadlines: ComplianceDeadline[]
|
||||
}
|
||||
|
||||
export interface RetentionReport {
|
||||
totalAnonymized: number
|
||||
upcomingAnonymizations: number
|
||||
currentCutoffDate: string
|
||||
retentionSchedule: RetentionScheduleItem[]
|
||||
}
|
||||
|
||||
export interface RetentionScheduleItem {
|
||||
legalBasis: string
|
||||
description: string
|
||||
retentionYears: number
|
||||
}
|
||||
|
||||
export interface RetentionPreview {
|
||||
affectedCount: number
|
||||
items: RetentionPreviewItem[]
|
||||
}
|
||||
|
||||
export interface RetentionPreviewItem {
|
||||
memberId: string
|
||||
membershipNumber: string
|
||||
membershipDate: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
// --- Mock data for development ---
|
||||
|
||||
const mockDashboard: ComplianceDashboardResponse = {
|
||||
status: {
|
||||
KCANG: "GREEN",
|
||||
FINANCE: "YELLOW",
|
||||
DSGVO: "GREEN",
|
||||
VEREIN: "GREEN",
|
||||
},
|
||||
upcomingDeadlines: [
|
||||
{
|
||||
id: "d1",
|
||||
clubId: "c1",
|
||||
area: "FINANCE",
|
||||
title: "Kassenprüfung durchführen",
|
||||
description: "Prüfung der Vereinskasse durch gewählte Kassenprüfer",
|
||||
dueDate: new Date(Date.now() + 14 * 86400000).toISOString().split("T")[0],
|
||||
isRecurring: true,
|
||||
recurrenceRule: "YEARLY",
|
||||
completedAt: null,
|
||||
completedBy: null,
|
||||
},
|
||||
{
|
||||
id: "d2",
|
||||
clubId: "c1",
|
||||
area: "DSGVO",
|
||||
title: "VVT aktualisieren",
|
||||
description:
|
||||
"Jährliche Überprüfung des Verzeichnisses von Verarbeitungstätigkeiten",
|
||||
dueDate: new Date(Date.now() + 28 * 86400000).toISOString().split("T")[0],
|
||||
isRecurring: true,
|
||||
recurrenceRule: "YEARLY",
|
||||
completedAt: null,
|
||||
completedBy: null,
|
||||
},
|
||||
],
|
||||
overdueDeadlines: [
|
||||
{
|
||||
id: "d3",
|
||||
clubId: "c1",
|
||||
area: "FINANCE",
|
||||
title: "EÜR erstellen",
|
||||
description: "Einnahmen-Überschuss-Rechnung für das Vorjahr",
|
||||
dueDate: new Date(Date.now() - 10 * 86400000).toISOString().split("T")[0],
|
||||
isRecurring: true,
|
||||
recurrenceRule: "YEARLY",
|
||||
completedAt: null,
|
||||
completedBy: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const mockRetentionReport: RetentionReport = {
|
||||
totalAnonymized: 3,
|
||||
upcomingAnonymizations: 1,
|
||||
currentCutoffDate: new Date(Date.now() - 5 * 365 * 86400000)
|
||||
.toISOString()
|
||||
.split("T")[0],
|
||||
retentionSchedule: [
|
||||
{
|
||||
legalBasis: "KCanG §24",
|
||||
description: "Mitgliederdaten nach Austritt",
|
||||
retentionYears: 5,
|
||||
},
|
||||
{
|
||||
legalBasis: "AO §147",
|
||||
description: "Finanzdaten (Aufbewahrungspflicht)",
|
||||
retentionYears: 10,
|
||||
},
|
||||
{
|
||||
legalBasis: "DSGVO",
|
||||
description: "Kommunikationsdaten (inaktiv)",
|
||||
retentionYears: 2,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// --- API functions ---
|
||||
|
||||
export async function getComplianceDashboard(
|
||||
upcomingDays = 30
|
||||
): Promise<ComplianceDashboardResponse> {
|
||||
if (process.env.NEXT_PUBLIC_USE_MOCK === "true") {
|
||||
return mockDashboard
|
||||
}
|
||||
return apiClient<ComplianceDashboardResponse>(
|
||||
`/api/v1/compliance/dashboard?upcomingDays=${upcomingDays}`
|
||||
)
|
||||
}
|
||||
|
||||
export async function getRetentionReport(): Promise<RetentionReport> {
|
||||
if (process.env.NEXT_PUBLIC_USE_MOCK === "true") {
|
||||
return mockRetentionReport
|
||||
}
|
||||
return apiClient<RetentionReport>("/api/v1/compliance/dashboard/retention")
|
||||
}
|
||||
|
||||
export async function previewRetention(): Promise<RetentionPreview> {
|
||||
if (process.env.NEXT_PUBLIC_USE_MOCK === "true") {
|
||||
return { affectedCount: 0, items: [] }
|
||||
}
|
||||
return apiClient<RetentionPreview>(
|
||||
"/api/v1/compliance/dashboard/retention/preview",
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function completeDeadline(
|
||||
deadlineId: string,
|
||||
completedBy: string
|
||||
): Promise<ComplianceDeadline> {
|
||||
if (process.env.NEXT_PUBLIC_USE_MOCK === "true") {
|
||||
return {
|
||||
...mockDashboard.upcomingDeadlines[0],
|
||||
completedAt: new Date().toISOString(),
|
||||
completedBy,
|
||||
}
|
||||
}
|
||||
return apiClient<ComplianceDeadline>(
|
||||
`/api/v1/compliance/deadlines/${deadlineId}/complete`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ completedBy }),
|
||||
}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user