dsfa/[id]/page.tsx (1893 LOC -> 350 LOC) split into 9 components: Section1-5Editor, SDMCoverageOverview, RAGSearchPanel, AddRiskModal, AddMitigationModal. Page is now a thin orchestrator. notfallplan/page.tsx (1890 LOC -> 435 LOC) split into 8 modules: types.ts, ConfigTab, IncidentsTab, TemplatesTab, ExercisesTab, Modals, ApiSections. All under the 500-line hard cap. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
351 lines
13 KiB
TypeScript
351 lines
13 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
import Link from 'next/link'
|
|
import { useParams, useRouter } from 'next/navigation'
|
|
import {
|
|
DSFA,
|
|
DSFA_SECTIONS,
|
|
DSFA_STATUS_LABELS,
|
|
DSFA_RISK_LEVEL_LABELS,
|
|
} from '@/lib/sdk/dsfa/types'
|
|
import {
|
|
getDSFA,
|
|
updateDSFASection,
|
|
updateDSFA,
|
|
} from '@/lib/sdk/dsfa/api'
|
|
import {
|
|
DSFASidebar,
|
|
ThresholdAnalysisSection,
|
|
StakeholderConsultationSection,
|
|
Art36Warning,
|
|
ReviewScheduleSection,
|
|
AIUseCaseSection,
|
|
} from '@/components/sdk/dsfa'
|
|
import {
|
|
Section1Editor,
|
|
Section2Editor,
|
|
Section3Editor,
|
|
Section4Editor,
|
|
Section5Editor,
|
|
SDMCoverageOverview,
|
|
} from './_components'
|
|
|
|
// =============================================================================
|
|
// MAIN PAGE
|
|
// =============================================================================
|
|
|
|
export default function DSFAEditorPage() {
|
|
const params = useParams()
|
|
const router = useRouter()
|
|
const dsfaId = params.id as string
|
|
|
|
const [dsfa, setDSFA] = useState<DSFA | null>(null)
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
const [activeSection, setActiveSection] = useState(0)
|
|
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
|
|
|
// Load DSFA data
|
|
useEffect(() => {
|
|
const loadDSFA = async () => {
|
|
setIsLoading(true)
|
|
try {
|
|
const data = await getDSFA(dsfaId)
|
|
setDSFA(data)
|
|
} catch (error) {
|
|
console.error('Failed to load DSFA:', error)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
loadDSFA()
|
|
}, [dsfaId])
|
|
|
|
const handleSectionUpdate = useCallback(async (sectionNumber: number, data: Record<string, unknown>) => {
|
|
if (!dsfa) return
|
|
|
|
setIsSaving(true)
|
|
setSaveMessage(null)
|
|
|
|
try {
|
|
const updated = await updateDSFASection(dsfaId, sectionNumber, data)
|
|
setDSFA(updated)
|
|
setSaveMessage({ type: 'success', text: 'Abschnitt gespeichert' })
|
|
setTimeout(() => setSaveMessage(null), 3000)
|
|
} catch (error) {
|
|
console.error('Failed to update section:', error)
|
|
setSaveMessage({ type: 'error', text: 'Fehler beim Speichern' })
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}, [dsfa, dsfaId])
|
|
|
|
const handleGenericUpdate = useCallback(async (data: Record<string, unknown>) => {
|
|
if (!dsfa) return
|
|
|
|
setIsSaving(true)
|
|
setSaveMessage(null)
|
|
|
|
try {
|
|
const updated = await updateDSFA(dsfaId, data as Partial<DSFA>)
|
|
setDSFA(updated)
|
|
setSaveMessage({ type: 'success', text: 'Abschnitt gespeichert' })
|
|
setTimeout(() => setSaveMessage(null), 3000)
|
|
} catch (error) {
|
|
console.error('Failed to update DSFA:', error)
|
|
setSaveMessage({ type: 'error', text: 'Fehler beim Speichern' })
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}, [dsfa, dsfaId])
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12">
|
|
<svg className="animate-spin w-8 h-8 text-purple-600" 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>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!dsfa) {
|
|
return (
|
|
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
|
<div className="w-16 h-16 mx-auto bg-red-100 rounded-full flex items-center justify-center mb-4">
|
|
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-900">DSFA nicht gefunden</h3>
|
|
<p className="mt-2 text-gray-500">
|
|
Die angeforderte DSFA existiert nicht oder wurde geloescht.
|
|
</p>
|
|
<Link
|
|
href="/sdk/dsfa"
|
|
className="mt-4 inline-flex items-center gap-2 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
|
</svg>
|
|
Zurueck zur Uebersicht
|
|
</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const sectionConfig = DSFA_SECTIONS.find(s => s.number === activeSection)
|
|
const statusColors: Record<string, string> = {
|
|
draft: 'bg-gray-100 text-gray-600',
|
|
in_review: 'bg-yellow-100 text-yellow-700',
|
|
approved: 'bg-green-100 text-green-700',
|
|
rejected: 'bg-red-100 text-red-700',
|
|
needs_update: 'bg-orange-100 text-orange-700',
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Link
|
|
href="/sdk/dsfa"
|
|
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
|
</svg>
|
|
</Link>
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[dsfa.status]}`}>
|
|
{DSFA_STATUS_LABELS[dsfa.status]}
|
|
</span>
|
|
<span className={`px-2 py-1 text-xs rounded-full ${
|
|
dsfa.overall_risk_level === 'low' ? 'bg-green-100 text-green-700' :
|
|
dsfa.overall_risk_level === 'medium' ? 'bg-yellow-100 text-yellow-700' :
|
|
dsfa.overall_risk_level === 'high' ? 'bg-orange-100 text-orange-700' :
|
|
'bg-red-100 text-red-700'
|
|
}`}>
|
|
Risiko: {DSFA_RISK_LEVEL_LABELS[dsfa.overall_risk_level]}
|
|
</span>
|
|
{dsfa.assessment_id && (
|
|
<span className="px-2 py-1 text-xs rounded-full bg-purple-100 text-purple-700">
|
|
UCCA-verknuepft
|
|
</span>
|
|
)}
|
|
</div>
|
|
<h1 className="text-2xl font-bold text-gray-900 mt-1">{dsfa.name}</h1>
|
|
{dsfa.description && (
|
|
<p className="text-sm text-gray-500 mt-1">{dsfa.description}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Save Message */}
|
|
{saveMessage && (
|
|
<div className={`px-4 py-2 rounded-lg text-sm ${
|
|
saveMessage.type === 'success'
|
|
? 'bg-green-100 text-green-700'
|
|
: 'bg-red-100 text-red-700'
|
|
}`}>
|
|
{saveMessage.text}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Main Content: Sidebar + Content Layout */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
|
{/* Left Column - Sidebar (1/4) */}
|
|
<div className="lg:col-span-1">
|
|
<DSFASidebar
|
|
dsfa={dsfa}
|
|
activeSection={activeSection}
|
|
onSectionChange={setActiveSection}
|
|
/>
|
|
</div>
|
|
|
|
{/* Right Column - Content (3/4) */}
|
|
<div className="lg:col-span-3 space-y-6">
|
|
{/* Section Content Card */}
|
|
<div className="bg-white rounded-xl border border-gray-200">
|
|
{/* Section Header */}
|
|
{sectionConfig && (
|
|
<div className="p-4 bg-gray-50 border-b border-gray-200 rounded-t-xl">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-900">
|
|
{sectionConfig.number}. {sectionConfig.titleDE}
|
|
</h2>
|
|
<p className="text-sm text-gray-500 mt-1">{sectionConfig.description}</p>
|
|
</div>
|
|
<span className="px-2 py-1 bg-blue-50 text-blue-600 rounded text-xs">
|
|
{sectionConfig.gdprRef}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Section Content */}
|
|
<div className="p-6">
|
|
{activeSection === 0 && (
|
|
<ThresholdAnalysisSection dsfa={dsfa} onUpdate={handleGenericUpdate} isSubmitting={isSaving} />
|
|
)}
|
|
{activeSection === 1 && (
|
|
<Section1Editor dsfa={dsfa} onUpdate={(data) => handleSectionUpdate(1, data)} isSubmitting={isSaving} />
|
|
)}
|
|
{activeSection === 2 && (
|
|
<Section2Editor dsfa={dsfa} onUpdate={(data) => handleSectionUpdate(2, data)} isSubmitting={isSaving} />
|
|
)}
|
|
{activeSection === 3 && (
|
|
<Section3Editor dsfa={dsfa} onUpdate={(data) => handleSectionUpdate(3, data)} isSubmitting={isSaving} />
|
|
)}
|
|
{activeSection === 4 && (
|
|
<Section4Editor dsfa={dsfa} onUpdate={(data) => handleSectionUpdate(4, data)} isSubmitting={isSaving} />
|
|
)}
|
|
|
|
{/* SDM Coverage Overview (shown in Section 3 and 4) */}
|
|
{(activeSection === 3 || activeSection === 4) && (dsfa.risks?.length > 0 || dsfa.mitigations?.length > 0) && (
|
|
<SDMCoverageOverview dsfa={dsfa} />
|
|
)}
|
|
|
|
{activeSection === 5 && (
|
|
<StakeholderConsultationSection dsfa={dsfa} onUpdate={handleGenericUpdate} isSubmitting={isSaving} />
|
|
)}
|
|
{activeSection === 6 && (
|
|
<div className="space-y-6">
|
|
<Section5Editor dsfa={dsfa} onUpdate={(data) => handleSectionUpdate(5, data)} isSubmitting={isSaving} />
|
|
<div className="border-t border-gray-200 pt-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Art. 36 Behoerdenkonsultation</h3>
|
|
<Art36Warning dsfa={dsfa} onUpdate={handleGenericUpdate} isSubmitting={isSaving} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
{activeSection === 7 && (
|
|
<ReviewScheduleSection dsfa={dsfa} onUpdate={handleGenericUpdate} isSubmitting={isSaving} />
|
|
)}
|
|
{activeSection === 8 && (
|
|
<AIUseCaseSection dsfa={dsfa} onUpdate={handleGenericUpdate} isSubmitting={isSaving} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bottom Actions Row */}
|
|
<BottomActions
|
|
activeSection={activeSection}
|
|
setActiveSection={setActiveSection}
|
|
dsfa={dsfa}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function BottomActions({
|
|
activeSection,
|
|
setActiveSection,
|
|
dsfa,
|
|
}: {
|
|
activeSection: number
|
|
setActiveSection: (n: number) => void
|
|
dsfa: DSFA
|
|
}) {
|
|
return (
|
|
<div className="flex items-center justify-between bg-white rounded-xl border border-gray-200 p-4">
|
|
{/* Navigation */}
|
|
<div className="flex items-center gap-2">
|
|
{activeSection > 0 && (
|
|
<button
|
|
onClick={() => setActiveSection(activeSection - 1)}
|
|
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
|
>
|
|
<svg className="w-5 h-5" 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
|
|
</button>
|
|
)}
|
|
{activeSection < 8 && (
|
|
<button
|
|
onClick={() => setActiveSection(activeSection + 1)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
|
>
|
|
Weiter
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Quick Info */}
|
|
<div className="flex items-center gap-4 text-sm text-gray-500">
|
|
<span>Risiken: {(dsfa.risks || []).length}</span>
|
|
<span>Massnahmen: {(dsfa.mitigations || []).length}</span>
|
|
<span>KI-Module: {(dsfa.ai_use_case_modules || []).length}</span>
|
|
<span>Version: {dsfa.version || 1}</span>
|
|
</div>
|
|
|
|
{/* Export */}
|
|
<div className="flex items-center gap-2">
|
|
<button className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors">
|
|
<svg className="w-5 h-5 text-red-500" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z"/>
|
|
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/>
|
|
</svg>
|
|
PDF
|
|
</button>
|
|
<button className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors">
|
|
<svg className="w-5 h-5 text-blue-500" 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>
|
|
JSON
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|