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>
436 lines
15 KiB
TypeScript
436 lines
15 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* Notfallplan & Breach Response (Art. 33/34 DSGVO)
|
|
*
|
|
* 4 Tabs:
|
|
* 1. Notfallplan-Konfiguration (Meldewege, Zustaendigkeiten, Eskalation)
|
|
* 2. Incident-Register (Art. 33 Abs. 5)
|
|
* 3. Melde-Templates (Art. 33 + Art. 34)
|
|
* 4. Uebungen & Tests
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { useSDK } from '@/lib/sdk'
|
|
import StepHeader from '@/components/sdk/StepHeader/StepHeader'
|
|
import {
|
|
ConfigTab,
|
|
IncidentsTab,
|
|
TemplatesTab,
|
|
ExercisesTab,
|
|
ContactModal,
|
|
ExerciseModal,
|
|
ScenarioModal,
|
|
ApiContactsSection,
|
|
ApiScenariosSection,
|
|
ApiExercisesSection,
|
|
DEFAULT_CONFIG,
|
|
DEFAULT_TEMPLATES,
|
|
NOTFALLPLAN_API,
|
|
loadFromStorage,
|
|
saveToStorage,
|
|
apiToIncident,
|
|
} from './_components'
|
|
import type {
|
|
Tab,
|
|
NotfallplanConfig,
|
|
Incident,
|
|
IncidentStatus,
|
|
MeldeTemplate,
|
|
Exercise,
|
|
ApiContact,
|
|
ApiScenario,
|
|
ApiExercise,
|
|
} from './_components'
|
|
|
|
export default function NotfallplanPage() {
|
|
const { state } = useSDK()
|
|
const [activeTab, setActiveTab] = useState<Tab>('config')
|
|
const [config, setConfig] = useState<NotfallplanConfig>(DEFAULT_CONFIG)
|
|
const [incidents, setIncidents] = useState<Incident[]>([])
|
|
const [templates, setTemplates] = useState<MeldeTemplate[]>(DEFAULT_TEMPLATES)
|
|
const [exercises, setExercises] = useState<Exercise[]>([])
|
|
const [showAddIncident, setShowAddIncident] = useState(false)
|
|
const [showAddExercise, setShowAddExercise] = useState(false)
|
|
const [showExerciseModal, setShowExerciseModal] = useState(false)
|
|
const [saved, setSaved] = useState(false)
|
|
|
|
// API-backed state
|
|
const [apiContacts, setApiContacts] = useState<ApiContact[]>([])
|
|
const [apiScenarios, setApiScenarios] = useState<ApiScenario[]>([])
|
|
const [apiExercises, setApiExercises] = useState<ApiExercise[]>([])
|
|
const [apiLoading, setApiLoading] = useState(false)
|
|
const [showContactModal, setShowContactModal] = useState(false)
|
|
const [showScenarioModal, setShowScenarioModal] = useState(false)
|
|
const [newContact, setNewContact] = useState({ name: '', role: '', email: '', phone: '', is_primary: false, available_24h: false })
|
|
const [newScenario, setNewScenario] = useState({ title: '', category: 'data_breach', severity: 'medium', description: '' })
|
|
const [newExercise, setNewExercise] = useState({ title: '', exercise_type: 'tabletop', exercise_date: '', notes: '' })
|
|
const [savingContact, setSavingContact] = useState(false)
|
|
const [savingScenario, setSavingScenario] = useState(false)
|
|
const [savingExercise, setSavingExercise] = useState(false)
|
|
|
|
useEffect(() => {
|
|
const data = loadFromStorage()
|
|
setConfig(data.config)
|
|
setExercises(data.exercises)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
loadApiData()
|
|
}, [])
|
|
|
|
async function loadApiData() {
|
|
setApiLoading(true)
|
|
try {
|
|
const [contactsRes, scenariosRes, exercisesRes, incidentsRes, templatesRes] = await Promise.all([
|
|
fetch(`${NOTFALLPLAN_API}/contacts`),
|
|
fetch(`${NOTFALLPLAN_API}/scenarios`),
|
|
fetch(`${NOTFALLPLAN_API}/exercises`),
|
|
fetch(`${NOTFALLPLAN_API}/incidents`),
|
|
fetch(`${NOTFALLPLAN_API}/templates`),
|
|
])
|
|
if (contactsRes.ok) {
|
|
const data = await contactsRes.json()
|
|
setApiContacts(Array.isArray(data) ? data : [])
|
|
}
|
|
if (scenariosRes.ok) {
|
|
const data = await scenariosRes.json()
|
|
setApiScenarios(Array.isArray(data) ? data : [])
|
|
}
|
|
if (exercisesRes.ok) {
|
|
const data = await exercisesRes.json()
|
|
setApiExercises(Array.isArray(data) ? data : [])
|
|
}
|
|
if (incidentsRes.ok) {
|
|
const data = await incidentsRes.json()
|
|
setIncidents(Array.isArray(data) ? data.map(apiToIncident) : [])
|
|
}
|
|
if (templatesRes.ok) {
|
|
const data = await templatesRes.json()
|
|
setTemplates(Array.isArray(data) && data.length > 0 ? data : DEFAULT_TEMPLATES)
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load Notfallplan API data:', err)
|
|
} finally {
|
|
setApiLoading(false)
|
|
}
|
|
}
|
|
|
|
async function apiAddIncident(newInc: Partial<Incident>) {
|
|
try {
|
|
const res = await fetch(`${NOTFALLPLAN_API}/incidents`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
title: newInc.title,
|
|
description: newInc.description,
|
|
severity: newInc.severity || 'medium',
|
|
estimated_affected_persons: newInc.estimatedAffectedPersons || 0,
|
|
art34_required: newInc.art34Required || false,
|
|
art34_justification: newInc.art34Justification || '',
|
|
}),
|
|
})
|
|
if (res.ok) {
|
|
const created = await res.json()
|
|
setIncidents(prev => [apiToIncident(created), ...prev])
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to create incident:', err)
|
|
}
|
|
}
|
|
|
|
async function apiUpdateIncidentStatus(id: string, status: IncidentStatus) {
|
|
try {
|
|
const body: Record<string, unknown> = { status }
|
|
if (status === 'reported') body.reported_to_authority_at = new Date().toISOString()
|
|
if (status === 'closed') { body.closed_at = new Date().toISOString(); body.closed_by = 'Admin' }
|
|
const res = await fetch(`${NOTFALLPLAN_API}/incidents/${id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
})
|
|
if (res.ok) {
|
|
const updated = await res.json()
|
|
setIncidents(prev => prev.map(inc => inc.id === id ? apiToIncident(updated) : inc))
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to update incident status:', err)
|
|
}
|
|
}
|
|
|
|
async function apiDeleteIncident(id: string) {
|
|
try {
|
|
const res = await fetch(`${NOTFALLPLAN_API}/incidents/${id}`, { method: 'DELETE' })
|
|
if (res.ok || res.status === 204) setIncidents(prev => prev.filter(inc => inc.id !== id))
|
|
} catch (err) {
|
|
console.error('Failed to delete incident:', err)
|
|
}
|
|
}
|
|
|
|
async function apiSaveTemplate(template: MeldeTemplate) {
|
|
try {
|
|
const isNew = !template.id.startsWith('tpl-')
|
|
const method = isNew || template.id.length < 10 ? 'POST' : 'PUT'
|
|
const url = method === 'POST'
|
|
? `${NOTFALLPLAN_API}/templates`
|
|
: `${NOTFALLPLAN_API}/templates/${template.id}`
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ type: template.type, title: template.title, content: template.content }),
|
|
})
|
|
if (res.ok) {
|
|
const saved = await res.json()
|
|
setTemplates(prev => {
|
|
const existing = prev.find(t => t.id === template.id)
|
|
if (existing) return prev.map(t => t.id === template.id ? { ...t, ...saved } : t)
|
|
return [...prev, saved]
|
|
})
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to save template:', err)
|
|
}
|
|
}
|
|
|
|
async function handleCreateContact() {
|
|
if (!newContact.name) return
|
|
setSavingContact(true)
|
|
try {
|
|
const res = await fetch(`${NOTFALLPLAN_API}/contacts`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(newContact),
|
|
})
|
|
if (res.ok) {
|
|
const created = await res.json()
|
|
setApiContacts(prev => [created, ...prev])
|
|
setShowContactModal(false)
|
|
setNewContact({ name: '', role: '', email: '', phone: '', is_primary: false, available_24h: false })
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to create contact:', err)
|
|
} finally {
|
|
setSavingContact(false)
|
|
}
|
|
}
|
|
|
|
async function handleDeleteContact(id: string) {
|
|
try {
|
|
const res = await fetch(`${NOTFALLPLAN_API}/contacts/${id}`, { method: 'DELETE' })
|
|
if (res.ok) setApiContacts(prev => prev.filter(c => c.id !== id))
|
|
} catch (err) {
|
|
console.error('Failed to delete contact:', err)
|
|
}
|
|
}
|
|
|
|
async function handleCreateScenario() {
|
|
if (!newScenario.title) return
|
|
setSavingScenario(true)
|
|
try {
|
|
const res = await fetch(`${NOTFALLPLAN_API}/scenarios`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(newScenario),
|
|
})
|
|
if (res.ok) {
|
|
const created = await res.json()
|
|
setApiScenarios(prev => [created, ...prev])
|
|
setShowScenarioModal(false)
|
|
setNewScenario({ title: '', category: 'data_breach', severity: 'medium', description: '' })
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to create scenario:', err)
|
|
} finally {
|
|
setSavingScenario(false)
|
|
}
|
|
}
|
|
|
|
async function handleDeleteScenario(id: string) {
|
|
try {
|
|
const res = await fetch(`${NOTFALLPLAN_API}/scenarios/${id}`, { method: 'DELETE' })
|
|
if (res.ok) setApiScenarios(prev => prev.filter(s => s.id !== id))
|
|
} catch (err) {
|
|
console.error('Failed to delete scenario:', err)
|
|
}
|
|
}
|
|
|
|
async function handleCreateExercise() {
|
|
if (!newExercise.title.trim()) return
|
|
setSavingExercise(true)
|
|
try {
|
|
const res = await fetch(`${NOTFALLPLAN_API}/exercises`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
title: newExercise.title.trim(),
|
|
exercise_type: newExercise.exercise_type,
|
|
exercise_date: newExercise.exercise_date || null,
|
|
notes: newExercise.notes.trim() || null,
|
|
}),
|
|
})
|
|
if (res.ok) {
|
|
setShowExerciseModal(false)
|
|
setNewExercise({ title: '', exercise_type: 'tabletop', exercise_date: '', notes: '' })
|
|
loadApiData()
|
|
}
|
|
} catch (e) {
|
|
console.error('Uebung erstellen fehlgeschlagen:', e)
|
|
} finally {
|
|
setSavingExercise(false)
|
|
}
|
|
}
|
|
|
|
async function handleDeleteExercise(id: string) {
|
|
try {
|
|
const res = await fetch(`${NOTFALLPLAN_API}/exercises/${id}`, { method: 'DELETE' })
|
|
if (res.ok) setApiExercises(prev => prev.filter(e => e.id !== id))
|
|
} catch (e) {
|
|
console.error('Loeschen fehlgeschlagen:', e)
|
|
}
|
|
}
|
|
|
|
const handleSave = useCallback(() => {
|
|
saveToStorage({ config, incidents: [], templates: DEFAULT_TEMPLATES, exercises })
|
|
setSaved(true)
|
|
setTimeout(() => setSaved(false), 2000)
|
|
}, [config, exercises])
|
|
|
|
const tabs: { id: Tab; label: string; count?: number }[] = [
|
|
{ id: 'config', label: 'Notfallplan' },
|
|
{ id: 'incidents', label: 'Incident-Register', count: incidents.filter(i => i.status !== 'closed').length || undefined },
|
|
{ id: 'templates', label: 'Melde-Templates' },
|
|
{ id: 'exercises', label: 'Uebungen & Tests', count: exercises.filter(e => !e.completedDate).length || undefined },
|
|
]
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<StepHeader stepId="notfallplan" />
|
|
|
|
{/* API Stats Banner */}
|
|
{!apiLoading && (apiContacts.length > 0 || apiScenarios.length > 0) && (
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg px-4 py-3 flex items-center gap-6 text-sm">
|
|
<span className="font-medium text-blue-900">Notfallplan-Datenbank:</span>
|
|
<span className="text-blue-700">{apiContacts.length} Kontakte</span>
|
|
<span className="text-blue-700">{apiScenarios.length} Szenarien</span>
|
|
<span className="text-blue-700">{apiExercises.length} Uebungen</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tab Navigation */}
|
|
<div className="border-b border-gray-200">
|
|
<nav className="-mb-px flex space-x-8">
|
|
{tabs.map(tab => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm ${
|
|
activeTab === tab.id
|
|
? 'border-blue-500 text-blue-600'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
{tab.count && (
|
|
<span className="ml-2 bg-red-100 text-red-800 text-xs font-medium px-2 py-0.5 rounded-full">
|
|
{tab.count}
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Save Button */}
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={handleSave}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
|
saved ? 'bg-green-600 text-white' : 'bg-blue-600 text-white hover:bg-blue-700'
|
|
}`}
|
|
>
|
|
{saved ? 'Gespeichert!' : 'Speichern'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tab Content */}
|
|
{activeTab === 'config' && (
|
|
<>
|
|
<ApiContactsSection
|
|
apiContacts={apiContacts}
|
|
apiLoading={apiLoading}
|
|
onAdd={() => setShowContactModal(true)}
|
|
onDelete={handleDeleteContact}
|
|
/>
|
|
<ApiScenariosSection
|
|
apiScenarios={apiScenarios}
|
|
apiLoading={apiLoading}
|
|
onAdd={() => setShowScenarioModal(true)}
|
|
onDelete={handleDeleteScenario}
|
|
/>
|
|
<ConfigTab config={config} setConfig={setConfig} />
|
|
</>
|
|
)}
|
|
{activeTab === 'incidents' && (
|
|
<IncidentsTab
|
|
incidents={incidents}
|
|
setIncidents={setIncidents}
|
|
showAdd={showAddIncident}
|
|
setShowAdd={setShowAddIncident}
|
|
onAdd={apiAddIncident}
|
|
onStatusChange={apiUpdateIncidentStatus}
|
|
onDelete={apiDeleteIncident}
|
|
/>
|
|
)}
|
|
{activeTab === 'templates' && (
|
|
<TemplatesTab templates={templates} setTemplates={setTemplates} onSave={apiSaveTemplate} />
|
|
)}
|
|
{activeTab === 'exercises' && (
|
|
<>
|
|
<ApiExercisesSection
|
|
apiExercises={apiExercises}
|
|
apiLoading={apiLoading}
|
|
onAdd={() => setShowExerciseModal(true)}
|
|
onDelete={handleDeleteExercise}
|
|
/>
|
|
<ExercisesTab
|
|
exercises={exercises}
|
|
setExercises={setExercises}
|
|
showAdd={showAddExercise}
|
|
setShowAdd={setShowAddExercise}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* Modals */}
|
|
{showContactModal && (
|
|
<ContactModal
|
|
newContact={newContact}
|
|
setNewContact={setNewContact}
|
|
onClose={() => setShowContactModal(false)}
|
|
onCreate={handleCreateContact}
|
|
saving={savingContact}
|
|
/>
|
|
)}
|
|
{showExerciseModal && (
|
|
<ExerciseModal
|
|
newExercise={newExercise}
|
|
setNewExercise={setNewExercise}
|
|
onClose={() => setShowExerciseModal(false)}
|
|
onCreate={handleCreateExercise}
|
|
saving={savingExercise}
|
|
/>
|
|
)}
|
|
{showScenarioModal && (
|
|
<ScenarioModal
|
|
newScenario={newScenario}
|
|
setNewScenario={setNewScenario}
|
|
onClose={() => setShowScenarioModal(false)}
|
|
onCreate={handleCreateScenario}
|
|
saving={savingScenario}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|