Files
breakpilot-compliance/admin-compliance/components/sdk/tom-dashboard/TOMOverviewTab.tsx
Benjamin Admin 4b1eede45b feat(tom): audit document, compliance checks, 25 controls, canonical control mapping
Phase A: TOM document HTML generator (12 sections, inline CSS, A4 print)
Phase B: TOMDocumentTab component (org-header form, revisions, print/download)
Phase C: 11 compliance checks with severity-weighted scoring
Phase D: MkDocs documentation for TOM module
Phase E: 25 new controls (63 → 88) in 13 categories

Canonical Control Mapping (three-layer architecture):
- Migration 068: tom_control_mappings + tom_control_sync_state tables
- 6 API endpoints: sync, list, by-tom, stats, manual add, delete
- Category mapping: 13 TOM categories → 17 canonical categories
- Frontend: sync button + coverage card (Overview), drill-down (Editor),
  belegende Controls count (Document)
- 20 tests (unit + API with mocked DB)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:56:53 +01:00

365 lines
16 KiB
TypeScript

'use client'
import { useMemo, useState, useEffect, useCallback } from 'react'
import { DerivedTOM, TOMGeneratorState } from '@/lib/sdk/tom-generator/types'
import { getControlById, getControlsByCategory, getAllCategories } from '@/lib/sdk/tom-generator/controls/loader'
import { SDM_GOAL_LABELS, getSDMCoverageStats, SDMGewaehrleistungsziel } from '@/lib/sdk/tom-generator/sdm-mapping'
interface TOMOverviewTabProps {
state: TOMGeneratorState
onSelectTOM: (tomId: string) => void
onStartGenerator: () => void
}
interface MappingStats {
sync_state: {
profile_hash: string | null
total_mappings: number
canonical_controls_matched: number
tom_controls_covered: number
last_synced_at: string | null
}
category_breakdown: { tom_category: string; total_mappings: number; unique_controls: number }[]
total_canonical_controls_available: number
}
const STATUS_BADGES: Record<string, { label: string; className: string }> = {
IMPLEMENTED: { label: 'Implementiert', className: 'bg-green-100 text-green-700' },
PARTIAL: { label: 'Teilweise', className: 'bg-yellow-100 text-yellow-700' },
NOT_IMPLEMENTED: { label: 'Fehlend', className: 'bg-red-100 text-red-700' },
}
const TYPE_BADGES: Record<string, { label: string; className: string }> = {
TECHNICAL: { label: 'Technisch', className: 'bg-blue-100 text-blue-700' },
ORGANIZATIONAL: { label: 'Organisatorisch', className: 'bg-indigo-100 text-indigo-700' },
}
const SCHUTZZIELE: { key: SDMGewaehrleistungsziel; label: string }[] = [
{ key: 'Vertraulichkeit', label: 'Vertraulichkeit' },
{ key: 'Integritaet', label: 'Integritaet' },
{ key: 'Verfuegbarkeit', label: 'Verfuegbarkeit' },
{ key: 'Nichtverkettung', label: 'Nichtverkettung' },
]
export function TOMOverviewTab({ state, onSelectTOM, onStartGenerator }: TOMOverviewTabProps) {
const [categoryFilter, setCategoryFilter] = useState<string>('ALL')
const [typeFilter, setTypeFilter] = useState<string>('ALL')
const [statusFilter, setStatusFilter] = useState<string>('ALL')
const [applicabilityFilter, setApplicabilityFilter] = useState<string>('ALL')
const [mappingStats, setMappingStats] = useState<MappingStats | null>(null)
const [syncing, setSyncing] = useState(false)
const categories = useMemo(() => getAllCategories(), [])
// Load mapping stats
useEffect(() => {
if (state.derivedTOMs.length === 0) return
fetch('/api/sdk/v1/compliance/tom-mappings/stats')
.then(r => r.ok ? r.json() : null)
.then(data => { if (data) setMappingStats(data) })
.catch(() => {})
}, [state.derivedTOMs.length])
const handleSyncControls = useCallback(async () => {
setSyncing(true)
try {
const resp = await fetch('/api/sdk/v1/compliance/tom-mappings/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
industry: state.companyProfile?.industry || null,
company_size: state.companyProfile?.size || null,
force: false,
}),
})
if (resp.ok) {
// Reload stats after sync
const statsResp = await fetch('/api/sdk/v1/compliance/tom-mappings/stats')
if (statsResp.ok) setMappingStats(await statsResp.json())
}
} catch { /* ignore */ }
setSyncing(false)
}, [state.companyProfile])
const stats = useMemo(() => {
const toms = state.derivedTOMs
return {
total: toms.length,
implemented: toms.filter(t => t.implementationStatus === 'IMPLEMENTED').length,
partial: toms.filter(t => t.implementationStatus === 'PARTIAL').length,
missing: toms.filter(t => t.implementationStatus === 'NOT_IMPLEMENTED').length,
}
}, [state.derivedTOMs])
const sdmStats = useMemo(() => {
const allStats = getSDMCoverageStats(state.derivedTOMs)
return SCHUTZZIELE.map(sz => ({
...sz,
stats: allStats[sz.key] || { total: 0, implemented: 0, partial: 0, missing: 0 },
}))
}, [state.derivedTOMs])
const filteredTOMs = useMemo(() => {
let toms = state.derivedTOMs
if (categoryFilter !== 'ALL') {
const categoryControlIds = getControlsByCategory(categoryFilter).map(c => c.id)
toms = toms.filter(t => categoryControlIds.includes(t.controlId))
}
if (typeFilter !== 'ALL') {
toms = toms.filter(t => {
const ctrl = getControlById(t.controlId)
return ctrl?.type === typeFilter
})
}
if (statusFilter !== 'ALL') {
toms = toms.filter(t => t.implementationStatus === statusFilter)
}
if (applicabilityFilter !== 'ALL') {
toms = toms.filter(t => t.applicability === applicabilityFilter)
}
return toms
}, [state.derivedTOMs, categoryFilter, typeFilter, statusFilter, applicabilityFilter])
if (state.derivedTOMs.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="text-gray-400 mb-4">
<svg className="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="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" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-700 mb-2">Keine TOMs vorhanden</h3>
<p className="text-gray-500 mb-6 max-w-md">
Starten Sie den TOM Generator, um technische und organisatorische Massnahmen basierend auf Ihrem Verarbeitungsverzeichnis abzuleiten.
</p>
<button
onClick={onStartGenerator}
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-6 py-3 font-medium transition-colors"
>
TOM Generator starten
</button>
</div>
)
}
return (
<div className="space-y-6">
{/* Stats Row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
<div className="text-3xl font-bold text-gray-900">{stats.total}</div>
<div className="text-sm text-gray-500 mt-1">Gesamt TOMs</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
<div className="text-3xl font-bold text-green-600">{stats.implemented}</div>
<div className="text-sm text-gray-500 mt-1">Implementiert</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
<div className="text-3xl font-bold text-yellow-600">{stats.partial}</div>
<div className="text-sm text-gray-500 mt-1">Teilweise</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
<div className="text-3xl font-bold text-red-600">{stats.missing}</div>
<div className="text-sm text-gray-500 mt-1">Fehlend</div>
</div>
</div>
{/* Art. 32 Schutzziele */}
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-3">Art. 32 DSGVO Schutzziele</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{sdmStats.map(sz => {
const total = sz.stats.total || 1
const implPercent = Math.round((sz.stats.implemented / total) * 100)
const partialPercent = Math.round((sz.stats.partial / total) * 100)
return (
<div key={sz.key} className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-sm font-medium text-gray-700 mb-2">{sz.label}</div>
<div className="flex items-center gap-2 mb-1">
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full flex">
<div
className="bg-green-500 h-full"
style={{ width: `${implPercent}%` }}
/>
<div
className="bg-yellow-400 h-full"
style={{ width: `${partialPercent}%` }}
/>
</div>
</div>
</div>
<div className="text-xs text-gray-500">
{sz.stats.implemented}/{sz.stats.total} implementiert
</div>
</div>
)
})}
</div>
</div>
{/* Canonical Control Library Coverage */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-semibold text-gray-700">Canonical Control Library</h3>
<p className="text-xs text-gray-500 mt-0.5">
Belegende Security-Controls aus OWASP, NIST, ENISA
</p>
</div>
<button
onClick={handleSyncControls}
disabled={syncing}
className="bg-purple-600 text-white hover:bg-purple-700 disabled:bg-gray-300 rounded-lg px-4 py-2 text-xs font-medium transition-colors"
>
{syncing ? 'Synchronisiere...' : 'Controls synchronisieren'}
</button>
</div>
{mappingStats ? (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div className="bg-gray-50 rounded-lg p-3 text-center">
<div className="text-xl font-bold text-gray-900">{mappingStats.sync_state.total_mappings}</div>
<div className="text-xs text-gray-500">Zugeordnete Controls</div>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<div className="text-xl font-bold text-purple-600">{mappingStats.sync_state.canonical_controls_matched}</div>
<div className="text-xs text-gray-500">Einzigartige Controls</div>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<div className="text-xl font-bold text-gray-900">{mappingStats.sync_state.tom_controls_covered}/13</div>
<div className="text-xs text-gray-500">Kategorien abgedeckt</div>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<div className="text-xl font-bold text-gray-900">{mappingStats.total_canonical_controls_available}</div>
<div className="text-xs text-gray-500">Verfuegbare Controls</div>
</div>
</div>
) : (
<div className="text-center py-4">
<p className="text-sm text-gray-400">
Noch keine Controls synchronisiert. Klicken Sie &quot;Controls synchronisieren&quot;, um relevante
Security-Controls aus der Canonical Control Library zuzuordnen.
</p>
</div>
)}
{mappingStats?.sync_state?.last_synced_at && (
<p className="text-xs text-gray-400 mt-2">
Letzte Synchronisation: {new Date(mappingStats.sync_state.last_synced_at).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
})}
</p>
)}
</div>
{/* Filter Controls */}
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Kategorie</label>
<select
value={categoryFilter}
onChange={e => setCategoryFilter(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="ALL">Alle Kategorien</option>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Typ</label>
<select
value={typeFilter}
onChange={e => setTypeFilter(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="ALL">Alle</option>
<option value="TECHNICAL">Technisch</option>
<option value="ORGANIZATIONAL">Organisatorisch</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Status</label>
<select
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="ALL">Alle</option>
<option value="IMPLEMENTED">Implementiert</option>
<option value="PARTIAL">Teilweise</option>
<option value="NOT_IMPLEMENTED">Fehlend</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Anwendbarkeit</label>
<select
value={applicabilityFilter}
onChange={e => setApplicabilityFilter(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="ALL">Alle</option>
<option value="REQUIRED">Erforderlich</option>
<option value="RECOMMENDED">Empfohlen</option>
<option value="OPTIONAL">Optional</option>
</select>
</div>
</div>
</div>
{/* TOM Card Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredTOMs.map(tom => {
const control = getControlById(tom.controlId)
const statusBadge = STATUS_BADGES[tom.implementationStatus] || STATUS_BADGES.NOT_IMPLEMENTED
const typeBadge = TYPE_BADGES[control?.type || 'TECHNICAL'] || TYPE_BADGES.TECHNICAL
const evidenceCount = tom.linkedEvidence?.length || 0
return (
<button
key={tom.id}
onClick={() => onSelectTOM(tom.id)}
className="bg-white rounded-xl border border-gray-200 p-5 text-left hover:border-purple-300 hover:shadow-md transition-all group"
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-mono text-gray-400">{control?.code || tom.controlId}</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusBadge.className}`}>
{statusBadge.label}
</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${typeBadge.className}`}>
{typeBadge.label}
</span>
</div>
{evidenceCount > 0 && (
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
{evidenceCount} Nachweise
</span>
)}
</div>
<h4 className="text-sm font-semibold text-gray-800 group-hover:text-purple-700 transition-colors mb-1">
{control?.name?.de || tom.controlId}
</h4>
<div className="text-xs text-gray-400">
{control?.category || 'Unbekannte Kategorie'}
</div>
</button>
)
})}
</div>
{filteredTOMs.length === 0 && state.derivedTOMs.length > 0 && (
<div className="text-center py-10 text-gray-500">
<p>Keine TOMs entsprechen den aktuellen Filterkriterien.</p>
</div>
)}
</div>
)
}