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>
450 lines
20 KiB
TypeScript
450 lines
20 KiB
TypeScript
'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 }
|