refactor(admin): split industry-templates page.tsx into colocated components
Split 879-LOC page.tsx into 187 LOC with 11 colocated components, _types.ts and _constants.ts for the industry templates module. Behavior preserved. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
|||||||
|
import type { IndustryTemplate, DetailTab } from '../_types'
|
||||||
|
import { DETAIL_TABS } from '../_constants'
|
||||||
|
import { VVTTab } from './VVTTab'
|
||||||
|
import { TOMTab } from './TOMTab'
|
||||||
|
import { RiskTab } from './RiskTab'
|
||||||
|
|
||||||
|
export function DetailContent({
|
||||||
|
detail,
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
applying,
|
||||||
|
onApply,
|
||||||
|
}: {
|
||||||
|
detail: IndustryTemplate
|
||||||
|
activeTab: DetailTab
|
||||||
|
onTabChange: (tab: DetailTab) => void
|
||||||
|
applying: boolean
|
||||||
|
onApply: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
<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 = detail.vvt_templates?.length || 0
|
||||||
|
if (tab.key === 'tom') count = detail.tom_recommendations?.length || 0
|
||||||
|
if (tab.key === 'risks') count = detail.risk_scenarios?.length || 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => onTabChange(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>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{activeTab === 'vvt' && <VVTTab templates={detail.vvt_templates || []} />}
|
||||||
|
{activeTab === 'tom' && <TOMTab recommendations={detail.tom_recommendations || []} />}
|
||||||
|
{activeTab === 'risks' && <RiskTab scenarios={detail.risk_scenarios || []} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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={onApply}
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { IndustryTemplate } from '../_types'
|
||||||
|
|
||||||
|
export function DetailHeader({
|
||||||
|
detail,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
detail: IndustryTemplate
|
||||||
|
onBack: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
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">
|
||||||
|
{detail.icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-xl font-bold text-slate-900">{detail.name}</h2>
|
||||||
|
<p className="text-slate-500 mt-1">{detail.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detail.regulations && detail.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">
|
||||||
|
{detail.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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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">
|
||||||
|
{detail.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">
|
||||||
|
{detail.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">
|
||||||
|
{detail.risk_scenarios?.length || 0}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">Risiko-Szenarien</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export function EmptyState({ onReload }: { onReload: () => void }) {
|
||||||
|
return (
|
||||||
|
<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={onReload}
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export function ErrorPanel({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { IndustrySummary } from '../_types'
|
||||||
|
|
||||||
|
export function IndustryGrid({
|
||||||
|
industries,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
industries: IndustrySummary[]
|
||||||
|
onSelect: (slug: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{industries.map((industry) => (
|
||||||
|
<button
|
||||||
|
key={industry.slug}
|
||||||
|
onClick={() => onSelect(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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export function PageHeader() {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { RiskScenario } from '../_types'
|
||||||
|
import { LIKELIHOOD_COLORS, IMPACT_COLORS, PRIORITY_LABELS } from '../_constants'
|
||||||
|
|
||||||
|
export function RiskTab({ scenarios }: { scenarios: RiskScenario[] }) {
|
||||||
|
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">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
export 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { TOMRecommendation } from '../_types'
|
||||||
|
import { PRIORITY_COLORS, PRIORITY_LABELS, TOM_CATEGORY_ICONS } from '../_constants'
|
||||||
|
|
||||||
|
export function TOMTab({ recommendations }: { recommendations: TOMRecommendation[] }) {
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export function Toast({ message, onDismiss }: { message: string; onDismiss: () => void }) {
|
||||||
|
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">{message}</p>
|
||||||
|
<button
|
||||||
|
onClick={onDismiss}
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { VVTTemplate } from '../_types'
|
||||||
|
|
||||||
|
export function VVTTab({ templates }: { templates: VVTTemplate[] }) {
|
||||||
|
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">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
admin-compliance/app/sdk/industry-templates/_constants.ts
Normal file
55
admin-compliance/app/sdk/industry-templates/_constants.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { DetailTab } from './_types'
|
||||||
|
|
||||||
|
export const DETAIL_TABS: { key: DetailTab; label: string }[] = [
|
||||||
|
{ key: 'vvt', label: 'VVT-Vorlagen' },
|
||||||
|
{ key: 'tom', label: 'TOM-Empfehlungen' },
|
||||||
|
{ key: 'risks', label: 'Risiko-Szenarien' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export 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' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRIORITY_LABELS: Record<string, string> = {
|
||||||
|
critical: 'Kritisch',
|
||||||
|
high: 'Hoch',
|
||||||
|
medium: 'Mittel',
|
||||||
|
low: 'Niedrig',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LIKELIHOOD_COLORS: Record<string, string> = {
|
||||||
|
low: 'bg-green-500',
|
||||||
|
medium: 'bg-yellow-500',
|
||||||
|
high: 'bg-orange-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IMPACT_COLORS: Record<string, string> = {
|
||||||
|
low: 'bg-green-500',
|
||||||
|
medium: 'bg-yellow-500',
|
||||||
|
high: 'bg-orange-500',
|
||||||
|
critical: 'bg-red-600',
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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',
|
||||||
|
}
|
||||||
45
admin-compliance/app/sdk/industry-templates/_types.ts
Normal file
45
admin-compliance/app/sdk/industry-templates/_types.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
export interface IndustrySummary {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
regulation_count: number
|
||||||
|
template_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IndustryTemplate {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
icon: string
|
||||||
|
regulations: string[]
|
||||||
|
vvt_templates: VVTTemplate[]
|
||||||
|
tom_recommendations: TOMRecommendation[]
|
||||||
|
risk_scenarios: RiskScenario[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VVTTemplate {
|
||||||
|
name: string
|
||||||
|
purpose: string
|
||||||
|
legal_basis: string
|
||||||
|
data_categories: string[]
|
||||||
|
data_subjects: string[]
|
||||||
|
retention_period: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TOMRecommendation {
|
||||||
|
category: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
priority: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiskScenario {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
likelihood: string
|
||||||
|
impact: string
|
||||||
|
mitigation: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DetailTab = 'vvt' | 'tom' | 'risks'
|
||||||
@@ -10,181 +10,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import type { IndustrySummary, IndustryTemplate, DetailTab } from './_types'
|
||||||
// =============================================================================
|
import { GridSkeleton, DetailSkeleton } from './_components/Skeletons'
|
||||||
// TYPES
|
import { PageHeader } from './_components/PageHeader'
|
||||||
// =============================================================================
|
import { ErrorPanel } from './_components/ErrorPanel'
|
||||||
|
import { IndustryGrid } from './_components/IndustryGrid'
|
||||||
interface IndustrySummary {
|
import { DetailHeader } from './_components/DetailHeader'
|
||||||
slug: string
|
import { DetailContent } from './_components/DetailContent'
|
||||||
name: string
|
import { Toast } from './_components/Toast'
|
||||||
description: string
|
import { EmptyState } from './_components/EmptyState'
|
||||||
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() {
|
export default function IndustryTemplatesPage() {
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// State
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const [industries, setIndustries] = useState<IndustrySummary[]>([])
|
const [industries, setIndustries] = useState<IndustrySummary[]>([])
|
||||||
const [selectedDetail, setSelectedDetail] = useState<IndustryTemplate | null>(null)
|
const [selectedDetail, setSelectedDetail] = useState<IndustryTemplate | null>(null)
|
||||||
const [selectedSlug, setSelectedSlug] = useState<string | null>(null)
|
const [selectedSlug, setSelectedSlug] = useState<string | null>(null)
|
||||||
@@ -196,10 +32,6 @@ export default function IndustryTemplatesPage() {
|
|||||||
const [applying, setApplying] = useState(false)
|
const [applying, setApplying] = useState(false)
|
||||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Data fetching
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const loadIndustries = useCallback(async () => {
|
const loadIndustries = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -237,7 +69,6 @@ export default function IndustryTemplatesPage() {
|
|||||||
|
|
||||||
const detail: IndustryTemplate = await detailRes.json()
|
const detail: IndustryTemplate = await detailRes.json()
|
||||||
|
|
||||||
// Merge sub-resources if the detail endpoint did not include them
|
|
||||||
if (vvtRes.ok) {
|
if (vvtRes.ok) {
|
||||||
const vvtData = await vvtRes.json()
|
const vvtData = await vvtRes.json()
|
||||||
detail.vvt_templates = vvtData.vvt_templates || vvtData.templates || vvtData || []
|
detail.vvt_templates = vvtData.vvt_templates || vvtData.templates || vvtData || []
|
||||||
@@ -264,10 +95,6 @@ export default function IndustryTemplatesPage() {
|
|||||||
loadIndustries()
|
loadIndustries()
|
||||||
}, [loadIndustries])
|
}, [loadIndustries])
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Handlers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const handleBackToGrid = useCallback(() => {
|
const handleBackToGrid = useCallback(() => {
|
||||||
setSelectedSlug(null)
|
setSelectedSlug(null)
|
||||||
setSelectedDetail(null)
|
setSelectedDetail(null)
|
||||||
@@ -278,7 +105,6 @@ export default function IndustryTemplatesPage() {
|
|||||||
if (!selectedDetail) return
|
if (!selectedDetail) return
|
||||||
setApplying(true)
|
setApplying(true)
|
||||||
try {
|
try {
|
||||||
// Placeholder: In production this would POST to an import endpoint
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||||
setToastMessage(
|
setToastMessage(
|
||||||
`Branchenpaket "${selectedDetail.name}" wurde erfolgreich angewendet. ` +
|
`Branchenpaket "${selectedDetail.name}" wurde erfolgreich angewendet. ` +
|
||||||
@@ -293,536 +119,14 @@ export default function IndustryTemplatesPage() {
|
|||||||
}
|
}
|
||||||
}, [selectedDetail])
|
}, [selectedDetail])
|
||||||
|
|
||||||
// Auto-dismiss toast
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!toastMessage) return
|
if (!toastMessage) return
|
||||||
const timer = setTimeout(() => setToastMessage(null), 6000)
|
const timer = setTimeout(() => setToastMessage(null), 6000)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [toastMessage])
|
}, [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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Inline keyframe for toast animation */}
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes slide-up {
|
@keyframes slide-up {
|
||||||
from { opacity: 0; transform: translateY(16px); }
|
from { opacity: 0; transform: translateY(16px); }
|
||||||
@@ -833,16 +137,13 @@ export default function IndustryTemplatesPage() {
|
|||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
{renderHeader()}
|
<PageHeader />
|
||||||
|
|
||||||
{/* Error state */}
|
{error && <ErrorPanel message={error} onRetry={loadIndustries} />}
|
||||||
{error && renderError(error, loadIndustries)}
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
selectedSlug ? <DetailSkeleton /> : <GridSkeleton />
|
selectedSlug ? <DetailSkeleton /> : <GridSkeleton />
|
||||||
) : selectedSlug ? (
|
) : selectedSlug ? (
|
||||||
// Detail View
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{detailLoading ? (
|
{detailLoading ? (
|
||||||
<DetailSkeleton />
|
<DetailSkeleton />
|
||||||
@@ -857,23 +158,30 @@ export default function IndustryTemplatesPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
Zurueck zur Uebersicht
|
Zurueck zur Uebersicht
|
||||||
</button>
|
</button>
|
||||||
{renderError(detailError, () => loadDetail(selectedSlug))}
|
<ErrorPanel message={detailError} onRetry={() => loadDetail(selectedSlug)} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : selectedDetail ? (
|
||||||
<>
|
<>
|
||||||
{renderDetailHeader()}
|
<DetailHeader detail={selectedDetail} onBack={handleBackToGrid} />
|
||||||
{renderDetailContent()}
|
<DetailContent
|
||||||
|
detail={selectedDetail}
|
||||||
|
activeTab={activeTab}
|
||||||
|
onTabChange={setActiveTab}
|
||||||
|
applying={applying}
|
||||||
|
onApply={handleApplyPackage}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : industries.length === 0 && !error ? (
|
) : industries.length === 0 && !error ? (
|
||||||
renderEmptyState()
|
<EmptyState onReload={loadIndustries} />
|
||||||
) : (
|
) : (
|
||||||
renderGrid()
|
<IndustryGrid industries={industries} onSelect={loadDetail} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Toast notification */}
|
{toastMessage && (
|
||||||
{renderToast()}
|
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user