Website (14 monoliths split): - compliance/page.tsx (1,519 → 9), docs/audit (1,262 → 20) - quality (1,231 → 16), alerts (1,203 → 10), docs (1,202 → 11) - i18n.ts (1,173 → 8 language files) - unity-bridge (1,094 → 12), backlog (1,087 → 6) - training (1,066 → 8), rag (1,063 → 8) - Deleted index_original.ts (4,899 LOC dead backup) Studio-v2 (5 monoliths split): - meet/page.tsx (1,481 → 9), messages (1,166 → 9) - AlertsB2BContext.tsx (1,165 → 5 modules) - alerts-b2b/page.tsx (1,019 → 6), korrektur/archiv (1,001 → 6) All existing imports preserved. Zero new TypeScript errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
304 lines
11 KiB
TypeScript
304 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import dynamic from 'next/dynamic'
|
|
import { ExecutiveDashboardData } from '../types'
|
|
|
|
const ComplianceTrendChart = dynamic(
|
|
() => import('@/components/compliance/charts/ComplianceTrendChart'),
|
|
{ ssr: false, loading: () => <div className="h-48 bg-slate-100 animate-pulse rounded" /> }
|
|
)
|
|
|
|
interface ExecutiveTabProps {
|
|
loading: boolean
|
|
onRefresh: () => void
|
|
}
|
|
|
|
export default function ExecutiveTab({ loading, onRefresh }: ExecutiveTabProps) {
|
|
const [executiveData, setExecutiveData] = useState<ExecutiveDashboardData | null>(null)
|
|
const [execLoading, setExecLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
|
|
|
|
useEffect(() => {
|
|
loadExecutiveData()
|
|
}, [])
|
|
|
|
const loadExecutiveData = async () => {
|
|
setExecLoading(true)
|
|
setError(null)
|
|
try {
|
|
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/dashboard/executive`)
|
|
if (res.ok) {
|
|
setExecutiveData(await res.json())
|
|
} else {
|
|
setError('Executive Dashboard konnte nicht geladen werden')
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load executive dashboard:', err)
|
|
setError('Verbindung zum Backend fehlgeschlagen')
|
|
} finally {
|
|
setExecLoading(false)
|
|
}
|
|
}
|
|
|
|
if (execLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error || !executiveData) {
|
|
return (
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
|
<p className="text-red-700">{error || 'Keine Daten verfuegbar'}</p>
|
|
<button
|
|
onClick={loadExecutiveData}
|
|
className="mt-4 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
|
>
|
|
Erneut versuchen
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const { traffic_light_status, overall_score, score_trend, score_change, top_risks, upcoming_deadlines, team_workload } = executiveData
|
|
|
|
const trafficLightColors = {
|
|
green: { bg: 'bg-green-500', ring: 'ring-green-200', text: 'text-green-700', label: 'Gut' },
|
|
yellow: { bg: 'bg-yellow-500', ring: 'ring-yellow-200', text: 'text-yellow-700', label: 'Achtung' },
|
|
red: { bg: 'bg-red-500', ring: 'ring-red-200', text: 'text-red-700', label: 'Kritisch' },
|
|
}
|
|
|
|
const tlConfig = trafficLightColors[traffic_light_status]
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header Row: Traffic Light + Key Metrics */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
|
<TrafficLightCard
|
|
tlConfig={tlConfig}
|
|
overall_score={overall_score}
|
|
score_change={score_change}
|
|
/>
|
|
<MetricCard
|
|
label="Verordnungen"
|
|
value={executiveData.total_regulations}
|
|
detail={`${executiveData.total_requirements} Anforderungen`}
|
|
iconBg="bg-blue-100"
|
|
iconColor="text-blue-600"
|
|
iconPath="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
|
/>
|
|
<MetricCard
|
|
label="Massnahmen"
|
|
value={executiveData.total_controls}
|
|
detail="Technische Controls"
|
|
iconBg="bg-green-100"
|
|
iconColor="text-green-600"
|
|
iconPath="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
/>
|
|
<MetricCard
|
|
label="Offene Risiken"
|
|
value={executiveData.open_risks}
|
|
detail="Unmitigiert"
|
|
iconBg="bg-red-100"
|
|
iconColor="text-red-600"
|
|
iconPath="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
/>
|
|
</div>
|
|
|
|
{/* Charts Row */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<TrendChartCard score_trend={score_trend} onRefresh={loadExecutiveData} />
|
|
<TopRisksCard top_risks={top_risks} />
|
|
</div>
|
|
|
|
{/* Bottom Row: Deadlines + Workload */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<DeadlinesCard upcoming_deadlines={upcoming_deadlines} />
|
|
<WorkloadCard team_workload={team_workload} />
|
|
</div>
|
|
|
|
{/* Last Updated */}
|
|
<div className="text-right text-sm text-slate-400">
|
|
Zuletzt aktualisiert: {new Date(executiveData.last_updated).toLocaleString('de-DE')}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Sub-components
|
|
// ============================================================================
|
|
|
|
function TrafficLightCard({ tlConfig, overall_score, score_change }: {
|
|
tlConfig: { bg: string; ring: string; text: string; label: string }
|
|
overall_score: number
|
|
score_change: number | null
|
|
}) {
|
|
return (
|
|
<div className="bg-white rounded-xl shadow-sm border p-6 flex flex-col items-center justify-center">
|
|
<div
|
|
className={`w-28 h-28 rounded-full flex items-center justify-center ${tlConfig.bg} ring-8 ${tlConfig.ring} shadow-lg mb-4`}
|
|
>
|
|
<span className="text-4xl font-bold text-white">{overall_score.toFixed(0)}%</span>
|
|
</div>
|
|
<p className={`text-lg font-semibold ${tlConfig.text}`}>{tlConfig.label}</p>
|
|
<p className="text-sm text-slate-500 mt-1">Erfuellungsgrad</p>
|
|
{score_change !== null && (
|
|
<p className={`text-sm mt-2 ${score_change >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
|
{score_change >= 0 ? '\u2191' : '\u2193'} {Math.abs(score_change).toFixed(1)}% zum Vormonat
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function MetricCard({ label, value, detail, iconBg, iconColor, iconPath }: {
|
|
label: string
|
|
value: number
|
|
detail: string
|
|
iconBg: string
|
|
iconColor: string
|
|
iconPath: string
|
|
}) {
|
|
return (
|
|
<div className="bg-white rounded-xl shadow-sm border p-6">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<p className="text-sm text-slate-500">{label}</p>
|
|
<span className={`w-8 h-8 ${iconBg} rounded-lg flex items-center justify-center`}>
|
|
<svg className={`w-4 h-4 ${iconColor}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={iconPath} />
|
|
</svg>
|
|
</span>
|
|
</div>
|
|
<p className="text-3xl font-bold text-slate-900">{value}</p>
|
|
<p className="text-sm text-slate-500 mt-1">{detail}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TrendChartCard({ score_trend, onRefresh }: {
|
|
score_trend: { date: string; score: number; label: string }[]
|
|
onRefresh: () => void
|
|
}) {
|
|
return (
|
|
<div className="bg-white rounded-xl shadow-sm border p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-slate-900">Compliance-Trend (12 Monate)</h3>
|
|
<button onClick={onRefresh} className="text-sm text-primary-600 hover:text-primary-700">
|
|
Aktualisieren
|
|
</button>
|
|
</div>
|
|
<div className="h-48">
|
|
<ComplianceTrendChart data={score_trend} lang="de" height={180} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TopRisksCard({ top_risks }: { top_risks: ExecutiveDashboardData['top_risks'] }) {
|
|
const riskColors: Record<string, string> = {
|
|
critical: 'bg-red-100 text-red-700 border-red-200',
|
|
high: 'bg-orange-100 text-orange-700 border-orange-200',
|
|
medium: 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
|
low: 'bg-green-100 text-green-700 border-green-200',
|
|
}
|
|
|
|
return (
|
|
<div className="bg-white rounded-xl shadow-sm border p-6">
|
|
<h3 className="text-lg font-semibold text-slate-900 mb-4">Top 5 Risiken</h3>
|
|
{top_risks.length === 0 ? (
|
|
<p className="text-slate-500 text-center py-8">Keine offenen Risiken</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{top_risks.map((risk) => (
|
|
<div key={risk.id} className="flex items-center gap-3 p-3 bg-slate-50 rounded-lg">
|
|
<span className={`px-2 py-1 text-xs font-medium rounded border ${riskColors[risk.risk_level] || riskColors.medium}`}>
|
|
{risk.risk_level.toUpperCase()}
|
|
</span>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-slate-900 truncate">{risk.title}</p>
|
|
<p className="text-xs text-slate-500">{risk.owner || 'Kein Owner'}</p>
|
|
</div>
|
|
<span className="text-xs text-slate-400">{risk.risk_id}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function DeadlinesCard({ upcoming_deadlines }: { upcoming_deadlines: ExecutiveDashboardData['upcoming_deadlines'] }) {
|
|
const statusColors: Record<string, string> = {
|
|
overdue: 'bg-red-100 text-red-700',
|
|
at_risk: 'bg-yellow-100 text-yellow-700',
|
|
on_track: 'bg-green-100 text-green-700',
|
|
}
|
|
|
|
return (
|
|
<div className="bg-white rounded-xl shadow-sm border p-6">
|
|
<h3 className="text-lg font-semibold text-slate-900 mb-4">Naechste Fristen</h3>
|
|
{upcoming_deadlines.length === 0 ? (
|
|
<p className="text-slate-500 text-center py-8">Keine anstehenden Fristen</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{upcoming_deadlines.slice(0, 5).map((deadline) => (
|
|
<div key={deadline.id} className="flex items-center gap-3 p-3 bg-slate-50 rounded-lg">
|
|
<span className={`px-2 py-1 text-xs font-medium rounded ${statusColors[deadline.status] || statusColors.on_track}`}>
|
|
{deadline.days_remaining < 0
|
|
? `${Math.abs(deadline.days_remaining)}d ueberfaellig`
|
|
: deadline.days_remaining === 0
|
|
? 'Heute'
|
|
: `${deadline.days_remaining}d`}
|
|
</span>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-slate-900 truncate">{deadline.title}</p>
|
|
<p className="text-xs text-slate-500">{new Date(deadline.deadline).toLocaleDateString('de-DE')}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function WorkloadCard({ team_workload }: { team_workload: ExecutiveDashboardData['team_workload'] }) {
|
|
return (
|
|
<div className="bg-white rounded-xl shadow-sm border p-6">
|
|
<h3 className="text-lg font-semibold text-slate-900 mb-4">Team-Auslastung</h3>
|
|
{team_workload.length === 0 ? (
|
|
<p className="text-slate-500 text-center py-8">Keine Daten verfuegbar</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{team_workload.slice(0, 5).map((member) => (
|
|
<div key={member.name}>
|
|
<div className="flex justify-between text-sm mb-1">
|
|
<span className="font-medium text-slate-700">{member.name}</span>
|
|
<span className="text-slate-500">
|
|
{member.completed_tasks}/{member.total_tasks} ({member.completion_rate.toFixed(0)}%)
|
|
</span>
|
|
</div>
|
|
<div className="h-2 bg-slate-200 rounded-full overflow-hidden flex">
|
|
<div
|
|
className="bg-green-500 h-full"
|
|
style={{ width: `${(member.completed_tasks / member.total_tasks) * 100}%` }}
|
|
/>
|
|
<div
|
|
className="bg-yellow-500 h-full"
|
|
style={{ width: `${(member.in_progress_tasks / member.total_tasks) * 100}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|