SDK modules added/enhanced: - compliance-hub, compliance-scope, consent-management, notfallplan - audit-report, workflow, source-policy, dsms - advisory-board documentation section - TOM dashboard components, TOM generator SDM mapping - DSFA: mitigation library, risk catalog, threshold analysis, source attribution - VVT: baseline catalog, profiling engine, types - Loeschfristen: baseline catalog, compliance engine, export, profiling, types - Compliance scope: engine, profiling, golden tests, types Existing SDK pages updated: - dsfa/[id], tom, vvt, loeschfristen, advisory-board — expanded functionality - SDKSidebar, StepHeader — new navigation items and layout - SDK layout, context, types — expanded type system Other admin-v2 changes: - AI agents page, RAG pipeline DSFA integration - GridOverlay component updates - Companion feature (development + education) - Compliance advisor SOUL definition Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
377 lines
16 KiB
TypeScript
377 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import { useMemo, useState, useEffect } from 'react'
|
|
import { DerivedTOM, TOMGeneratorState } from '@/lib/sdk/tom-generator/types'
|
|
import { getControlById } from '@/lib/sdk/tom-generator/controls/loader'
|
|
|
|
interface TOMEditorTabProps {
|
|
state: TOMGeneratorState
|
|
selectedTOMId: string | null
|
|
onUpdateTOM: (tomId: string, updates: Partial<DerivedTOM>) => void
|
|
onBack: () => void
|
|
}
|
|
|
|
const STATUS_OPTIONS: { value: DerivedTOM['implementationStatus']; label: string; className: string }[] = [
|
|
{ value: 'IMPLEMENTED', label: 'Implementiert', className: 'border-green-300 bg-green-50 text-green-700' },
|
|
{ value: 'PARTIAL', label: 'Teilweise implementiert', className: 'border-yellow-300 bg-yellow-50 text-yellow-700' },
|
|
{ value: 'NOT_IMPLEMENTED', label: 'Nicht implementiert', className: 'border-red-300 bg-red-50 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' },
|
|
}
|
|
|
|
interface VVTActivity {
|
|
id: string
|
|
name?: string
|
|
title?: string
|
|
structuredToms?: { category?: string }[]
|
|
}
|
|
|
|
export function TOMEditorTab({ state, selectedTOMId, onUpdateTOM, onBack }: TOMEditorTabProps) {
|
|
const tom = useMemo(() => {
|
|
if (!selectedTOMId) return null
|
|
return state.derivedTOMs.find(t => t.id === selectedTOMId) || null
|
|
}, [state.derivedTOMs, selectedTOMId])
|
|
|
|
const control = useMemo(() => {
|
|
if (!tom) return null
|
|
return getControlById(tom.controlId)
|
|
}, [tom])
|
|
|
|
const [implementationStatus, setImplementationStatus] = useState<DerivedTOM['implementationStatus']>('NOT_IMPLEMENTED')
|
|
const [responsiblePerson, setResponsiblePerson] = useState('')
|
|
const [implementationDate, setImplementationDate] = useState('')
|
|
const [notes, setNotes] = useState('')
|
|
const [linkedEvidence, setLinkedEvidence] = useState<string[]>([])
|
|
const [selectedEvidenceId, setSelectedEvidenceId] = useState('')
|
|
|
|
useEffect(() => {
|
|
if (tom) {
|
|
setImplementationStatus(tom.implementationStatus)
|
|
setResponsiblePerson(tom.responsiblePerson || '')
|
|
setImplementationDate(tom.implementationDate ? new Date(tom.implementationDate).toISOString().slice(0, 10) : '')
|
|
setNotes(tom.aiGeneratedDescription || '')
|
|
setLinkedEvidence(tom.linkedEvidence || [])
|
|
}
|
|
}, [tom])
|
|
|
|
const vvtActivities = useMemo(() => {
|
|
if (!control) return []
|
|
try {
|
|
const raw = localStorage.getItem('bp_vvt')
|
|
if (!raw) return []
|
|
const activities: VVTActivity[] = JSON.parse(raw)
|
|
return activities.filter(a =>
|
|
a.structuredToms?.some(t => t.category === control.category)
|
|
)
|
|
} catch {
|
|
return []
|
|
}
|
|
}, [control])
|
|
|
|
const availableDocuments = useMemo(() => {
|
|
return (state.documents || []).filter(
|
|
doc => !linkedEvidence.includes(doc.id)
|
|
)
|
|
}, [state.documents, linkedEvidence])
|
|
|
|
const linkedDocuments = useMemo(() => {
|
|
return linkedEvidence
|
|
.map(id => (state.documents || []).find(d => d.id === id))
|
|
.filter(Boolean)
|
|
}, [state.documents, linkedEvidence])
|
|
|
|
const evidenceGaps = useMemo(() => {
|
|
if (!control?.evidenceRequirements) return []
|
|
return control.evidenceRequirements.map(req => {
|
|
const hasMatch = (state.documents || []).some(doc =>
|
|
linkedEvidence.includes(doc.id) &&
|
|
(doc.filename?.toLowerCase().includes(req.toLowerCase()) ||
|
|
doc.documentType?.toLowerCase().includes(req.toLowerCase()))
|
|
)
|
|
return { requirement: req, fulfilled: hasMatch }
|
|
})
|
|
}, [control, state.documents, linkedEvidence])
|
|
|
|
const handleSave = () => {
|
|
if (!tom) return
|
|
onUpdateTOM(tom.id, {
|
|
implementationStatus,
|
|
responsiblePerson: responsiblePerson || null,
|
|
implementationDate: implementationDate ? new Date(implementationDate) : null,
|
|
aiGeneratedDescription: notes || null,
|
|
linkedEvidence,
|
|
})
|
|
}
|
|
|
|
const handleAddEvidence = () => {
|
|
if (!selectedEvidenceId) return
|
|
setLinkedEvidence(prev => [...prev, selectedEvidenceId])
|
|
setSelectedEvidenceId('')
|
|
}
|
|
|
|
const handleRemoveEvidence = (docId: string) => {
|
|
setLinkedEvidence(prev => prev.filter(id => id !== docId))
|
|
}
|
|
|
|
if (!selectedTOMId || !tom) {
|
|
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="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
|
</svg>
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-700 mb-2">Keine TOM ausgewaehlt</h3>
|
|
<p className="text-gray-500">Waehlen Sie eine TOM aus der Uebersicht, um sie zu bearbeiten.</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const typeBadge = TYPE_BADGES[control?.type || 'TECHNICAL'] || TYPE_BADGES.TECHNICAL
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<button
|
|
onClick={onBack}
|
|
className="text-sm text-purple-600 hover:text-purple-800 font-medium flex items-center gap-1"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
Zurueck zur Uebersicht
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 text-sm font-medium transition-colors"
|
|
>
|
|
Aenderungen speichern
|
|
</button>
|
|
</div>
|
|
|
|
{/* TOM Header Card */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<div className="flex items-start gap-3 mb-3">
|
|
<span className="text-xs font-mono bg-gray-100 text-gray-600 px-2 py-1 rounded">{control?.code || tom.controlId}</span>
|
|
<span className={`text-xs px-2 py-1 rounded-full font-medium ${typeBadge.className}`}>
|
|
{typeBadge.label}
|
|
</span>
|
|
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded-full font-medium">
|
|
{control?.category || 'Unbekannt'}
|
|
</span>
|
|
</div>
|
|
<h2 className="text-xl font-bold text-gray-900 mb-2">{control?.name?.de || tom.controlId}</h2>
|
|
{control?.description?.de && (
|
|
<p className="text-sm text-gray-600 leading-relaxed">{control.description.de}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Implementation Status */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-sm font-semibold text-gray-700 mb-4">Implementierungsstatus</h3>
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
|
{STATUS_OPTIONS.map(opt => (
|
|
<label
|
|
key={opt.value}
|
|
className={`flex items-center gap-3 border rounded-lg p-3 cursor-pointer transition-all ${
|
|
implementationStatus === opt.value
|
|
? opt.className + ' ring-2 ring-offset-1 ring-current'
|
|
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="implementationStatus"
|
|
value={opt.value}
|
|
checked={implementationStatus === opt.value}
|
|
onChange={() => setImplementationStatus(opt.value)}
|
|
className="sr-only"
|
|
/>
|
|
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
|
|
implementationStatus === opt.value ? 'border-current' : 'border-gray-300'
|
|
}`}>
|
|
{implementationStatus === opt.value && (
|
|
<div className="w-2 h-2 rounded-full bg-current" />
|
|
)}
|
|
</div>
|
|
<span className="text-sm font-medium">{opt.label}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Responsible Person */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-sm font-semibold text-gray-700 mb-4">Verantwortliche Person</h3>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-600 mb-1">Umgesetzt von</label>
|
|
<input
|
|
type="text"
|
|
value={responsiblePerson}
|
|
onChange={e => setResponsiblePerson(e.target.value)}
|
|
placeholder="Name der verantwortlichen Person"
|
|
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"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-medium text-gray-600 mb-1">Umsetzungsdatum</label>
|
|
<input
|
|
type="date"
|
|
value={implementationDate}
|
|
onChange={e => setImplementationDate(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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Notes */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-sm font-semibold text-gray-700 mb-4">Anmerkungen</h3>
|
|
<textarea
|
|
value={notes}
|
|
onChange={e => setNotes(e.target.value)}
|
|
rows={4}
|
|
placeholder="Anmerkungen zur Umsetzung, Besonderheiten, etc."
|
|
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 resize-y"
|
|
/>
|
|
</div>
|
|
|
|
{/* Evidence Section */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-sm font-semibold text-gray-700 mb-4">Nachweisdokumente</h3>
|
|
|
|
{linkedDocuments.length > 0 ? (
|
|
<div className="space-y-2 mb-4">
|
|
{linkedDocuments.map(doc => doc && (
|
|
<div key={doc.id} className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
|
|
<div className="flex items-center gap-2">
|
|
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
|
|
<span className="text-sm text-gray-700">{doc.originalName || doc.filename || doc.id}</span>
|
|
</div>
|
|
<button
|
|
onClick={() => handleRemoveEvidence(doc.id)}
|
|
className="text-red-500 hover:text-red-700 text-xs font-medium"
|
|
>
|
|
Entfernen
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-gray-400 mb-4">Keine Nachweisdokumente verknuepft.</p>
|
|
)}
|
|
|
|
{availableDocuments.length > 0 && (
|
|
<div className="flex items-end gap-2">
|
|
<div className="flex-1">
|
|
<label className="block text-xs font-medium text-gray-600 mb-1">Dokument hinzufuegen</label>
|
|
<select
|
|
value={selectedEvidenceId}
|
|
onChange={e => setSelectedEvidenceId(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="">Dokument auswaehlen...</option>
|
|
{availableDocuments.map(doc => (
|
|
<option key={doc.id} value={doc.id}>{doc.originalName || doc.filename || doc.id}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<button
|
|
onClick={handleAddEvidence}
|
|
disabled={!selectedEvidenceId}
|
|
className="bg-purple-600 text-white hover:bg-purple-700 disabled:bg-gray-300 disabled:cursor-not-allowed rounded-lg px-4 py-2 text-sm font-medium transition-colors"
|
|
>
|
|
Hinzufuegen
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Evidence Gaps */}
|
|
{evidenceGaps.length > 0 && (
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-sm font-semibold text-gray-700 mb-4">Nachweis-Anforderungen</h3>
|
|
<div className="space-y-2">
|
|
{evidenceGaps.map((gap, idx) => (
|
|
<div key={idx} className="flex items-center gap-3">
|
|
<div className={`w-5 h-5 rounded flex items-center justify-center flex-shrink-0 ${
|
|
gap.fulfilled ? 'bg-green-100 text-green-600' : 'bg-red-50 text-red-400'
|
|
}`}>
|
|
{gap.fulfilled ? (
|
|
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
|
</svg>
|
|
) : (
|
|
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
|
</svg>
|
|
)}
|
|
</div>
|
|
<span className={`text-sm ${gap.fulfilled ? 'text-gray-700' : 'text-gray-500'}`}>
|
|
{gap.requirement}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* VVT Cross-References */}
|
|
{vvtActivities.length > 0 && (
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-sm font-semibold text-gray-700 mb-4">VVT-Querverweise</h3>
|
|
<div className="space-y-2">
|
|
{vvtActivities.map(activity => (
|
|
<div key={activity.id} className="flex items-center gap-2 bg-purple-50 rounded-lg px-3 py-2">
|
|
<svg className="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
|
</svg>
|
|
<span className="text-sm text-purple-700">{activity.name || activity.title || activity.id}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Framework Mappings */}
|
|
{control?.mappings && control.mappings.length > 0 && (
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
<h3 className="text-sm font-semibold text-gray-700 mb-4">Framework-Zuordnungen</h3>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
{control.mappings.map((mapping, idx) => (
|
|
<div key={idx} className="flex items-center gap-2 bg-gray-50 rounded-lg px-3 py-2">
|
|
<span className="text-xs font-semibold text-gray-500 uppercase">{mapping.framework}</span>
|
|
<span className="text-sm text-gray-700">{mapping.reference}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Bottom Save */}
|
|
<div className="flex items-center justify-between pt-2">
|
|
<button
|
|
onClick={onBack}
|
|
className="text-sm text-gray-500 hover:text-gray-700 font-medium"
|
|
>
|
|
Zurueck zur Uebersicht
|
|
</button>
|
|
<button
|
|
onClick={handleSave}
|
|
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-6 py-2.5 font-medium transition-colors"
|
|
>
|
|
Aenderungen speichern
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|