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>
1042 lines
39 KiB
TypeScript
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>
|
|
)
|
|
}
|