Files
breakpilot-compliance/admin-compliance/app/sdk/notfallplan/page.tsx
Sharang Parnerkar ef8284dff5 refactor(admin): split dsfa/[id] and notfallplan page.tsx into colocated components
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>
2026-04-11 18:51:54 +02:00

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>
)
}