Services: Admin-Compliance, Backend-Compliance, AI-Compliance-SDK, Consent-SDK, Developer-Portal, PCA-Platform, DSMS Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
329 lines
15 KiB
TypeScript
329 lines
15 KiB
TypeScript
'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>
|
|
)
|
|
}
|