All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 19s
Kaputtes (admin) Layout geloescht (Role-Selection, 404-Sidebar, localhost-Dashboard). SDK-Flow nach /sdk/sdk-flow verschoben. Route-Gruppe (sdk) aufgeloest. Root-Seite redirected auf /sdk. ~25 ungenutzte Dateien/Verzeichnisse entfernt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
694 lines
27 KiB
TypeScript
694 lines
27 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
import {
|
|
GCIResult,
|
|
GCIBreakdown,
|
|
GCIHistoryResponse,
|
|
GCIMatrixResponse,
|
|
NIS2Score,
|
|
ISOGapAnalysis,
|
|
WeightProfile,
|
|
MaturityLevel,
|
|
MATURITY_INFO,
|
|
getScoreColor,
|
|
getScoreRingColor,
|
|
} from '@/lib/sdk/gci/types'
|
|
import {
|
|
getGCIScore,
|
|
getGCIBreakdown,
|
|
getGCIHistory,
|
|
getGCIMatrix,
|
|
getNIS2Score,
|
|
getISOGapAnalysis,
|
|
getWeightProfiles,
|
|
} from '@/lib/sdk/gci/api'
|
|
|
|
// =============================================================================
|
|
// TYPES
|
|
// =============================================================================
|
|
|
|
type TabId = 'overview' | 'breakdown' | 'nis2' | 'iso' | 'matrix' | 'audit'
|
|
|
|
interface Tab {
|
|
id: TabId
|
|
label: string
|
|
}
|
|
|
|
const TABS: Tab[] = [
|
|
{ id: 'overview', label: 'Uebersicht' },
|
|
{ id: 'breakdown', label: 'Breakdown' },
|
|
{ id: 'nis2', label: 'NIS2' },
|
|
{ id: 'iso', label: 'ISO 27001' },
|
|
{ id: 'matrix', label: 'Matrix' },
|
|
{ id: 'audit', label: 'Audit Trail' },
|
|
]
|
|
|
|
// =============================================================================
|
|
// HELPER COMPONENTS
|
|
// =============================================================================
|
|
|
|
function TabNavigation({ tabs, activeTab, onTabChange }: { tabs: Tab[]; activeTab: TabId; onTabChange: (tab: TabId) => void }) {
|
|
return (
|
|
<div className="border-b border-gray-200">
|
|
<nav className="flex gap-1 -mb-px overflow-x-auto" aria-label="Tabs">
|
|
{tabs.map(tab => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => onTabChange(tab.id)}
|
|
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
|
activeTab === tab.id
|
|
? 'border-purple-600 text-purple-600'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ScoreCircle({ score, size = 144, label }: { score: number; size?: number; label?: string }) {
|
|
const radius = (size / 2) - 12
|
|
const circumference = 2 * Math.PI * radius
|
|
const strokeDashoffset = circumference - (score / 100) * circumference
|
|
|
|
return (
|
|
<div className="relative flex flex-col items-center">
|
|
<svg className="-rotate-90" width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
|
<circle cx={size/2} cy={size/2} r={radius} stroke="#e5e7eb" strokeWidth="8" fill="none" />
|
|
<circle
|
|
cx={size/2} cy={size/2} r={radius}
|
|
stroke={getScoreRingColor(score)}
|
|
strokeWidth="8" fill="none"
|
|
strokeLinecap="round"
|
|
strokeDasharray={circumference}
|
|
strokeDashoffset={strokeDashoffset}
|
|
className="transition-all duration-1000"
|
|
/>
|
|
</svg>
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
|
<span className={`text-3xl font-bold ${getScoreColor(score)}`}>{score.toFixed(1)}</span>
|
|
{label && <span className="text-xs text-gray-500 mt-1">{label}</span>}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function MaturityBadge({ level }: { level: MaturityLevel }) {
|
|
const info = MATURITY_INFO[level] || MATURITY_INFO.HIGH_RISK
|
|
return (
|
|
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${info.bgColor} ${info.color} border ${info.borderColor}`}>
|
|
{info.label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function AreaScoreBar({ name, score, weight }: { name: string; score: number; weight: number }) {
|
|
return (
|
|
<div className="space-y-1">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="font-medium text-gray-700">{name}</span>
|
|
<span className={`font-semibold ${getScoreColor(score)}`}>{score.toFixed(1)}%</span>
|
|
</div>
|
|
<div className="w-full bg-gray-200 rounded-full h-3">
|
|
<div
|
|
className="h-3 rounded-full transition-all duration-700"
|
|
style={{ width: `${Math.min(score, 100)}%`, backgroundColor: getScoreRingColor(score) }}
|
|
/>
|
|
</div>
|
|
<div className="text-xs text-gray-400">Gewichtung: {(weight * 100).toFixed(0)}%</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function LoadingSpinner() {
|
|
return (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ErrorMessage({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
|
return (
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
|
<p>{message}</p>
|
|
{onRetry && (
|
|
<button onClick={onRetry} className="mt-2 text-sm underline hover:no-underline">
|
|
Erneut versuchen
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// TAB: OVERVIEW
|
|
// =============================================================================
|
|
|
|
function OverviewTab({ gci, history, profiles, selectedProfile, onProfileChange }: {
|
|
gci: GCIResult
|
|
history: GCIHistoryResponse | null
|
|
profiles: WeightProfile[]
|
|
selectedProfile: string
|
|
onProfileChange: (p: string) => void
|
|
}) {
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Profile Selector */}
|
|
{profiles.length > 0 && (
|
|
<div className="flex items-center gap-3">
|
|
<label className="text-sm font-medium text-gray-700">Gewichtungsprofil:</label>
|
|
<select
|
|
value={selectedProfile}
|
|
onChange={e => onProfileChange(e.target.value)}
|
|
className="rounded-md border-gray-300 shadow-sm text-sm focus:border-purple-500 focus:ring-purple-500"
|
|
>
|
|
{profiles.map(p => (
|
|
<option key={p.id} value={p.id}>{p.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
|
|
{/* Main Score */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<div className="flex flex-col md:flex-row items-center gap-8">
|
|
<ScoreCircle score={gci.gci_score} label="GCI Score" />
|
|
<div className="flex-1 space-y-4">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900">Gesamt-Compliance-Index</h3>
|
|
<div className="flex items-center gap-3 mt-2">
|
|
<MaturityBadge level={gci.maturity_level} />
|
|
<span className="text-sm text-gray-500">
|
|
Berechnet: {new Date(gci.calculated_at).toLocaleString('de-DE')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<p className="text-sm text-gray-600">
|
|
{MATURITY_INFO[gci.maturity_level]?.description || ''}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Area Scores */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-base font-semibold text-gray-900 mb-4">Regulierungsbereiche</h3>
|
|
<div className="space-y-4">
|
|
{gci.area_scores.map(area => (
|
|
<AreaScoreBar
|
|
key={area.regulation_id}
|
|
name={area.regulation_name}
|
|
score={area.score}
|
|
weight={area.weight}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* History Chart (simplified) */}
|
|
{history && history.snapshots.length > 0 && (
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-base font-semibold text-gray-900 mb-4">Verlauf</h3>
|
|
<div className="flex items-end gap-2 h-32">
|
|
{history.snapshots.map((snap, i) => (
|
|
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
|
<span className="text-xs text-gray-500">{snap.score.toFixed(0)}</span>
|
|
<div
|
|
className="w-full rounded-t transition-all duration-500"
|
|
style={{
|
|
height: `${(snap.score / 100) * 100}%`,
|
|
backgroundColor: getScoreRingColor(snap.score),
|
|
minHeight: '4px',
|
|
}}
|
|
/>
|
|
<span className="text-[10px] text-gray-400">
|
|
{new Date(snap.calculated_at).toLocaleDateString('de-DE', { month: 'short' })}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Adjustments */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
|
<div className="text-sm text-gray-500">Kritikalitaets-Multiplikator</div>
|
|
<div className="text-2xl font-bold text-gray-900">{gci.criticality_multiplier.toFixed(2)}x</div>
|
|
</div>
|
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
|
<div className="text-sm text-gray-500">Incident-Korrektur</div>
|
|
<div className={`text-2xl font-bold ${gci.incident_adjustment < 0 ? 'text-red-600' : 'text-green-600'}`}>
|
|
{gci.incident_adjustment > 0 ? '+' : ''}{gci.incident_adjustment.toFixed(1)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// TAB: BREAKDOWN
|
|
// =============================================================================
|
|
|
|
function BreakdownTab({ breakdown }: { breakdown: GCIBreakdown | null; loading: boolean }) {
|
|
if (!breakdown) return <LoadingSpinner />
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Level 1: Modules */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-base font-semibold text-gray-900 mb-4">Level 1: Modul-Scores</h3>
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-200">
|
|
<th className="text-left py-2 pr-4 font-medium text-gray-600">Modul</th>
|
|
<th className="text-left py-2 pr-4 font-medium text-gray-600">Kategorie</th>
|
|
<th className="text-right py-2 pr-4 font-medium text-gray-600">Zugewiesen</th>
|
|
<th className="text-right py-2 pr-4 font-medium text-gray-600">Abgeschlossen</th>
|
|
<th className="text-right py-2 pr-4 font-medium text-gray-600">Raw Score</th>
|
|
<th className="text-right py-2 pr-4 font-medium text-gray-600">Validitaet</th>
|
|
<th className="text-right py-2 font-medium text-gray-600">Final</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{breakdown.level1_modules.map(m => (
|
|
<tr key={m.module_id} className="border-b border-gray-100 hover:bg-gray-50">
|
|
<td className="py-2 pr-4 font-medium text-gray-900">{m.module_name}</td>
|
|
<td className="py-2 pr-4">
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700">
|
|
{m.category}
|
|
</span>
|
|
</td>
|
|
<td className="py-2 pr-4 text-right text-gray-600">{m.assigned}</td>
|
|
<td className="py-2 pr-4 text-right text-gray-600">{m.completed}</td>
|
|
<td className="py-2 pr-4 text-right text-gray-600">{(m.raw_score * 100).toFixed(1)}%</td>
|
|
<td className="py-2 pr-4 text-right text-gray-600">{(m.validity_factor * 100).toFixed(0)}%</td>
|
|
<td className={`py-2 text-right font-semibold ${getScoreColor(m.final_score * 100)}`}>
|
|
{(m.final_score * 100).toFixed(1)}%
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Level 2: Areas */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-base font-semibold text-gray-900 mb-4">Level 2: Regulierungsbereiche (risikogewichtet)</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{breakdown.level2_areas.map(area => (
|
|
<div key={area.area_id} className="border border-gray-200 rounded-lg p-4">
|
|
<div className="flex justify-between items-center mb-2">
|
|
<h4 className="font-medium text-gray-900">{area.area_name}</h4>
|
|
<span className={`text-lg font-bold ${getScoreColor(area.area_score)}`}>
|
|
{area.area_score.toFixed(1)}%
|
|
</span>
|
|
</div>
|
|
<div className="space-y-1">
|
|
{area.modules.map(m => (
|
|
<div key={m.module_id} className="flex justify-between text-xs text-gray-500">
|
|
<span>{m.module_name}</span>
|
|
<span>{(m.final_score * 100).toFixed(0)}% (w:{m.risk_weight.toFixed(1)})</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// TAB: NIS2
|
|
// =============================================================================
|
|
|
|
function NIS2Tab({ nis2 }: { nis2: NIS2Score | null }) {
|
|
if (!nis2) return <LoadingSpinner />
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* NIS2 Overall */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<div className="flex items-center gap-6">
|
|
<ScoreCircle score={nis2.overall_score} size={120} label="NIS2" />
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900">NIS2 Compliance Score</h3>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
Network and Information Security Directive 2 (EU 2022/2555)
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* NIS2 Areas */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-base font-semibold text-gray-900 mb-4">NIS2 Bereiche</h3>
|
|
<div className="space-y-3">
|
|
{nis2.areas.map(area => (
|
|
<AreaScoreBar key={area.area_id} name={area.area_name} score={area.score} weight={area.weight} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* NIS2 Roles */}
|
|
{nis2.role_scores && nis2.role_scores.length > 0 && (
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-base font-semibold text-gray-900 mb-4">Rollen-Compliance</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
{nis2.role_scores.map(role => (
|
|
<div key={role.role_id} className="border border-gray-200 rounded-lg p-3">
|
|
<div className="font-medium text-gray-900 text-sm">{role.role_name}</div>
|
|
<div className="flex items-center justify-between mt-2">
|
|
<span className={`text-lg font-bold ${getScoreColor(role.completion_rate * 100)}`}>
|
|
{(role.completion_rate * 100).toFixed(0)}%
|
|
</span>
|
|
<span className="text-xs text-gray-500">
|
|
{role.modules_completed}/{role.modules_required} Module
|
|
</span>
|
|
</div>
|
|
<div className="w-full bg-gray-200 rounded-full h-1.5 mt-2">
|
|
<div
|
|
className="h-1.5 rounded-full"
|
|
style={{
|
|
width: `${Math.min(role.completion_rate * 100, 100)}%`,
|
|
backgroundColor: getScoreRingColor(role.completion_rate * 100),
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// TAB: ISO 27001
|
|
// =============================================================================
|
|
|
|
function ISOTab({ iso }: { iso: ISOGapAnalysis | null }) {
|
|
if (!iso) return <LoadingSpinner />
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Coverage Overview */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<div className="flex items-center gap-6">
|
|
<ScoreCircle score={iso.coverage_percent} size={120} label="Abdeckung" />
|
|
<div className="flex-1">
|
|
<h3 className="text-lg font-semibold text-gray-900">ISO 27001:2022 Gap-Analyse</h3>
|
|
<div className="grid grid-cols-3 gap-4 mt-3">
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-green-600">{iso.covered_full}</div>
|
|
<div className="text-xs text-gray-500">Voll abgedeckt</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-yellow-600">{iso.covered_partial}</div>
|
|
<div className="text-xs text-gray-500">Teilweise</div>
|
|
</div>
|
|
<div className="text-center">
|
|
<div className="text-2xl font-bold text-red-600">{iso.not_covered}</div>
|
|
<div className="text-xs text-gray-500">Nicht abgedeckt</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Category Summaries */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-base font-semibold text-gray-900 mb-4">Kategorien</h3>
|
|
<div className="space-y-3">
|
|
{iso.category_summaries.map(cat => {
|
|
const coveragePercent = cat.total_controls > 0
|
|
? ((cat.covered_full + cat.covered_partial * 0.5) / cat.total_controls) * 100
|
|
: 0
|
|
return (
|
|
<div key={cat.category_id} className="space-y-1">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="font-medium text-gray-700">{cat.category_id}: {cat.category_name}</span>
|
|
<span className="text-gray-500">
|
|
{cat.covered_full}/{cat.total_controls} Controls
|
|
</span>
|
|
</div>
|
|
<div className="w-full bg-gray-200 rounded-full h-3 flex overflow-hidden">
|
|
<div className="h-3 bg-green-500" style={{ width: `${(cat.covered_full / cat.total_controls) * 100}%` }} />
|
|
<div className="h-3 bg-yellow-500" style={{ width: `${(cat.covered_partial / cat.total_controls) * 100}%` }} />
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Gaps */}
|
|
{iso.gaps && iso.gaps.length > 0 && (
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-base font-semibold text-gray-900 mb-4">
|
|
Offene Gaps ({iso.gaps.length})
|
|
</h3>
|
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
|
{iso.gaps.map(gap => (
|
|
<div key={gap.control_id} className="flex items-start gap-3 p-3 border border-gray-100 rounded-lg hover:bg-gray-50">
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
|
gap.priority === 'high' ? 'bg-red-100 text-red-700' :
|
|
gap.priority === 'medium' ? 'bg-yellow-100 text-yellow-700' :
|
|
'bg-gray-100 text-gray-700'
|
|
}`}>
|
|
{gap.priority}
|
|
</span>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium text-gray-900">{gap.control_id}: {gap.control_name}</div>
|
|
<div className="text-xs text-gray-500 mt-0.5">{gap.recommendation}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// TAB: MATRIX
|
|
// =============================================================================
|
|
|
|
function MatrixTab({ matrix }: { matrix: GCIMatrixResponse | null }) {
|
|
if (!matrix || !matrix.matrix) return <LoadingSpinner />
|
|
|
|
const regulations = matrix.matrix.length > 0 ? Object.keys(matrix.matrix[0].regulations) : []
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-base font-semibold text-gray-900 mb-4">Compliance-Matrix (Rollen x Regulierungen)</h3>
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-200">
|
|
<th className="text-left py-2 pr-4 font-medium text-gray-600">Rolle</th>
|
|
{regulations.map(r => (
|
|
<th key={r} className="text-center py-2 px-3 font-medium text-gray-600 uppercase">{r}</th>
|
|
))}
|
|
<th className="text-center py-2 px-3 font-medium text-gray-600">Gesamt</th>
|
|
<th className="text-center py-2 px-3 font-medium text-gray-600">Module</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{matrix.matrix.map(entry => (
|
|
<tr key={entry.role} className="border-b border-gray-100 hover:bg-gray-50">
|
|
<td className="py-2 pr-4 font-medium text-gray-900">{entry.role_name}</td>
|
|
{regulations.map(r => (
|
|
<td key={r} className="py-2 px-3 text-center">
|
|
<span className={`font-semibold ${getScoreColor(entry.regulations[r])}`}>
|
|
{entry.regulations[r].toFixed(0)}%
|
|
</span>
|
|
</td>
|
|
))}
|
|
<td className="py-2 px-3 text-center">
|
|
<span className={`font-bold ${getScoreColor(entry.overall_score)}`}>
|
|
{entry.overall_score.toFixed(0)}%
|
|
</span>
|
|
</td>
|
|
<td className="py-2 px-3 text-center text-gray-500">
|
|
{entry.completed_modules}/{entry.required_modules}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// TAB: AUDIT TRAIL
|
|
// =============================================================================
|
|
|
|
function AuditTab({ gci }: { gci: GCIResult }) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-base font-semibold text-gray-900 mb-4">
|
|
Audit Trail - Berechnung GCI {gci.gci_score.toFixed(1)}
|
|
</h3>
|
|
<p className="text-sm text-gray-500 mb-4">
|
|
Jeder Schritt der GCI-Berechnung ist nachvollziehbar und prueffaehig dokumentiert.
|
|
</p>
|
|
<div className="space-y-2">
|
|
{gci.audit_trail.map((entry, i) => (
|
|
<div key={i} className="flex items-start gap-3 p-3 border border-gray-100 rounded-lg">
|
|
<div className={`flex-shrink-0 w-2 h-2 rounded-full mt-1.5 ${
|
|
entry.impact === 'positive' ? 'bg-green-500' :
|
|
entry.impact === 'negative' ? 'bg-red-500' :
|
|
'bg-gray-400'
|
|
}`} />
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm font-medium text-gray-900">{entry.factor}</span>
|
|
<span className={`text-sm font-mono ${
|
|
entry.impact === 'positive' ? 'text-green-600' :
|
|
entry.impact === 'negative' ? 'text-red-600' :
|
|
'text-gray-600'
|
|
}`}>
|
|
{entry.value > 0 ? '+' : ''}{entry.value.toFixed(2)}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-gray-500 mt-0.5">{entry.description}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// MAIN PAGE
|
|
// =============================================================================
|
|
|
|
export default function GCIPage() {
|
|
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const [gci, setGCI] = useState<GCIResult | null>(null)
|
|
const [breakdown, setBreakdown] = useState<GCIBreakdown | null>(null)
|
|
const [history, setHistory] = useState<GCIHistoryResponse | null>(null)
|
|
const [matrix, setMatrix] = useState<GCIMatrixResponse | null>(null)
|
|
const [nis2, setNIS2] = useState<NIS2Score | null>(null)
|
|
const [iso, setISO] = useState<ISOGapAnalysis | null>(null)
|
|
const [profiles, setProfiles] = useState<WeightProfile[]>([])
|
|
const [selectedProfile, setSelectedProfile] = useState('default')
|
|
|
|
const loadData = useCallback(async (profile?: string) => {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const [gciRes, historyRes, profilesRes] = await Promise.all([
|
|
getGCIScore(profile),
|
|
getGCIHistory(),
|
|
getWeightProfiles(),
|
|
])
|
|
setGCI(gciRes)
|
|
setHistory(historyRes)
|
|
setProfiles(profilesRes.profiles || [])
|
|
} catch (err: any) {
|
|
setError(err.message || 'Fehler beim Laden der GCI-Daten')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
loadData(selectedProfile)
|
|
}, [selectedProfile, loadData])
|
|
|
|
// Lazy-load tab data
|
|
useEffect(() => {
|
|
if (activeTab === 'breakdown' && !breakdown && gci) {
|
|
getGCIBreakdown(selectedProfile).then(setBreakdown).catch(() => {})
|
|
}
|
|
if (activeTab === 'nis2' && !nis2) {
|
|
getNIS2Score().then(setNIS2).catch(() => {})
|
|
}
|
|
if (activeTab === 'iso' && !iso) {
|
|
getISOGapAnalysis().then(setISO).catch(() => {})
|
|
}
|
|
if (activeTab === 'matrix' && !matrix) {
|
|
getGCIMatrix().then(setMatrix).catch(() => {})
|
|
}
|
|
}, [activeTab, breakdown, nis2, iso, matrix, gci, selectedProfile])
|
|
|
|
const handleProfileChange = (profile: string) => {
|
|
setSelectedProfile(profile)
|
|
setBreakdown(null) // reset breakdown to reload
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">Gesamt-Compliance-Index (GCI)</h1>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
4-stufiges, mathematisch fundiertes Compliance-Scoring
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => loadData(selectedProfile)}
|
|
disabled={loading}
|
|
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
{loading ? 'Lade...' : 'Aktualisieren'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<TabNavigation tabs={TABS} activeTab={activeTab} onTabChange={setActiveTab} />
|
|
|
|
{/* Content */}
|
|
{error && <ErrorMessage message={error} onRetry={() => loadData(selectedProfile)} />}
|
|
|
|
{loading && !gci ? (
|
|
<LoadingSpinner />
|
|
) : gci ? (
|
|
<div className="pb-8">
|
|
{activeTab === 'overview' && (
|
|
<OverviewTab
|
|
gci={gci}
|
|
history={history}
|
|
profiles={profiles}
|
|
selectedProfile={selectedProfile}
|
|
onProfileChange={handleProfileChange}
|
|
/>
|
|
)}
|
|
{activeTab === 'breakdown' && (
|
|
<BreakdownTab breakdown={breakdown} loading={!breakdown} />
|
|
)}
|
|
{activeTab === 'nis2' && <NIS2Tab nis2={nis2} />}
|
|
{activeTab === 'iso' && <ISOTab iso={iso} />}
|
|
{activeTab === 'matrix' && <MatrixTab matrix={matrix} />}
|
|
{activeTab === 'audit' && <AuditTab gci={gci} />}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|