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>
297 lines
10 KiB
TypeScript
297 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import React from 'react'
|
|
|
|
interface ModalShellProps {
|
|
title: string
|
|
onClose: () => void
|
|
children: React.ReactNode
|
|
footer: React.ReactNode
|
|
}
|
|
|
|
function ModalShell({ title, onClose, children, footer }: ModalShellProps) {
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4">
|
|
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
|
<h3 className="font-semibold text-gray-900">{title}</h3>
|
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
|
<svg className="w-5 h-5" 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 className="p-6 space-y-4">{children}</div>
|
|
<div className="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">{footer}</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---- Contact Modal ----
|
|
|
|
export function ContactModal({
|
|
newContact,
|
|
setNewContact,
|
|
onClose,
|
|
onCreate,
|
|
saving,
|
|
}: {
|
|
newContact: { name: string; role: string; email: string; phone: string; is_primary: boolean; available_24h: boolean }
|
|
setNewContact: React.Dispatch<React.SetStateAction<typeof newContact>>
|
|
onClose: () => void
|
|
onCreate: () => void
|
|
saving: boolean
|
|
}) {
|
|
return (
|
|
<ModalShell
|
|
title="Notfallkontakt hinzufuegen"
|
|
onClose={onClose}
|
|
footer={
|
|
<>
|
|
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
onClick={onCreate}
|
|
disabled={!newContact.name || saving}
|
|
className="px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg font-medium disabled:opacity-50"
|
|
>
|
|
{saving ? 'Speichern...' : 'Hinzufuegen'}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
|
<input
|
|
type="text"
|
|
value={newContact.name}
|
|
onChange={e => setNewContact(p => ({ ...p, name: e.target.value }))}
|
|
placeholder="Vollstaendiger Name"
|
|
className="w-full border rounded px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Rolle</label>
|
|
<input
|
|
type="text"
|
|
value={newContact.role}
|
|
onChange={e => setNewContact(p => ({ ...p, role: e.target.value }))}
|
|
placeholder="z.B. Datenschutzbeauftragter"
|
|
className="w-full border rounded px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail</label>
|
|
<input
|
|
type="email"
|
|
value={newContact.email}
|
|
onChange={e => setNewContact(p => ({ ...p, email: e.target.value }))}
|
|
placeholder="email@example.com"
|
|
className="w-full border rounded px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Telefon</label>
|
|
<input
|
|
type="tel"
|
|
value={newContact.phone}
|
|
onChange={e => setNewContact(p => ({ ...p, phone: e.target.value }))}
|
|
placeholder="+49..."
|
|
className="w-full border rounded px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-6">
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={newContact.is_primary}
|
|
onChange={e => setNewContact(p => ({ ...p, is_primary: e.target.checked }))}
|
|
className="rounded"
|
|
/>
|
|
Primaerkontakt
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={newContact.available_24h}
|
|
onChange={e => setNewContact(p => ({ ...p, available_24h: e.target.checked }))}
|
|
className="rounded"
|
|
/>
|
|
24/7 erreichbar
|
|
</label>
|
|
</div>
|
|
</ModalShell>
|
|
)
|
|
}
|
|
|
|
// ---- Exercise Modal ----
|
|
|
|
export function ExerciseModal({
|
|
newExercise,
|
|
setNewExercise,
|
|
onClose,
|
|
onCreate,
|
|
saving,
|
|
}: {
|
|
newExercise: { title: string; exercise_type: string; exercise_date: string; notes: string }
|
|
setNewExercise: React.Dispatch<React.SetStateAction<typeof newExercise>>
|
|
onClose: () => void
|
|
onCreate: () => void
|
|
saving: boolean
|
|
}) {
|
|
return (
|
|
<ModalShell
|
|
title="Neue Uebung planen"
|
|
onClose={onClose}
|
|
footer={
|
|
<>
|
|
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
onClick={onCreate}
|
|
disabled={!newExercise.title || saving}
|
|
className="px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg font-medium disabled:opacity-50"
|
|
>
|
|
{saving ? 'Speichern...' : 'Uebung erstellen'}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
|
|
<input
|
|
type="text"
|
|
value={newExercise.title}
|
|
onChange={e => setNewExercise(p => ({ ...p, title: e.target.value }))}
|
|
placeholder="z.B. Tabletop-Uebung Ransomware"
|
|
className="w-full border rounded px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Uebungstyp</label>
|
|
<select
|
|
value={newExercise.exercise_type}
|
|
onChange={e => setNewExercise(p => ({ ...p, exercise_type: e.target.value }))}
|
|
className="w-full border rounded px-3 py-2 text-sm"
|
|
>
|
|
<option value="tabletop">Tabletop</option>
|
|
<option value="simulation">Simulation</option>
|
|
<option value="walkthrough">Walkthrough</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Datum</label>
|
|
<input
|
|
type="date"
|
|
value={newExercise.exercise_date}
|
|
onChange={e => setNewExercise(p => ({ ...p, exercise_date: e.target.value }))}
|
|
className="w-full border rounded px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Notizen</label>
|
|
<textarea
|
|
value={newExercise.notes}
|
|
onChange={e => setNewExercise(p => ({ ...p, notes: e.target.value }))}
|
|
placeholder="Ziele, Teilnehmer, Ablauf..."
|
|
rows={3}
|
|
className="w-full border rounded px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
</ModalShell>
|
|
)
|
|
}
|
|
|
|
// ---- Scenario Modal ----
|
|
|
|
export function ScenarioModal({
|
|
newScenario,
|
|
setNewScenario,
|
|
onClose,
|
|
onCreate,
|
|
saving,
|
|
}: {
|
|
newScenario: { title: string; category: string; severity: string; description: string }
|
|
setNewScenario: React.Dispatch<React.SetStateAction<typeof newScenario>>
|
|
onClose: () => void
|
|
onCreate: () => void
|
|
saving: boolean
|
|
}) {
|
|
return (
|
|
<ModalShell
|
|
title="Notfallszenario hinzufuegen"
|
|
onClose={onClose}
|
|
footer={
|
|
<>
|
|
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
onClick={onCreate}
|
|
disabled={!newScenario.title || saving}
|
|
className="px-4 py-2 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg font-medium disabled:opacity-50"
|
|
>
|
|
{saving ? 'Speichern...' : 'Hinzufuegen'}
|
|
</button>
|
|
</>
|
|
}
|
|
>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
|
|
<input
|
|
type="text"
|
|
value={newScenario.title}
|
|
onChange={e => setNewScenario(p => ({ ...p, title: e.target.value }))}
|
|
placeholder="z.B. Ransomware-Angriff auf Produktivsysteme"
|
|
className="w-full border rounded px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
|
<select
|
|
value={newScenario.category}
|
|
onChange={e => setNewScenario(p => ({ ...p, category: e.target.value }))}
|
|
className="w-full border rounded px-3 py-2 text-sm"
|
|
>
|
|
<option value="data_breach">Datenpanne</option>
|
|
<option value="system_failure">Systemausfall</option>
|
|
<option value="physical">Physischer Vorfall</option>
|
|
<option value="other">Sonstiges</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Schweregrad</label>
|
|
<select
|
|
value={newScenario.severity}
|
|
onChange={e => setNewScenario(p => ({ ...p, severity: e.target.value }))}
|
|
className="w-full border rounded px-3 py-2 text-sm"
|
|
>
|
|
<option value="low">Niedrig</option>
|
|
<option value="medium">Mittel</option>
|
|
<option value="high">Hoch</option>
|
|
<option value="critical">Kritisch</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
|
<textarea
|
|
value={newScenario.description}
|
|
onChange={e => setNewScenario(p => ({ ...p, description: e.target.value }))}
|
|
placeholder="Kurzbeschreibung des Szenarios und moeglicher Auswirkungen..."
|
|
rows={3}
|
|
className="w-full border rounded px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
</ModalShell>
|
|
)
|
|
}
|