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>
This commit is contained in:
449
admin-compliance/components/sdk/tom-dashboard/TOMDocumentTab.tsx
Normal file
449
admin-compliance/components/sdk/tom-dashboard/TOMDocumentTab.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import type { TOMGeneratorState } from '@/lib/sdk/tom-generator/types'
|
||||
import type { TOMComplianceCheckResult } from '@/lib/sdk/tom-compliance'
|
||||
import {
|
||||
buildTOMDocumentHtml,
|
||||
createDefaultTOMDocumentOrgHeader,
|
||||
type TOMDocumentOrgHeader,
|
||||
type TOMDocumentRevision,
|
||||
} from '@/lib/sdk/tom-document'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface TOMDocumentTabProps {
|
||||
state: TOMGeneratorState
|
||||
complianceResult: TOMComplianceCheckResult | null
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
function TOMDocumentTab({ state, complianceResult }: TOMDocumentTabProps) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const [orgHeader, setOrgHeader] = useState<TOMDocumentOrgHeader>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('bp_tom_document_orgheader')
|
||||
if (saved) {
|
||||
try { return JSON.parse(saved) } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return createDefaultTOMDocumentOrgHeader()
|
||||
})
|
||||
|
||||
const [revisions, setRevisions] = useState<TOMDocumentRevision[]>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('bp_tom_document_revisions')
|
||||
if (saved) {
|
||||
try { return JSON.parse(saved) } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// localStorage persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('bp_tom_document_orgheader', JSON.stringify(orgHeader))
|
||||
}, [orgHeader])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('bp_tom_document_revisions', JSON.stringify(revisions))
|
||||
}, [revisions])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const tomCount = useMemo(() => {
|
||||
if (!state?.derivedTOMs) return 0
|
||||
return Array.isArray(state.derivedTOMs) ? state.derivedTOMs.length : 0
|
||||
}, [state?.derivedTOMs])
|
||||
|
||||
const applicableTOMs = useMemo(() => {
|
||||
if (!state?.derivedTOMs || !Array.isArray(state.derivedTOMs)) return []
|
||||
return state.derivedTOMs.filter(t => t.applicability !== 'NOT_APPLICABLE')
|
||||
}, [state?.derivedTOMs])
|
||||
|
||||
const implementedCount = useMemo(() => {
|
||||
return applicableTOMs.filter(t => t.implementationStatus === 'IMPLEMENTED').length
|
||||
}, [applicableTOMs])
|
||||
|
||||
const [canonicalCount, setCanonicalCount] = useState(0)
|
||||
useEffect(() => {
|
||||
if (tomCount === 0) return
|
||||
fetch('/api/sdk/v1/compliance/tom-mappings/stats')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => { if (data?.sync_state?.canonical_controls_matched) setCanonicalCount(data.sync_state.canonical_controls_matched) })
|
||||
.catch(() => {})
|
||||
}, [tomCount])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handlePrintTOMDocument = useCallback(() => {
|
||||
const html = buildTOMDocumentHtml(
|
||||
state?.derivedTOMs || [],
|
||||
orgHeader,
|
||||
state?.companyProfile || null,
|
||||
state?.riskProfile || null,
|
||||
complianceResult,
|
||||
revisions,
|
||||
)
|
||||
const printWindow = window.open('', '_blank')
|
||||
if (printWindow) {
|
||||
printWindow.document.write(html)
|
||||
printWindow.document.close()
|
||||
setTimeout(() => printWindow.print(), 300)
|
||||
}
|
||||
}, [state, orgHeader, complianceResult, revisions])
|
||||
|
||||
const handleDownloadTOMDocumentHtml = useCallback(() => {
|
||||
const html = buildTOMDocumentHtml(
|
||||
state?.derivedTOMs || [],
|
||||
orgHeader,
|
||||
state?.companyProfile || null,
|
||||
state?.riskProfile || null,
|
||||
complianceResult,
|
||||
revisions,
|
||||
)
|
||||
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `TOM-Dokumentation-${orgHeader.organizationName || 'Organisation'}-${new Date().toISOString().split('T')[0]}.html`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, [state, orgHeader, complianceResult, revisions])
|
||||
|
||||
const handleAddRevision = useCallback(() => {
|
||||
setRevisions(prev => [...prev, {
|
||||
version: String(prev.length + 1) + '.0',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
author: '',
|
||||
changes: '',
|
||||
}])
|
||||
}, [])
|
||||
|
||||
const handleUpdateRevision = useCallback((index: number, field: keyof TOMDocumentRevision, value: string) => {
|
||||
setRevisions(prev => prev.map((r, i) => i === index ? { ...r, [field]: value } : r))
|
||||
}, [])
|
||||
|
||||
const handleRemoveRevision = useCallback((index: number) => {
|
||||
setRevisions(prev => prev.filter((_, i) => i !== index))
|
||||
}, [])
|
||||
|
||||
const updateOrgHeader = useCallback((field: keyof TOMDocumentOrgHeader, value: string | string[]) => {
|
||||
setOrgHeader(prev => ({ ...prev, [field]: value }))
|
||||
}, [])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 1. Action Bar */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">TOM-Dokument (Art. 32 DSGVO)</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Auditfaehiges Dokument mit {applicableTOMs.length} Massnahmen generieren
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleDownloadTOMDocumentHtml}
|
||||
disabled={tomCount === 0}
|
||||
className="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
HTML herunterladen
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePrintTOMDocument}
|
||||
disabled={tomCount === 0}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 text-sm font-medium transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Als PDF drucken
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Org Header Form */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-base font-semibold text-gray-900 mb-4">Organisationsdaten</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Organisation</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.organizationName}
|
||||
onChange={e => updateOrgHeader('organizationName', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Branche</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.industry}
|
||||
onChange={e => updateOrgHeader('industry', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Datenschutzbeauftragter</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.dpoName}
|
||||
onChange={e => updateOrgHeader('dpoName', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">DSB-Kontakt</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.dpoContact}
|
||||
onChange={e => updateOrgHeader('dpoContact', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verantwortlicher</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.responsiblePerson}
|
||||
onChange={e => updateOrgHeader('responsiblePerson', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">IT-Sicherheitskontakt</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.itSecurityContact}
|
||||
onChange={e => updateOrgHeader('itSecurityContact', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Mitarbeiteranzahl</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.employeeCount}
|
||||
onChange={e => updateOrgHeader('employeeCount', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Standorte</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.locations.join(', ')}
|
||||
onChange={e => updateOrgHeader('locations', e.target.value.split(',').map(s => s.trim()))}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Dokumentversion</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.documentVersion}
|
||||
onChange={e => updateOrgHeader('documentVersion', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Pruefintervall</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.reviewInterval}
|
||||
onChange={e => updateOrgHeader('reviewInterval', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Letzte Pruefung</label>
|
||||
<input
|
||||
type="date"
|
||||
value={orgHeader.lastReviewDate}
|
||||
onChange={e => updateOrgHeader('lastReviewDate', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Naechste Pruefung</label>
|
||||
<input
|
||||
type="date"
|
||||
value={orgHeader.nextReviewDate}
|
||||
onChange={e => updateOrgHeader('nextReviewDate', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Revisions Manager */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-base font-semibold text-gray-900">Aenderungshistorie</h4>
|
||||
<button
|
||||
onClick={handleAddRevision}
|
||||
className="text-sm text-purple-600 hover:text-purple-700 font-medium"
|
||||
>
|
||||
+ Version hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
{revisions.length > 0 ? (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-2 text-gray-600 font-medium">Version</th>
|
||||
<th className="text-left py-2 text-gray-600 font-medium">Datum</th>
|
||||
<th className="text-left py-2 text-gray-600 font-medium">Autor</th>
|
||||
<th className="text-left py-2 text-gray-600 font-medium">Aenderungen</th>
|
||||
<th className="py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{revisions.map((revision, index) => (
|
||||
<tr key={index} className="border-b border-gray-100">
|
||||
<td className="py-2 pr-2">
|
||||
<input
|
||||
type="text"
|
||||
value={revision.version}
|
||||
onChange={e => handleUpdateRevision(index, 'version', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<input
|
||||
type="date"
|
||||
value={revision.date}
|
||||
onChange={e => handleUpdateRevision(index, 'date', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<input
|
||||
type="text"
|
||||
value={revision.author}
|
||||
onChange={e => handleUpdateRevision(index, 'author', e.target.value)}
|
||||
placeholder="Name"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<input
|
||||
type="text"
|
||||
value={revision.changes}
|
||||
onChange={e => handleUpdateRevision(index, 'changes', e.target.value)}
|
||||
placeholder="Beschreibung der Aenderungen"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
onClick={() => handleRemoveRevision(index)}
|
||||
className="text-sm text-red-600 hover:text-red-700 font-medium"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 italic">
|
||||
Noch keine Revisionen. Die erste Version wird automatisch im Dokument eingetragen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 4. Document Preview */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-base font-semibold text-gray-900 mb-4">Dokument-Vorschau</h4>
|
||||
{tomCount === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">Starten Sie den TOM-Generator, um Massnahmen abzuleiten.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Cover preview */}
|
||||
<div className="bg-purple-50 rounded-lg p-4 text-center">
|
||||
<p className="text-purple-700 font-semibold text-lg">TOM-Dokumentation</p>
|
||||
<p className="text-purple-600 text-sm">
|
||||
Art. 32 DSGVO — {orgHeader.organizationName || 'Organisation'}
|
||||
</p>
|
||||
<p className="text-purple-500 text-xs mt-1">
|
||||
Version {orgHeader.documentVersion} | Stand: {new Date().toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">{applicableTOMs.length}</p>
|
||||
<p className="text-xs text-gray-500">Massnahmen</p>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-green-700">{implementedCount}</p>
|
||||
<p className="text-xs text-gray-500">Umgesetzt</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-purple-700">{canonicalCount || '-'}</p>
|
||||
<p className="text-xs text-gray-500">Belegende Controls</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">12</p>
|
||||
<p className="text-xs text-gray-500">Sektionen</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{complianceResult ? complianceResult.score : '-'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Compliance-Score</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 12 Sections list */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">12 Dokument-Sektionen:</p>
|
||||
<ol className="list-decimal list-inside text-sm text-gray-600 space-y-1">
|
||||
<li>Ziel und Zweck</li>
|
||||
<li>Geltungsbereich</li>
|
||||
<li>Grundprinzipien Art. 32</li>
|
||||
<li>Schutzbedarf und Risikoanalyse</li>
|
||||
<li>Massnahmen-Uebersicht</li>
|
||||
<li>Detaillierte Massnahmen</li>
|
||||
<li>SDM Gewaehrleistungsziele</li>
|
||||
<li>Verantwortlichkeiten</li>
|
||||
<li>Pruef- und Revisionszyklus</li>
|
||||
<li>Compliance-Status</li>
|
||||
<li>Aenderungshistorie</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { TOMDocumentTab }
|
||||
@@ -1,9 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState, useEffect } from 'react'
|
||||
import { useMemo, useState, useEffect, useCallback } from 'react'
|
||||
import { DerivedTOM, TOMGeneratorState } from '@/lib/sdk/tom-generator/types'
|
||||
import { getControlById } from '@/lib/sdk/tom-generator/controls/loader'
|
||||
|
||||
interface CanonicalMapping {
|
||||
id: string
|
||||
canonical_control_code: string
|
||||
canonical_title: string | null
|
||||
canonical_severity: string | null
|
||||
canonical_objective: string | null
|
||||
mapping_type: string
|
||||
}
|
||||
|
||||
interface TOMEditorTabProps {
|
||||
state: TOMGeneratorState
|
||||
selectedTOMId: string | null
|
||||
@@ -46,6 +55,17 @@ export function TOMEditorTab({ state, selectedTOMId, onUpdateTOM, onBack }: TOME
|
||||
const [notes, setNotes] = useState('')
|
||||
const [linkedEvidence, setLinkedEvidence] = useState<string[]>([])
|
||||
const [selectedEvidenceId, setSelectedEvidenceId] = useState('')
|
||||
const [canonicalMappings, setCanonicalMappings] = useState<CanonicalMapping[]>([])
|
||||
const [showCanonical, setShowCanonical] = useState(false)
|
||||
|
||||
// Load canonical controls for this TOM's category
|
||||
useEffect(() => {
|
||||
if (!control?.category) { setCanonicalMappings([]); return }
|
||||
fetch(`/api/sdk/v1/compliance/tom-mappings/by-tom/${encodeURIComponent(control.category)}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => { if (data?.mappings) setCanonicalMappings(data.mappings) })
|
||||
.catch(() => setCanonicalMappings([]))
|
||||
}, [control?.category])
|
||||
|
||||
useEffect(() => {
|
||||
if (tom) {
|
||||
@@ -341,6 +361,62 @@ export function TOMEditorTab({ state, selectedTOMId, onUpdateTOM, onBack }: TOME
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Canonical Controls (Belegende Security-Controls) */}
|
||||
{canonicalMappings.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700">
|
||||
Belegende Security-Controls ({canonicalMappings.length})
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowCanonical(!showCanonical)}
|
||||
className="text-xs text-purple-600 hover:text-purple-700 font-medium"
|
||||
>
|
||||
{showCanonical ? 'Einklappen' : 'Alle anzeigen'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(showCanonical ? canonicalMappings : canonicalMappings.slice(0, 5)).map(m => (
|
||||
<div key={m.id} className="flex items-start gap-3 bg-gray-50 rounded-lg px-3 py-2">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-xs font-mono bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded">
|
||||
{m.canonical_control_code}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-700 font-medium truncate">{m.canonical_title || m.canonical_control_code}</p>
|
||||
{m.canonical_objective && showCanonical && (
|
||||
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{m.canonical_objective}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center gap-1.5">
|
||||
{m.canonical_severity && (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded-full font-medium ${
|
||||
m.canonical_severity === 'critical' ? 'bg-red-100 text-red-700' :
|
||||
m.canonical_severity === 'high' ? 'bg-orange-100 text-orange-700' :
|
||||
m.canonical_severity === 'medium' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{m.canonical_severity}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded-full ${
|
||||
m.mapping_type === 'manual' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
{m.mapping_type === 'manual' ? 'manuell' : 'auto'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!showCanonical && canonicalMappings.length > 5 && (
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
+ {canonicalMappings.length - 5} weitere Controls
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Framework Mappings */}
|
||||
{control?.mappings && control.mappings.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
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'
|
||||
@@ -11,6 +11,18 @@ interface TOMOverviewTabProps {
|
||||
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' },
|
||||
@@ -34,9 +46,41 @@ export function TOMOverviewTab({ state, onSelectTOM, onStartGenerator }: TOMOver
|
||||
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 {
|
||||
@@ -159,6 +203,59 @@ export function TOMOverviewTab({ state, onSelectTOM, onStartGenerator }: TOMOver
|
||||
</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 "Controls synchronisieren", 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">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { TOMOverviewTab } from './TOMOverviewTab'
|
||||
export { TOMEditorTab } from './TOMEditorTab'
|
||||
export { TOMGapExportTab } from './TOMGapExportTab'
|
||||
export { TOMDocumentTab } from './TOMDocumentTab'
|
||||
|
||||
Reference in New Issue
Block a user