feat(admin-v2): Major SDK/Compliance overhaul and new modules
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>
This commit is contained in:
376
admin-v2/components/sdk/tom-dashboard/TOMEditorTab.tsx
Normal file
376
admin-v2/components/sdk/tom-dashboard/TOMEditorTab.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
328
admin-v2/components/sdk/tom-dashboard/TOMGapExportTab.tsx
Normal file
328
admin-v2/components/sdk/tom-dashboard/TOMGapExportTab.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { TOMGeneratorState, GapAnalysisResult, DerivedTOM } from '@/lib/sdk/tom-generator/types'
|
||||
import { getControlById, getAllControls } from '@/lib/sdk/tom-generator/controls/loader'
|
||||
import {
|
||||
SDM_GOAL_LABELS,
|
||||
SDM_GOAL_DESCRIPTIONS,
|
||||
getSDMCoverageStats,
|
||||
MODULE_LABELS,
|
||||
getModuleCoverageStats,
|
||||
SDMGewaehrleistungsziel,
|
||||
TOMModuleCategory,
|
||||
} from '@/lib/sdk/tom-generator/sdm-mapping'
|
||||
|
||||
interface TOMGapExportTabProps {
|
||||
state: TOMGeneratorState
|
||||
onRunGapAnalysis: () => void
|
||||
}
|
||||
|
||||
function getScoreColor(score: number): string {
|
||||
if (score >= 75) return 'text-green-600'
|
||||
if (score >= 50) return 'text-yellow-600'
|
||||
return 'text-red-600'
|
||||
}
|
||||
|
||||
function getScoreBgColor(score: number): string {
|
||||
if (score >= 75) return 'bg-green-50 border-green-200'
|
||||
if (score >= 50) return 'bg-yellow-50 border-yellow-200'
|
||||
return 'bg-red-50 border-red-200'
|
||||
}
|
||||
|
||||
function getBarColor(score: number): string {
|
||||
if (score >= 75) return 'bg-green-500'
|
||||
if (score >= 50) return 'bg-yellow-500'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
function downloadJSON(data: unknown, filename: string) {
|
||||
const json = JSON.stringify(data, null, 2)
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export function TOMGapExportTab({ state, onRunGapAnalysis }: TOMGapExportTabProps) {
|
||||
const gap = state.gapAnalysis as GapAnalysisResult | null | undefined
|
||||
|
||||
const sdmGoals = useMemo(() => {
|
||||
const goals = Object.keys(SDM_GOAL_LABELS) as SDMGewaehrleistungsziel[]
|
||||
const allStats = getSDMCoverageStats(state.derivedTOMs)
|
||||
return goals.map(key => {
|
||||
const stats = allStats[key] || { total: 0, implemented: 0, partial: 0, missing: 0 }
|
||||
const total = stats.total || 1
|
||||
const percent = Math.round((stats.implemented / total) * 100)
|
||||
return {
|
||||
key,
|
||||
label: SDM_GOAL_LABELS[key],
|
||||
description: SDM_GOAL_DESCRIPTIONS[key],
|
||||
stats,
|
||||
percent,
|
||||
}
|
||||
})
|
||||
}, [state.derivedTOMs])
|
||||
|
||||
const modules = useMemo(() => {
|
||||
const moduleKeys = Object.keys(MODULE_LABELS) as TOMModuleCategory[]
|
||||
const allStats = getModuleCoverageStats(state.derivedTOMs)
|
||||
return moduleKeys.map(key => {
|
||||
const stats = allStats[key] || { total: 0, implemented: 0 }
|
||||
const total = stats.total || 1
|
||||
const percent = Math.round((stats.implemented / total) * 100)
|
||||
return {
|
||||
key,
|
||||
label: MODULE_LABELS[key],
|
||||
stats: { ...stats, partial: 0, missing: total - stats.implemented },
|
||||
percent,
|
||||
}
|
||||
})
|
||||
}, [state.derivedTOMs])
|
||||
|
||||
const handleExportTOMs = () => {
|
||||
downloadJSON(state.derivedTOMs, `tom-export-${new Date().toISOString().slice(0, 10)}.json`)
|
||||
}
|
||||
|
||||
const handleExportGap = () => {
|
||||
if (!gap) return
|
||||
downloadJSON(gap, `gap-analyse-${new Date().toISOString().slice(0, 10)}.json`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Gap Analysis */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Gap-Analyse</h3>
|
||||
<button
|
||||
onClick={onRunGapAnalysis}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
Analyse ausfuehren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{gap ? (
|
||||
<div className="space-y-6">
|
||||
{/* Score Gauge */}
|
||||
<div className="flex justify-center">
|
||||
<div className={`rounded-xl border-2 p-8 text-center ${getScoreBgColor(gap.overallScore)}`}>
|
||||
<div className={`text-5xl font-bold ${getScoreColor(gap.overallScore)}`}>
|
||||
{gap.overallScore}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-1">von 100 Punkten</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Missing Controls */}
|
||||
{gap.missingControls && gap.missingControls.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-red-700 mb-2">
|
||||
Fehlende Kontrollen ({gap.missingControls.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{gap.missingControls.map((mc, idx) => {
|
||||
const control = getControlById(mc.controlId)
|
||||
return (
|
||||
<div key={idx} className="flex items-center gap-2 bg-red-50 rounded-lg px-3 py-2">
|
||||
<span className="text-xs font-mono text-red-400">{control?.code || mc.controlId}</span>
|
||||
<span className="text-sm text-red-700">{control?.name?.de || mc.controlId}</span>
|
||||
{mc.reason && <span className="text-xs text-red-400 ml-auto">{mc.reason}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Partial Controls */}
|
||||
{gap.partialControls && gap.partialControls.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-yellow-700 mb-2">
|
||||
Teilweise implementierte Kontrollen ({gap.partialControls.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{gap.partialControls.map((pc, idx) => {
|
||||
const control = getControlById(pc.controlId)
|
||||
return (
|
||||
<div key={idx} className="flex items-center gap-2 bg-yellow-50 rounded-lg px-3 py-2">
|
||||
<span className="text-xs font-mono text-yellow-500">{control?.code || pc.controlId}</span>
|
||||
<span className="text-sm text-yellow-700">{control?.name?.de || pc.controlId}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Missing Evidence */}
|
||||
{gap.missingEvidence && gap.missingEvidence.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-orange-700 mb-2">
|
||||
Fehlende Nachweise ({gap.missingEvidence.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{gap.missingEvidence.map((item, idx) => {
|
||||
const control = getControlById(item.controlId)
|
||||
return (
|
||||
<div key={idx} className="flex items-center gap-2 bg-orange-50 rounded-lg px-3 py-2">
|
||||
<svg className="w-4 h-4 text-orange-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<span className="text-sm text-orange-700">
|
||||
{control?.name?.de || item.controlId}: {item.requiredEvidence.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{gap.recommendations && gap.recommendations.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-blue-700 mb-2">
|
||||
Empfehlungen ({gap.recommendations.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{gap.recommendations.map((rec, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2 bg-blue-50 rounded-lg px-3 py-2">
|
||||
<svg className="w-4 h-4 text-blue-400 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm text-blue-700">
|
||||
{typeof rec === 'string' ? rec : (rec as { text?: string; message?: string }).text || (rec as { text?: string; message?: string }).message || JSON.stringify(rec)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<svg className="w-12 h-12 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<p className="text-sm">Fuehren Sie die Gap-Analyse aus, um Luecken in Ihren TOMs zu identifizieren.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SDM Gewaehrleistungsziele */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">SDM Gewaehrleistungsziele</h3>
|
||||
<div className="space-y-4">
|
||||
{sdmGoals.map(goal => (
|
||||
<div key={goal.key}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700">{goal.label}</span>
|
||||
{goal.description && (
|
||||
<span className="text-xs text-gray-400 ml-2">{goal.description}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{goal.stats.implemented}/{goal.stats.total} implementiert
|
||||
{goal.stats.partial > 0 && ` | ${goal.stats.partial} teilweise`}
|
||||
{goal.stats.missing > 0 && ` | ${goal.stats.missing} fehlend`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div className="h-full flex">
|
||||
<div
|
||||
className="bg-green-500 h-full transition-all"
|
||||
style={{ width: `${goal.percent}%` }}
|
||||
/>
|
||||
<div
|
||||
className="bg-yellow-400 h-full transition-all"
|
||||
style={{ width: `${goal.stats.total ? Math.round((goal.stats.partial / goal.stats.total) * 100) : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Module Coverage */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Modul-Abdeckung</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{modules.map(mod => (
|
||||
<div key={mod.key} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">{mod.label}</div>
|
||||
<div className="flex items-end gap-2 mb-2">
|
||||
<span className={`text-2xl font-bold ${getScoreColor(mod.percent)}`}>
|
||||
{mod.percent}%
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 mb-1">
|
||||
({mod.stats.implemented}/{mod.stats.total})
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${getBarColor(mod.percent)}`}
|
||||
style={{ width: `${mod.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{mod.stats.partial > 0 && (
|
||||
<div className="text-xs text-yellow-600 mt-1">{mod.stats.partial} teilweise</div>
|
||||
)}
|
||||
{mod.stats.missing > 0 && (
|
||||
<div className="text-xs text-red-500 mt-0.5">{mod.stats.missing} fehlend</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Section */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Export</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<button
|
||||
onClick={handleExportTOMs}
|
||||
disabled={state.derivedTOMs.length === 0}
|
||||
className="flex flex-col items-center gap-2 border border-gray-200 rounded-lg p-4 hover:border-purple-300 hover:bg-purple-50 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-8 h-8 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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 font-medium text-gray-700">JSON Export</span>
|
||||
<span className="text-xs text-gray-400">Alle TOMs als JSON</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleExportGap}
|
||||
disabled={!gap}
|
||||
className="flex flex-col items-center gap-2 border border-gray-200 rounded-lg p-4 hover:border-purple-300 hover:bg-purple-50 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-8 h-8 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-700">Gap-Analyse Export</span>
|
||||
<span className="text-xs text-gray-400">Analyseergebnis als JSON</span>
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col items-center gap-2 border border-dashed border-gray-300 rounded-lg p-4 bg-gray-50">
|
||||
<svg className="w-8 h-8 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-gray-500">Vollstaendiger Export (ZIP)</span>
|
||||
<span className="text-xs text-gray-400 text-center">
|
||||
Nutzen Sie den TOM Generator fuer den vollstaendigen Export mit DOCX/PDF
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
267
admin-v2/components/sdk/tom-dashboard/TOMOverviewTab.tsx
Normal file
267
admin-v2/components/sdk/tom-dashboard/TOMOverviewTab.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } 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
|
||||
}
|
||||
|
||||
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 categories = useMemo(() => getAllCategories(), [])
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
3
admin-v2/components/sdk/tom-dashboard/index.ts
Normal file
3
admin-v2/components/sdk/tom-dashboard/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { TOMOverviewTab } from './TOMOverviewTab'
|
||||
export { TOMEditorTab } from './TOMEditorTab'
|
||||
export { TOMGapExportTab } from './TOMGapExportTab'
|
||||
Reference in New Issue
Block a user