Extract components and hooks from oversized pages into colocated _components/ and _hooks/ subdirectories to enforce the 500-LOC hard cap. page.tsx files reduced to 205, 121, and 136 LOC respectively. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
117 lines
4.0 KiB
TypeScript
117 lines
4.0 KiB
TypeScript
'use client'
|
|
|
|
import { MaturityLevel, MATURITY_INFO, getScoreColor, getScoreRingColor } from '@/lib/sdk/gci/types'
|
|
|
|
export type TabId = 'overview' | 'breakdown' | 'nis2' | 'iso' | 'matrix' | 'audit'
|
|
|
|
export interface Tab {
|
|
id: TabId
|
|
label: string
|
|
}
|
|
|
|
export 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' },
|
|
]
|
|
|
|
export 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>
|
|
)
|
|
}
|
|
|
|
export 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>
|
|
)
|
|
}
|
|
|
|
export 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>
|
|
)
|
|
}
|
|
|
|
export 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>
|
|
)
|
|
}
|
|
|
|
export 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>
|
|
)
|
|
}
|
|
|
|
export 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>
|
|
)
|
|
}
|