refactor: Remove Compliance SDK from admin-v2 sidebar, add new SDK modules
Remove Compliance SDK category from sidebar navigation as it is now handled exclusively in the Compliance Admin. Add new SDK modules (DSB Portal, Industry Templates, Multi-Tenant, Reporting, SSO) and GCI engine components. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,879 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Branchenspezifische Module (Phase 3.3)
|
||||
*
|
||||
* Industry-specific compliance template packages:
|
||||
* - Browse industry templates (grid view)
|
||||
* - View full detail with VVT, TOM, Risk tabs
|
||||
* - Apply template packages to current compliance setup
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface IndustrySummary {
|
||||
slug: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
regulation_count: number
|
||||
template_count: number
|
||||
}
|
||||
|
||||
interface IndustryTemplate {
|
||||
slug: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
regulations: string[]
|
||||
vvt_templates: VVTTemplate[]
|
||||
tom_recommendations: TOMRecommendation[]
|
||||
risk_scenarios: RiskScenario[]
|
||||
}
|
||||
|
||||
interface VVTTemplate {
|
||||
name: string
|
||||
purpose: string
|
||||
legal_basis: string
|
||||
data_categories: string[]
|
||||
data_subjects: string[]
|
||||
retention_period: string
|
||||
}
|
||||
|
||||
interface TOMRecommendation {
|
||||
category: string
|
||||
name: string
|
||||
description: string
|
||||
priority: string
|
||||
}
|
||||
|
||||
interface RiskScenario {
|
||||
name: string
|
||||
description: string
|
||||
likelihood: string
|
||||
impact: string
|
||||
mitigation: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
type DetailTab = 'vvt' | 'tom' | 'risks'
|
||||
|
||||
const DETAIL_TABS: { key: DetailTab; label: string }[] = [
|
||||
{ key: 'vvt', label: 'VVT-Vorlagen' },
|
||||
{ key: 'tom', label: 'TOM-Empfehlungen' },
|
||||
{ key: 'risks', label: 'Risiko-Szenarien' },
|
||||
]
|
||||
|
||||
const PRIORITY_COLORS: Record<string, { bg: string; text: string; border: string }> = {
|
||||
critical: { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200' },
|
||||
high: { bg: 'bg-orange-50', text: 'text-orange-700', border: 'border-orange-200' },
|
||||
medium: { bg: 'bg-yellow-50', text: 'text-yellow-700', border: 'border-yellow-200' },
|
||||
low: { bg: 'bg-green-50', text: 'text-green-700', border: 'border-green-200' },
|
||||
}
|
||||
|
||||
const PRIORITY_LABELS: Record<string, string> = {
|
||||
critical: 'Kritisch',
|
||||
high: 'Hoch',
|
||||
medium: 'Mittel',
|
||||
low: 'Niedrig',
|
||||
}
|
||||
|
||||
const LIKELIHOOD_COLORS: Record<string, string> = {
|
||||
low: 'bg-green-500',
|
||||
medium: 'bg-yellow-500',
|
||||
high: 'bg-orange-500',
|
||||
}
|
||||
|
||||
const IMPACT_COLORS: Record<string, string> = {
|
||||
low: 'bg-green-500',
|
||||
medium: 'bg-yellow-500',
|
||||
high: 'bg-orange-500',
|
||||
critical: 'bg-red-600',
|
||||
}
|
||||
|
||||
const TOM_CATEGORY_ICONS: Record<string, string> = {
|
||||
'Zutrittskontrolle': '\uD83D\uDEAA',
|
||||
'Zugangskontrolle': '\uD83D\uDD10',
|
||||
'Zugriffskontrolle': '\uD83D\uDC65',
|
||||
'Trennungskontrolle': '\uD83D\uDDC2\uFE0F',
|
||||
'Pseudonymisierung': '\uD83C\uDFAD',
|
||||
'Verschluesselung': '\uD83D\uDD12',
|
||||
'Integritaet': '\u2705',
|
||||
'Verfuegbarkeit': '\u2B06\uFE0F',
|
||||
'Belastbarkeit': '\uD83D\uDEE1\uFE0F',
|
||||
'Wiederherstellung': '\uD83D\uDD04',
|
||||
'Datenschutz-Management': '\uD83D\uDCCB',
|
||||
'Auftragsverarbeitung': '\uD83D\uDCDD',
|
||||
'Incident Response': '\uD83D\uDEA8',
|
||||
'Schulung': '\uD83C\uDF93',
|
||||
'Netzwerksicherheit': '\uD83C\uDF10',
|
||||
'Datensicherung': '\uD83D\uDCBE',
|
||||
'Monitoring': '\uD83D\uDCCA',
|
||||
'Physische Sicherheit': '\uD83C\uDFE2',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SKELETON COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function GridSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="bg-white rounded-xl border border-slate-200 p-6 animate-pulse">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-slate-200" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="h-5 bg-slate-200 rounded w-2/3" />
|
||||
<div className="h-4 bg-slate-100 rounded w-full" />
|
||||
<div className="h-4 bg-slate-100 rounded w-4/5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 mt-5">
|
||||
<div className="h-6 bg-slate-100 rounded-full w-28" />
|
||||
<div className="h-6 bg-slate-100 rounded-full w-24" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div className="bg-white rounded-xl border p-6 space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-xl bg-slate-200" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="h-6 bg-slate-200 rounded w-1/3" />
|
||||
<div className="h-4 bg-slate-100 rounded w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-7 bg-slate-100 rounded-full w-20" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border p-6 space-y-4">
|
||||
<div className="flex gap-2 border-b pb-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-9 bg-slate-100 rounded-lg w-32" />
|
||||
))}
|
||||
</div>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-28 bg-slate-50 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export default function IndustryTemplatesPage() {
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
const [industries, setIndustries] = useState<IndustrySummary[]>([])
|
||||
const [selectedDetail, setSelectedDetail] = useState<IndustryTemplate | null>(null)
|
||||
const [selectedSlug, setSelectedSlug] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>('vvt')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [detailError, setDetailError] = useState<string | null>(null)
|
||||
const [applying, setApplying] = useState(false)
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data fetching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const loadIndustries = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/industry/templates')
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
setIndustries(Array.isArray(data) ? data : data.industries || data.templates || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load industries:', err)
|
||||
setError('Branchenvorlagen konnten nicht geladen werden. Bitte pruefen Sie die Verbindung zum Backend.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadDetail = useCallback(async (slug: string) => {
|
||||
setDetailLoading(true)
|
||||
setDetailError(null)
|
||||
setSelectedSlug(slug)
|
||||
setActiveTab('vvt')
|
||||
try {
|
||||
const [detailRes, vvtRes, tomRes, risksRes] = await Promise.all([
|
||||
fetch(`/api/sdk/v1/industry/templates/${slug}`),
|
||||
fetch(`/api/sdk/v1/industry/templates/${slug}/vvt`),
|
||||
fetch(`/api/sdk/v1/industry/templates/${slug}/tom`),
|
||||
fetch(`/api/sdk/v1/industry/templates/${slug}/risks`),
|
||||
])
|
||||
|
||||
if (!detailRes.ok) {
|
||||
throw new Error(`HTTP ${detailRes.status}: ${detailRes.statusText}`)
|
||||
}
|
||||
|
||||
const detail: IndustryTemplate = await detailRes.json()
|
||||
|
||||
// Merge sub-resources if the detail endpoint did not include them
|
||||
if (vvtRes.ok) {
|
||||
const vvtData = await vvtRes.json()
|
||||
detail.vvt_templates = vvtData.vvt_templates || vvtData.templates || vvtData || []
|
||||
}
|
||||
if (tomRes.ok) {
|
||||
const tomData = await tomRes.json()
|
||||
detail.tom_recommendations = tomData.tom_recommendations || tomData.recommendations || tomData || []
|
||||
}
|
||||
if (risksRes.ok) {
|
||||
const risksData = await risksRes.json()
|
||||
detail.risk_scenarios = risksData.risk_scenarios || risksData.scenarios || risksData || []
|
||||
}
|
||||
|
||||
setSelectedDetail(detail)
|
||||
} catch (err) {
|
||||
console.error('Failed to load industry detail:', err)
|
||||
setDetailError('Details konnten nicht geladen werden. Bitte versuchen Sie es erneut.')
|
||||
} finally {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadIndustries()
|
||||
}, [loadIndustries])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handleBackToGrid = useCallback(() => {
|
||||
setSelectedSlug(null)
|
||||
setSelectedDetail(null)
|
||||
setDetailError(null)
|
||||
}, [])
|
||||
|
||||
const handleApplyPackage = useCallback(async () => {
|
||||
if (!selectedDetail) return
|
||||
setApplying(true)
|
||||
try {
|
||||
// Placeholder: In production this would POST to an import endpoint
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
setToastMessage(
|
||||
`Branchenpaket "${selectedDetail.name}" wurde erfolgreich angewendet. ` +
|
||||
`${selectedDetail.vvt_templates?.length || 0} VVT-Vorlagen, ` +
|
||||
`${selectedDetail.tom_recommendations?.length || 0} TOM-Empfehlungen und ` +
|
||||
`${selectedDetail.risk_scenarios?.length || 0} Risiko-Szenarien wurden importiert.`
|
||||
)
|
||||
} catch {
|
||||
setToastMessage('Fehler beim Anwenden des Branchenpakets. Bitte versuchen Sie es erneut.')
|
||||
} finally {
|
||||
setApplying(false)
|
||||
}
|
||||
}, [selectedDetail])
|
||||
|
||||
// Auto-dismiss toast
|
||||
useEffect(() => {
|
||||
if (!toastMessage) return
|
||||
const timer = setTimeout(() => setToastMessage(null), 6000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [toastMessage])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: Header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderHeader = () => (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center text-white text-lg">
|
||||
{'\uD83C\uDFED'}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Branchenspezifische Module</h1>
|
||||
<p className="text-slate-500 mt-0.5">
|
||||
Vorkonfigurierte Compliance-Pakete nach Branche
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: Error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderError = (message: string, onRetry: () => void) => (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-5 flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<p className="text-red-700 font-medium">Fehler</p>
|
||||
<p className="text-red-600 text-sm mt-1">{message}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="px-4 py-1.5 text-sm font-medium text-red-700 bg-red-100 hover:bg-red-200 rounded-lg transition-colors"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: Industry Grid
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderGrid = () => (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{industries.map((industry) => (
|
||||
<button
|
||||
key={industry.slug}
|
||||
onClick={() => loadDetail(industry.slug)}
|
||||
className="bg-white rounded-xl border border-slate-200 p-6 text-left hover:border-emerald-300 hover:shadow-md transition-all duration-200 group"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-100 flex items-center justify-center text-3xl flex-shrink-0 group-hover:from-emerald-100 group-hover:to-teal-100 transition-colors">
|
||||
{industry.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-slate-900 group-hover:text-emerald-700 transition-colors">
|
||||
{industry.name}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500 mt-1 line-clamp-2">
|
||||
{industry.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-4">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-emerald-50 text-emerald-700 border border-emerald-100">
|
||||
<svg className="w-3.5 h-3.5" 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>
|
||||
{industry.regulation_count} Regulierungen
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-teal-50 text-teal-700 border border-teal-100">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
||||
</svg>
|
||||
{industry.template_count} Vorlagen
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: Detail View - Header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderDetailHeader = () => {
|
||||
if (!selectedDetail) return null
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<button
|
||||
onClick={handleBackToGrid}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-emerald-600 transition-colors mb-4"
|
||||
>
|
||||
<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>
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-16 h-16 rounded-xl bg-gradient-to-br from-emerald-50 to-teal-50 border border-emerald-100 flex items-center justify-center text-4xl flex-shrink-0">
|
||||
{selectedDetail.icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-slate-900">{selectedDetail.name}</h2>
|
||||
<p className="text-slate-500 mt-1">{selectedDetail.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Regulation Badges */}
|
||||
{selectedDetail.regulations && selectedDetail.regulations.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-2">
|
||||
Relevante Regulierungen
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedDetail.regulations.map((reg) => (
|
||||
<span
|
||||
key={reg}
|
||||
className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-emerald-50 text-emerald-700 border border-emerald-200"
|
||||
>
|
||||
{reg}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="grid grid-cols-3 gap-4 mt-5 pt-5 border-t border-slate-100">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-emerald-600">
|
||||
{selectedDetail.vvt_templates?.length || 0}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">VVT-Vorlagen</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-teal-600">
|
||||
{selectedDetail.tom_recommendations?.length || 0}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">TOM-Empfehlungen</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold text-amber-600">
|
||||
{selectedDetail.risk_scenarios?.length || 0}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">Risiko-Szenarien</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: VVT Tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderVVTTab = () => {
|
||||
const templates = selectedDetail?.vvt_templates || []
|
||||
if (templates.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<p className="text-lg">Keine VVT-Vorlagen verfuegbar</p>
|
||||
<p className="text-sm mt-1">Fuer diese Branche wurden noch keine Verarbeitungsvorlagen definiert.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{templates.map((vvt, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white border border-slate-200 rounded-lg p-5 hover:border-emerald-200 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-slate-900">{vvt.name}</h4>
|
||||
<p className="text-sm text-slate-500 mt-1">{vvt.purpose}</p>
|
||||
</div>
|
||||
<span className="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-slate-100 text-slate-600 flex-shrink-0">
|
||||
{vvt.retention_period}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-slate-100">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{/* Legal Basis */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">Rechtsgrundlage</p>
|
||||
<p className="text-sm text-slate-700">{vvt.legal_basis}</p>
|
||||
</div>
|
||||
|
||||
{/* Retention Period (mobile only, since shown in badge on desktop) */}
|
||||
<div className="sm:hidden">
|
||||
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1">Aufbewahrungsfrist</p>
|
||||
<p className="text-sm text-slate-700">{vvt.retention_period}</p>
|
||||
</div>
|
||||
|
||||
{/* Data Categories */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">Datenkategorien</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{vvt.data_categories.map((cat) => (
|
||||
<span
|
||||
key={cat}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-emerald-50 text-emerald-700 border border-emerald-100"
|
||||
>
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Subjects */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider mb-1.5">Betroffene</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{vvt.data_subjects.map((sub) => (
|
||||
<span
|
||||
key={sub}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-teal-50 text-teal-700 border border-teal-100"
|
||||
>
|
||||
{sub}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: TOM Tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderTOMTab = () => {
|
||||
const recommendations = selectedDetail?.tom_recommendations || []
|
||||
if (recommendations.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<p className="text-lg">Keine TOM-Empfehlungen verfuegbar</p>
|
||||
<p className="text-sm mt-1">Fuer diese Branche wurden noch keine technisch-organisatorischen Massnahmen definiert.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Group by category
|
||||
const grouped: Record<string, TOMRecommendation[]> = {}
|
||||
recommendations.forEach((tom) => {
|
||||
if (!grouped[tom.category]) {
|
||||
grouped[tom.category] = []
|
||||
}
|
||||
grouped[tom.category].push(tom)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(grouped).map(([category, items]) => {
|
||||
const icon = TOM_CATEGORY_ICONS[category] || '\uD83D\uDD27'
|
||||
return (
|
||||
<div key={category}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-lg">{icon}</span>
|
||||
<h4 className="font-semibold text-slate-800">{category}</h4>
|
||||
<span className="text-xs text-slate-400 ml-1">({items.length})</span>
|
||||
</div>
|
||||
<div className="space-y-3 ml-7">
|
||||
{items.map((tom, idx) => {
|
||||
const prio = PRIORITY_COLORS[tom.priority] || PRIORITY_COLORS.medium
|
||||
const prioLabel = PRIORITY_LABELS[tom.priority] || tom.priority
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white border border-slate-200 rounded-lg p-4 hover:border-emerald-200 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<h5 className="font-medium text-slate-900">{tom.name}</h5>
|
||||
<p className="text-sm text-slate-500 mt-1">{tom.description}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold border flex-shrink-0 ${prio.bg} ${prio.text} ${prio.border}`}
|
||||
>
|
||||
{prioLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: Risk Tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderRiskTab = () => {
|
||||
const scenarios = selectedDetail?.risk_scenarios || []
|
||||
if (scenarios.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<p className="text-lg">Keine Risiko-Szenarien verfuegbar</p>
|
||||
<p className="text-sm mt-1">Fuer diese Branche wurden noch keine Risiko-Szenarien definiert.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{scenarios.map((risk, idx) => {
|
||||
const likelihoodColor = LIKELIHOOD_COLORS[risk.likelihood] || 'bg-slate-400'
|
||||
const impactColor = IMPACT_COLORS[risk.impact] || 'bg-slate-400'
|
||||
const likelihoodLabel = PRIORITY_LABELS[risk.likelihood] || risk.likelihood
|
||||
const impactLabel = PRIORITY_LABELS[risk.impact] || risk.impact
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white border border-slate-200 rounded-lg p-5 hover:border-emerald-200 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<h4 className="font-semibold text-slate-900">{risk.name}</h4>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Likelihood badge */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${likelihoodColor}`} />
|
||||
<span className="text-xs text-slate-500">
|
||||
Wahrsch.: <span className="font-medium text-slate-700">{likelihoodLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-slate-300">|</span>
|
||||
{/* Impact badge */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${impactColor}`} />
|
||||
<span className="text-xs text-slate-500">
|
||||
Auswirkung: <span className="font-medium text-slate-700">{impactLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-slate-500 mt-2">{risk.description}</p>
|
||||
|
||||
{/* Mitigation */}
|
||||
<div className="mt-3 pt-3 border-t border-slate-100">
|
||||
<div className="flex items-start gap-2">
|
||||
<svg className="w-4 h-4 text-emerald-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider">Massnahme</p>
|
||||
<p className="text-sm text-slate-700 mt-0.5">{risk.mitigation}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: Detail Tabs + Content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderDetailContent = () => {
|
||||
if (!selectedDetail) return null
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex border-b border-slate-200 bg-slate-50">
|
||||
{DETAIL_TABS.map((tab) => {
|
||||
const isActive = activeTab === tab.key
|
||||
let count = 0
|
||||
if (tab.key === 'vvt') count = selectedDetail.vvt_templates?.length || 0
|
||||
if (tab.key === 'tom') count = selectedDetail.tom_recommendations?.length || 0
|
||||
if (tab.key === 'risks') count = selectedDetail.risk_scenarios?.length || 0
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors relative ${
|
||||
isActive
|
||||
? 'text-emerald-700 bg-white border-b-2 border-emerald-500'
|
||||
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{count > 0 && (
|
||||
<span
|
||||
className={`ml-1.5 inline-flex items-center justify-center px-1.5 py-0.5 rounded-full text-xs ${
|
||||
isActive
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'bg-slate-200 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">
|
||||
{activeTab === 'vvt' && renderVVTTab()}
|
||||
{activeTab === 'tom' && renderTOMTab()}
|
||||
{activeTab === 'risks' && renderRiskTab()}
|
||||
</div>
|
||||
|
||||
{/* Apply Button */}
|
||||
<div className="px-6 py-4 border-t border-slate-200 bg-slate-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-slate-500">
|
||||
Importiert alle Vorlagen, Empfehlungen und Szenarien in Ihr System.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleApplyPackage}
|
||||
disabled={applying}
|
||||
className={`inline-flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-semibold text-white transition-all ${
|
||||
applying
|
||||
? 'bg-emerald-400 cursor-not-allowed'
|
||||
: 'bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 shadow-sm hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
{applying ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Wird angewendet...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Branchenpaket anwenden
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: Toast
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderToast = () => {
|
||||
if (!toastMessage) return null
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-50 max-w-md animate-slide-up">
|
||||
<div className="bg-slate-900 text-white rounded-xl shadow-2xl px-5 py-4 flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-emerald-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-sm leading-relaxed">{toastMessage}</p>
|
||||
<button
|
||||
onClick={() => setToastMessage(null)}
|
||||
className="text-slate-400 hover:text-white flex-shrink-0 ml-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render: Empty state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderEmptyState = () => (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-12 text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-emerald-50 flex items-center justify-center text-3xl mx-auto mb-4">
|
||||
{'\uD83C\uDFED'}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900">Keine Branchenvorlagen verfuegbar</h3>
|
||||
<p className="text-slate-500 mt-2 max-w-md mx-auto">
|
||||
Es sind derzeit keine branchenspezifischen Compliance-Pakete im System hinterlegt.
|
||||
Bitte kontaktieren Sie den Administrator oder versuchen Sie es spaeter erneut.
|
||||
</p>
|
||||
<button
|
||||
onClick={loadIndustries}
|
||||
className="mt-4 px-4 py-2 text-sm font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 rounded-lg transition-colors"
|
||||
>
|
||||
Erneut laden
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Inline keyframe for toast animation */}
|
||||
<style>{`
|
||||
@keyframes slide-up {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-slide-up {
|
||||
animation: slide-up 0.3s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{renderHeader()}
|
||||
|
||||
{/* Error state */}
|
||||
{error && renderError(error, loadIndustries)}
|
||||
|
||||
{/* Main Content */}
|
||||
{loading ? (
|
||||
selectedSlug ? <DetailSkeleton /> : <GridSkeleton />
|
||||
) : selectedSlug ? (
|
||||
// Detail View
|
||||
<div className="space-y-6">
|
||||
{detailLoading ? (
|
||||
<DetailSkeleton />
|
||||
) : detailError ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleBackToGrid}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-emerald-600 transition-colors"
|
||||
>
|
||||
<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>
|
||||
{renderError(detailError, () => loadDetail(selectedSlug))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{renderDetailHeader()}
|
||||
{renderDetailContent()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : industries.length === 0 && !error ? (
|
||||
renderEmptyState()
|
||||
) : (
|
||||
renderGrid()
|
||||
)}
|
||||
|
||||
{/* Toast notification */}
|
||||
{renderToast()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user