Files
breakpilot-lehrer/website/app/admin/compliance/_components/ExecutiveTab.tsx
Benjamin Admin 0b37c5e692 [split-required] Split website + studio-v2 monoliths (Phase 3 continued)
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>
2026-04-24 17:52:36 +02:00

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>
)
}