This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/admin-v2/app/(sdk)/sdk/reporting/page.tsx
BreakPilot Dev 00f778ca9b refactor: Remove Compliance SDK from admin-v2 sidebar, add new SDK modules
Remove Compliance SDK category from sidebar navigation as it is now
handled exclusively in the Compliance Admin. Add new SDK modules
(DSB Portal, Industry Templates, Multi-Tenant, Reporting, SSO) and
GCI engine components.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 10:20:16 +01:00

1042 lines
39 KiB
TypeScript

'use client'
import React, { useState, useEffect, useMemo } from 'react'
import {
Shield,
FileText,
AlertTriangle,
Clock,
Users,
GraduationCap,
Megaphone,
Activity,
Printer,
RefreshCw,
ChevronDown,
ChevronUp,
CalendarClock,
TrendingUp,
BarChart3,
CheckCircle2,
XCircle,
Info
} from 'lucide-react'
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell
} from 'recharts'
// =============================================================================
// TYPES
// =============================================================================
interface ExecutiveReport {
generated_at: string
tenant_id: string
compliance_score: number
dsgvo: {
processing_activities: number
active_processings: number
toms_implemented: number
toms_planned: number
toms_total: number
completion_percent: number
open_dsrs: number
overdue_dsrs: number
dsfas_completed: number
retention_policies: number
}
vendors: {
total_vendors: number
active_vendors: number
by_risk_level: Record<string, number>
pending_reviews: number
expired_contracts: number
}
incidents: {
total_incidents: number
open_incidents: number
critical_incidents: number
notifications_pending: number
avg_resolution_hours: number
}
whistleblower: {
total_reports: number
open_reports: number
overdue_acknowledgments: number
overdue_feedbacks: number
avg_resolution_days: number
}
academy: {
total_courses: number
total_enrollments: number
completion_rate: number
overdue_count: number
avg_completion_days: number
}
risk_overview: {
overall_level: string
module_risks: Array<{
module: string
level: string
score: number
issues: number
}>
open_findings: number
critical_findings: number
}
upcoming_deadlines: Array<{
module: string
type: string
description: string
due_date: string
days_left: number
severity: string
}>
recent_activity: Array<{
timestamp: string
module: string
action: string
description: string
user_id?: string
}>
}
type SortDirection = 'asc' | 'desc'
// =============================================================================
// CONSTANTS
// =============================================================================
const FALLBACK_TENANT_ID = '00000000-0000-0000-0000-000000000001'
const FALLBACK_USER_ID = '00000000-0000-0000-0000-000000000001'
const API_BASE = '/api/sdk/v1/reporting/executive'
const MODULE_LABELS: Record<string, string> = {
dsgvo: 'DSGVO',
vendors: 'Dienstleister',
incidents: 'Vorfaelle',
whistleblower: 'Hinweisgeber',
academy: 'Academy',
contracts: 'Vertraege',
dsr: 'Betroffenenrechte'
}
const MODULE_ICONS: Record<string, React.ReactNode> = {
dsgvo: <Shield className="w-5 h-5" />,
vendors: <Users className="w-5 h-5" />,
incidents: <AlertTriangle className="w-5 h-5" />,
whistleblower: <Megaphone className="w-5 h-5" />,
academy: <GraduationCap className="w-5 h-5" />,
contracts: <FileText className="w-5 h-5" />,
dsr: <Clock className="w-5 h-5" />
}
const SEVERITY_STYLES: Record<string, { bg: string; text: string; border: string }> = {
INFO: { bg: 'bg-blue-100 dark:bg-blue-900/40', text: 'text-blue-700 dark:text-blue-300', border: 'border-blue-300 dark:border-blue-700' },
WARNING: { bg: 'bg-yellow-100 dark:bg-yellow-900/40', text: 'text-yellow-700 dark:text-yellow-300', border: 'border-yellow-300 dark:border-yellow-700' },
URGENT: { bg: 'bg-orange-100 dark:bg-orange-900/40', text: 'text-orange-700 dark:text-orange-300', border: 'border-orange-300 dark:border-orange-700' },
OVERDUE: { bg: 'bg-red-100 dark:bg-red-900/40', text: 'text-red-700 dark:text-red-300', border: 'border-red-300 dark:border-red-700' }
}
const RISK_COLORS: Record<string, string> = {
LOW: '#22c55e',
MEDIUM: '#eab308',
HIGH: '#f97316',
CRITICAL: '#ef4444'
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
function getTenantId(): string {
if (typeof window !== 'undefined') {
return localStorage.getItem('sdk-tenant-id') || FALLBACK_TENANT_ID
}
return FALLBACK_TENANT_ID
}
function getUserId(): string {
if (typeof window !== 'undefined') {
return localStorage.getItem('sdk-user-id') || FALLBACK_USER_ID
}
return FALLBACK_USER_ID
}
function getRiskColor(score: number): string {
if (score >= 75) return '#22c55e'
if (score >= 50) return '#eab308'
if (score >= 25) return '#f97316'
return '#ef4444'
}
function getRiskLevel(score: number): string {
if (score >= 75) return 'LOW'
if (score >= 50) return 'MEDIUM'
if (score >= 25) return 'HIGH'
return 'CRITICAL'
}
function getRiskBadgeClasses(level: string): string {
switch (level.toUpperCase()) {
case 'LOW':
return 'bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-300'
case 'MEDIUM':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-300'
case 'HIGH':
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/50 dark:text-orange-300'
case 'CRITICAL':
return 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300'
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'
}
}
function getModuleBorderColor(level: string): string {
switch (level.toUpperCase()) {
case 'LOW': return 'border-l-green-500'
case 'MEDIUM': return 'border-l-yellow-500'
case 'HIGH': return 'border-l-orange-500'
case 'CRITICAL': return 'border-l-red-500'
default: return 'border-l-gray-400'
}
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
function formatDateTime(dateString: string): string {
return new Date(dateString).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
function formatRelativeTime(dateString: string): string {
const now = new Date()
const date = new Date(dateString)
const diffMs = now.getTime() - date.getTime()
const diffMinutes = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMinutes < 1) return 'Gerade eben'
if (diffMinutes < 60) return `vor ${diffMinutes} Min.`
if (diffHours < 24) return `vor ${diffHours} Std.`
if (diffDays < 7) return `vor ${diffDays} Tag${diffDays !== 1 ? 'en' : ''}`
return formatDate(dateString)
}
// =============================================================================
// SKELETON / LOADING COMPONENTS
// =============================================================================
function SkeletonPulse({ className = '' }: { className?: string }) {
return (
<div className={`animate-pulse bg-gray-200 dark:bg-gray-700 rounded ${className}`} />
)
}
function LoadingSkeleton() {
return (
<div className="space-y-8">
{/* Header Skeleton */}
<div className="flex items-center justify-between">
<div>
<SkeletonPulse className="h-8 w-96 mb-2" />
<SkeletonPulse className="h-4 w-48" />
</div>
<SkeletonPulse className="h-10 w-32" />
</div>
{/* Score Skeleton */}
<div className="flex items-center justify-center py-8">
<SkeletonPulse className="h-48 w-48 rounded-full" />
</div>
{/* Module Grid Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<SkeletonPulse className="h-5 w-24 mb-3" />
<SkeletonPulse className="h-8 w-16 mb-2" />
<SkeletonPulse className="h-3 w-full mb-1" />
<SkeletonPulse className="h-3 w-3/4" />
</div>
))}
</div>
{/* Chart Skeleton */}
<SkeletonPulse className="h-64 w-full rounded-xl" />
{/* Table Skeleton */}
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<SkeletonPulse key={i} className="h-12 w-full rounded-lg" />
))}
</div>
</div>
)
}
// =============================================================================
// COMPLIANCE SCORE RING
// =============================================================================
function ComplianceScoreRing({ score, size = 200 }: { score: number; size?: number }) {
const strokeWidth = 12
const radius = (size - strokeWidth) / 2
const circumference = 2 * Math.PI * radius
const progress = Math.max(0, Math.min(100, score))
const offset = circumference - (progress / 100) * circumference
const color = getRiskColor(score)
const level = getRiskLevel(score)
return (
<div className="flex flex-col items-center gap-4">
<div className="relative" style={{ width: size, height: size }}>
<svg width={size} height={size} className="transform -rotate-90">
{/* Background circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-gray-200 dark:text-gray-700"
/>
{/* Progress circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-1000 ease-out"
/>
</svg>
{/* Center text */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-5xl font-bold text-gray-900 dark:text-white">{score}</span>
<span className="text-sm text-gray-500 dark:text-gray-400">von 100</span>
</div>
</div>
<span className={`px-4 py-1.5 text-sm font-semibold rounded-full ${getRiskBadgeClasses(level)}`}>
Risikostufe: {level}
</span>
</div>
)
}
// =============================================================================
// MODULE CARD
// =============================================================================
function ModuleCard({
module,
icon,
riskLevel,
children
}: {
module: string
icon: React.ReactNode
riskLevel: string
children: React.ReactNode
}) {
const borderColor = getModuleBorderColor(riskLevel)
return (
<div className={`bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 border-l-4 ${borderColor} p-5 hover:shadow-md transition-shadow`}>
<div className="flex items-center gap-2 mb-3">
<span className="text-gray-600 dark:text-gray-400">{icon}</span>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white uppercase tracking-wide">
{module}
</h3>
<span className={`ml-auto px-2 py-0.5 text-xs font-medium rounded-full ${getRiskBadgeClasses(riskLevel)}`}>
{riskLevel}
</span>
</div>
<div className="space-y-2">
{children}
</div>
</div>
)
}
function MetricRow({ label, value, highlight = false }: { label: string; value: string | number; highlight?: boolean }) {
return (
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500 dark:text-gray-400">{label}</span>
<span className={`font-semibold ${highlight ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white'}`}>
{value}
</span>
</div>
)
}
function MiniProgressBar({ percent, color = 'purple' }: { percent: number; color?: string }) {
const colorClasses: Record<string, string> = {
green: 'bg-green-500',
yellow: 'bg-yellow-500',
orange: 'bg-orange-500',
red: 'bg-red-500',
purple: 'bg-purple-500'
}
const barColor = colorClasses[color] || colorClasses.purple
return (
<div className="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${barColor}`}
style={{ width: `${Math.min(100, Math.max(0, percent))}%` }}
/>
</div>
)
}
function VendorRiskBar({ byRiskLevel }: { byRiskLevel: Record<string, number> }) {
const total = Object.values(byRiskLevel).reduce((sum, v) => sum + v, 0)
if (total === 0) return <span className="text-xs text-gray-400 dark:text-gray-500">Keine Daten</span>
const segments = [
{ key: 'low', color: 'bg-green-500', count: byRiskLevel['low'] || byRiskLevel['LOW'] || 0 },
{ key: 'medium', color: 'bg-yellow-500', count: byRiskLevel['medium'] || byRiskLevel['MEDIUM'] || 0 },
{ key: 'high', color: 'bg-orange-500', count: byRiskLevel['high'] || byRiskLevel['HIGH'] || 0 },
{ key: 'critical', color: 'bg-red-500', count: byRiskLevel['critical'] || byRiskLevel['CRITICAL'] || 0 }
]
return (
<div className="space-y-1">
<div className="flex h-2 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-700">
{segments.map(seg => {
const pct = (seg.count / total) * 100
if (pct <= 0) return null
return (
<div
key={seg.key}
className={`${seg.color} transition-all`}
style={{ width: `${pct}%` }}
title={`${seg.key}: ${seg.count}`}
/>
)
})}
</div>
<div className="flex gap-3 text-xs text-gray-500 dark:text-gray-400">
{segments.filter(s => s.count > 0).map(seg => (
<span key={seg.key} className="flex items-center gap-1">
<span className={`w-2 h-2 rounded-full ${seg.color}`} />
{seg.count}
</span>
))}
</div>
</div>
)
}
// =============================================================================
// RISK HEATMAP CHART
// =============================================================================
function RiskHeatmapChart({ moduleRisks }: { moduleRisks: ExecutiveReport['risk_overview']['module_risks'] }) {
const chartData = moduleRisks.map(mr => ({
name: MODULE_LABELS[mr.module] || mr.module,
score: mr.score,
issues: mr.issues,
level: mr.level,
fill: RISK_COLORS[mr.level.toUpperCase()] || '#9ca3af'
}))
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 print:border print:border-gray-300">
<div className="flex items-center gap-2 mb-4">
<BarChart3 className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Risiko-Heatmap nach Modul
</h2>
</div>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} layout="vertical" margin={{ left: 80, right: 40, top: 5, bottom: 5 }}>
<XAxis type="number" domain={[0, 100]} tick={{ fontSize: 12 }} />
<YAxis
type="category"
dataKey="name"
tick={{ fontSize: 13 }}
width={80}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255,255,255,0.95)',
border: '1px solid #e5e7eb',
borderRadius: '8px',
fontSize: '13px'
}}
formatter={(value: number, _name: string, props: { payload: { issues: number; level: string } }) => {
return [`${value}/100 (${props.payload.issues} offene Punkte)`, 'Risiko-Score']
}}
/>
<Bar dataKey="score" radius={[0, 6, 6, 0]} barSize={28}>
{chartData.map((entry, index) => (
<Cell key={index} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{/* Legend */}
<div className="flex items-center justify-center gap-6 mt-4 text-xs text-gray-500 dark:text-gray-400">
{Object.entries(RISK_COLORS).map(([level, color]) => (
<span key={level} className="flex items-center gap-1.5">
<span className="w-3 h-3 rounded-sm" style={{ backgroundColor: color }} />
{level}
</span>
))}
</div>
</div>
)
}
// =============================================================================
// DEADLINES TABLE
// =============================================================================
function DeadlinesTable({ deadlines }: { deadlines: ExecutiveReport['upcoming_deadlines'] }) {
const [sortDir, setSortDir] = useState<SortDirection>('asc')
const sorted = useMemo(() => {
return [...deadlines].sort((a, b) => {
const diff = a.days_left - b.days_left
return sortDir === 'asc' ? diff : -diff
})
}, [deadlines, sortDir])
const toggleSort = () => {
setSortDir(prev => prev === 'asc' ? 'desc' : 'asc')
}
if (deadlines.length === 0) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-8 text-center print:border print:border-gray-300">
<CalendarClock className="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto mb-3" />
<h3 className="text-base font-semibold text-gray-700 dark:text-gray-300">Keine Fristen</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Es stehen derzeit keine anstehenden Fristen aus.
</p>
</div>
)
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden print:border print:border-gray-300">
<div className="flex items-center gap-2 p-5 pb-3">
<CalendarClock className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Anstehende Fristen
</h2>
<span className="ml-auto text-sm text-gray-500 dark:text-gray-400">{deadlines.length} Eintraege</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-t border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<th className="text-left px-5 py-3 font-medium text-gray-500 dark:text-gray-400">Modul</th>
<th className="text-left px-5 py-3 font-medium text-gray-500 dark:text-gray-400">Beschreibung</th>
<th className="text-left px-5 py-3 font-medium text-gray-500 dark:text-gray-400">
<button onClick={toggleSort} className="flex items-center gap-1 hover:text-gray-700 dark:hover:text-gray-200 transition-colors">
Faelligkeit
{sortDir === 'asc' ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</button>
</th>
<th className="text-left px-5 py-3 font-medium text-gray-500 dark:text-gray-400">Verbleibend</th>
<th className="text-left px-5 py-3 font-medium text-gray-500 dark:text-gray-400">Dringlichkeit</th>
</tr>
</thead>
<tbody>
{sorted.map((dl, i) => {
const severity = SEVERITY_STYLES[dl.severity.toUpperCase()] || SEVERITY_STYLES.INFO
return (
<tr key={i} className="border-b border-gray-100 dark:border-gray-700/50 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
<td className="px-5 py-3">
<span className="flex items-center gap-2 text-gray-700 dark:text-gray-300">
{MODULE_ICONS[dl.module.toLowerCase()] || <FileText className="w-4 h-4" />}
<span className="font-medium">{MODULE_LABELS[dl.module.toLowerCase()] || dl.module}</span>
</span>
</td>
<td className="px-5 py-3 text-gray-600 dark:text-gray-400 max-w-xs truncate">
{dl.description}
</td>
<td className="px-5 py-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">
{formatDate(dl.due_date)}
</td>
<td className="px-5 py-3 whitespace-nowrap">
<span className={`font-semibold ${dl.days_left <= 0 ? 'text-red-600 dark:text-red-400' : dl.days_left <= 7 ? 'text-orange-600 dark:text-orange-400' : 'text-gray-700 dark:text-gray-300'}`}>
{dl.days_left <= 0
? `${Math.abs(dl.days_left)} Tag${Math.abs(dl.days_left) !== 1 ? 'e' : ''} ueberfaellig`
: `${dl.days_left} Tag${dl.days_left !== 1 ? 'e' : ''}`
}
</span>
</td>
<td className="px-5 py-3">
<span className={`inline-flex px-2.5 py-1 text-xs font-semibold rounded-full ${severity.bg} ${severity.text}`}>
{dl.severity}
</span>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}
// =============================================================================
// ACTIVITY TIMELINE
// =============================================================================
function ActivityTimeline({ activities }: { activities: ExecutiveReport['recent_activity'] }) {
const displayedActivities = activities.slice(0, 20)
if (displayedActivities.length === 0) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-8 text-center print:border print:border-gray-300">
<Activity className="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto mb-3" />
<h3 className="text-base font-semibold text-gray-700 dark:text-gray-300">Keine Aktivitaeten</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Es wurden noch keine Aktivitaeten verzeichnet.
</p>
</div>
)
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 print:border print:border-gray-300">
<div className="flex items-center gap-2 mb-5">
<Activity className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Letzte Aktivitaeten
</h2>
<span className="ml-auto text-sm text-gray-500 dark:text-gray-400">
{displayedActivities.length} von {activities.length}
</span>
</div>
<div className="relative">
{/* Vertical line */}
<div className="absolute left-[19px] top-2 bottom-2 w-px bg-gray-200 dark:bg-gray-700" />
<div className="space-y-4">
{displayedActivities.map((act, i) => (
<div key={i} className="flex items-start gap-4 relative">
{/* Timeline dot */}
<div className="relative z-10 flex-shrink-0 w-10 h-10 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-gray-500 dark:text-gray-400">
{MODULE_ICONS[act.module.toLowerCase()] || <FileText className="w-4 h-4" />}
</div>
{/* Content */}
<div className="flex-1 min-w-0 pb-1">
<div className="flex items-center gap-2 flex-wrap">
<span className={`inline-flex px-2 py-0.5 text-xs font-medium rounded-full ${getRiskBadgeClasses('LOW')}`}>
{MODULE_LABELS[act.module.toLowerCase()] || act.module}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">
{formatRelativeTime(act.timestamp)}
</span>
</div>
<p className="text-sm font-medium text-gray-800 dark:text-gray-200 mt-0.5">
{act.action}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">
{act.description}
</p>
</div>
</div>
))}
</div>
</div>
</div>
)
}
// =============================================================================
// ERROR STATE
// =============================================================================
function ErrorState({ error, onRetry }: { error: string; onRetry: () => void }) {
return (
<div className="flex flex-col items-center justify-center py-16">
<div className="w-16 h-16 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center mb-4">
<XCircle className="w-8 h-8 text-red-500" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
Bericht konnte nicht geladen werden
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 max-w-md text-center">
{error}
</p>
<button
onClick={onRetry}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<RefreshCw className="w-4 h-4" />
Erneut versuchen
</button>
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function ExecutiveReportingPage() {
const [report, setReport] = useState<ExecutiveReport | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchReport = async () => {
setIsLoading(true)
setError(null)
try {
const response = await fetch(API_BASE, {
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': getTenantId(),
'X-User-ID': getUserId()
}
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data: ExecutiveReport = await response.json()
setReport(data)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unbekannter Fehler'
console.error('Fehler beim Laden des Executive Reports:', message)
setError(message)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
fetchReport()
}, [])
const handlePrint = () => {
window.print()
}
const moduleRiskMap = useMemo(() => {
if (!report) return {}
const map: Record<string, string> = {}
for (const mr of report.risk_overview.module_risks) {
map[mr.module.toLowerCase()] = mr.level
}
return map
}, [report])
return (
<div className="space-y-8 print:space-y-6">
{/* Header */}
<div className="flex items-start justify-between flex-wrap gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Compliance-Bericht fuer die Geschaeftsfuehrung
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{report
? `Erstellt am ${formatDateTime(report.generated_at)}`
: 'Wird geladen...'
}
</p>
</div>
<button
onClick={handlePrint}
disabled={isLoading || !!error}
className="print:hidden flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Printer className="w-4 h-4" />
PDF exportieren
</button>
</div>
{/* Loading State */}
{isLoading && <LoadingSkeleton />}
{/* Error State */}
{!isLoading && error && <ErrorState error={error} onRetry={fetchReport} />}
{/* Report Content */}
{!isLoading && !error && report && (
<>
{/* ============================================================= */}
{/* SECTION 1: Overall Compliance Score */}
{/* ============================================================= */}
<section className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 py-10 px-6 print:border print:border-gray-300">
<div className="flex flex-col items-center">
<h2 className="text-base font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-6">
Gesamt-Compliance-Score
</h2>
<ComplianceScoreRing score={report.compliance_score} size={200} />
<div className="flex items-center gap-6 mt-6 text-sm text-gray-600 dark:text-gray-400">
<span className="flex items-center gap-1.5">
<TrendingUp className="w-4 h-4" />
{report.risk_overview.open_findings} offene Feststellungen
</span>
<span className="flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4 text-red-500" />
{report.risk_overview.critical_findings} kritisch
</span>
</div>
</div>
</section>
{/* ============================================================= */}
{/* SECTION 2: Module Overview Grid */}
{/* ============================================================= */}
<section>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 text-purple-600" />
Moduluebersicht
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* DSGVO Module */}
<ModuleCard
module="DSGVO"
icon={<Shield className="w-5 h-5" />}
riskLevel={moduleRiskMap['dsgvo'] || 'MEDIUM'}
>
<MetricRow label="TOM-Umsetzung" value={`${report.dsgvo.completion_percent}%`} />
<MiniProgressBar
percent={report.dsgvo.completion_percent}
color={report.dsgvo.completion_percent >= 75 ? 'green' : report.dsgvo.completion_percent >= 50 ? 'yellow' : 'red'}
/>
<MetricRow label="Offene DSR-Anfragen" value={report.dsgvo.open_dsrs} highlight={report.dsgvo.overdue_dsrs > 0} />
<MetricRow label="Aktive VVTs" value={report.dsgvo.active_processings} />
<MetricRow label="DSFA abgeschlossen" value={report.dsgvo.dsfas_completed} />
</ModuleCard>
{/* Vendors Module */}
<ModuleCard
module="Dienstleister"
icon={<Users className="w-5 h-5" />}
riskLevel={moduleRiskMap['vendors'] || 'MEDIUM'}
>
<MetricRow label="Dienstleister gesamt" value={report.vendors.total_vendors} />
<VendorRiskBar byRiskLevel={report.vendors.by_risk_level} />
<MetricRow label="Ausstehende Pruefungen" value={report.vendors.pending_reviews} highlight={report.vendors.pending_reviews > 0} />
<MetricRow label="Abgelaufene Vertraege" value={report.vendors.expired_contracts} highlight={report.vendors.expired_contracts > 0} />
</ModuleCard>
{/* Incidents Module */}
<ModuleCard
module="Vorfaelle"
icon={<AlertTriangle className="w-5 h-5" />}
riskLevel={moduleRiskMap['incidents'] || 'MEDIUM'}
>
<MetricRow label="Offene Vorfaelle" value={report.incidents.open_incidents} highlight={report.incidents.critical_incidents > 0} />
<MetricRow label="Kritische Vorfaelle" value={report.incidents.critical_incidents} highlight={report.incidents.critical_incidents > 0} />
<MetricRow label="Meldungen ausstehend" value={report.incidents.notifications_pending} highlight={report.incidents.notifications_pending > 0} />
<MetricRow label="Durchschn. Loesung" value={`${report.incidents.avg_resolution_hours}h`} />
</ModuleCard>
{/* Whistleblower Module */}
<ModuleCard
module="Hinweisgeber"
icon={<Megaphone className="w-5 h-5" />}
riskLevel={moduleRiskMap['whistleblower'] || 'MEDIUM'}
>
<MetricRow label="Meldungen gesamt" value={report.whistleblower.total_reports} />
<MetricRow label="Offene Meldungen" value={report.whistleblower.open_reports} />
<MetricRow label="Ueberfaellige Bestaetigung" value={report.whistleblower.overdue_acknowledgments} highlight={report.whistleblower.overdue_acknowledgments > 0} />
<MetricRow label="Ueberfaellige Rueckmeldung" value={report.whistleblower.overdue_feedbacks} highlight={report.whistleblower.overdue_feedbacks > 0} />
</ModuleCard>
{/* Academy Module */}
<ModuleCard
module="Academy"
icon={<GraduationCap className="w-5 h-5" />}
riskLevel={moduleRiskMap['academy'] || 'MEDIUM'}
>
<MetricRow label="Abschlussrate" value={`${report.academy.completion_rate}%`} />
<MiniProgressBar
percent={report.academy.completion_rate}
color={report.academy.completion_rate >= 80 ? 'green' : report.academy.completion_rate >= 50 ? 'yellow' : 'red'}
/>
<MetricRow label="Ueberfaellige Schulungen" value={report.academy.overdue_count} highlight={report.academy.overdue_count > 0} />
<MetricRow label="Kurse / Teilnehmer" value={`${report.academy.total_courses} / ${report.academy.total_enrollments}`} />
</ModuleCard>
</div>
</section>
{/* ============================================================= */}
{/* SECTION 3: Risk Heatmap */}
{/* ============================================================= */}
<section>
<RiskHeatmapChart moduleRisks={report.risk_overview.module_risks} />
</section>
{/* ============================================================= */}
{/* SECTION 4: Upcoming Deadlines */}
{/* ============================================================= */}
<section>
<DeadlinesTable deadlines={report.upcoming_deadlines} />
</section>
{/* ============================================================= */}
{/* SECTION 5: Recent Activity Timeline */}
{/* ============================================================= */}
<section>
<ActivityTimeline activities={report.recent_activity} />
</section>
{/* ============================================================= */}
{/* FOOTER: Info Note */}
{/* ============================================================= */}
<section className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-4 print:border print:border-blue-300">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<div>
<h4 className="font-medium text-blue-800 dark:text-blue-300 text-sm">
Hinweis zum Compliance-Bericht
</h4>
<p className="text-sm text-blue-600 dark:text-blue-400 mt-1">
Dieser Bericht wird automatisch aus den Daten aller aktiven Compliance-Module generiert.
Der Compliance-Score berechnet sich aus dem gewichteten Mittel der einzelnen Modulbewertungen.
Fuer einen rechtlich verbindlichen Nachweis konsultieren Sie bitte Ihren Datenschutzbeauftragten.
Bericht-ID: {report.tenant_id} | Stichtag: {formatDateTime(report.generated_at)}
</p>
</div>
</div>
</section>
</>
)}
{/* ================================================================= */}
{/* PRINT-FRIENDLY STYLES */}
{/* ================================================================= */}
<style jsx global>{`
@media print {
/* Hide non-essential UI elements */
nav,
aside,
header,
footer,
.print\\:hidden,
button:not(.print-keep),
[data-sidebar],
[data-topbar] {
display: none !important;
}
/* White background for print */
body,
html {
background: white !important;
color: black !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
/* Remove dark mode classes for print */
.dark\\:bg-gray-800,
.dark\\:bg-gray-900,
.dark\\:bg-gray-700 {
background-color: white !important;
}
.dark\\:text-white,
.dark\\:text-gray-200,
.dark\\:text-gray-300,
.dark\\:text-gray-400 {
color: #1f2937 !important;
}
.dark\\:border-gray-700,
.dark\\:border-gray-600 {
border-color: #e5e7eb !important;
}
/* Ensure charts render with colors */
svg {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
/* Page break rules */
section {
page-break-inside: avoid;
}
table {
page-break-inside: auto;
}
tr {
page-break-inside: avoid;
}
/* Full width for print */
main,
[role="main"] {
max-width: 100% !important;
padding: 0 !important;
margin: 0 !important;
}
/* Ensure colored borders print */
.border-l-green-500 { border-left-color: #22c55e !important; }
.border-l-yellow-500 { border-left-color: #eab308 !important; }
.border-l-orange-500 { border-left-color: #f97316 !important; }
.border-l-red-500 { border-left-color: #ef4444 !important; }
/* Badge colors for print */
.bg-green-100 { background-color: #dcfce7 !important; }
.bg-yellow-100 { background-color: #fef9c3 !important; }
.bg-orange-100 { background-color: #ffedd5 !important; }
.bg-red-100 { background-color: #fee2e2 !important; }
.bg-blue-100 { background-color: #dbeafe !important; }
.bg-blue-50 { background-color: #eff6ff !important; }
.text-green-800 { color: #166534 !important; }
.text-yellow-800 { color: #854d0e !important; }
.text-orange-800 { color: #9a3412 !important; }
.text-red-800 { color: #991b1b !important; }
.text-blue-800 { color: #1e40af !important; }
.text-red-600 { color: #dc2626 !important; }
.text-orange-600 { color: #ea580c !important; }
}
`}</style>
</div>
)
}