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>
293 lines
12 KiB
TypeScript
293 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect } from 'react'
|
|
import type { Incident, IncidentStatus, IncidentSeverity } from './types'
|
|
import {
|
|
INCIDENT_STATUS_LABELS,
|
|
INCIDENT_STATUS_COLORS,
|
|
SEVERITY_LABELS,
|
|
SEVERITY_COLORS,
|
|
} from './types'
|
|
|
|
function CountdownTimer({ detectedAt }: { detectedAt: string }) {
|
|
const [remaining, setRemaining] = useState('')
|
|
const [overdue, setOverdue] = useState(false)
|
|
|
|
useEffect(() => {
|
|
function update() {
|
|
const detected = new Date(detectedAt).getTime()
|
|
const deadline = detected + 72 * 60 * 60 * 1000
|
|
const now = Date.now()
|
|
const diff = deadline - now
|
|
|
|
if (diff <= 0) {
|
|
const overdueMs = Math.abs(diff)
|
|
const hours = Math.floor(overdueMs / (1000 * 60 * 60))
|
|
const mins = Math.floor((overdueMs % (1000 * 60 * 60)) / (1000 * 60))
|
|
setRemaining(`${hours}h ${mins}min ueberfaellig`)
|
|
setOverdue(true)
|
|
} else {
|
|
const hours = Math.floor(diff / (1000 * 60 * 60))
|
|
const mins = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
|
setRemaining(`${hours}h ${mins}min verbleibend`)
|
|
setOverdue(false)
|
|
}
|
|
}
|
|
update()
|
|
const interval = setInterval(update, 60000)
|
|
return () => clearInterval(interval)
|
|
}, [detectedAt])
|
|
|
|
return (
|
|
<span className={`text-sm font-medium ${overdue ? 'text-red-600' : 'text-orange-600'}`}>
|
|
72h-Frist: {remaining}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
export function IncidentsTab({
|
|
incidents,
|
|
setIncidents,
|
|
showAdd,
|
|
setShowAdd,
|
|
onAdd,
|
|
onStatusChange,
|
|
onDelete,
|
|
}: {
|
|
incidents: Incident[]
|
|
setIncidents: React.Dispatch<React.SetStateAction<Incident[]>>
|
|
showAdd: boolean
|
|
setShowAdd: (v: boolean) => void
|
|
onAdd?: (incident: Partial<Incident>) => Promise<void>
|
|
onStatusChange?: (id: string, status: IncidentStatus) => Promise<void>
|
|
onDelete?: (id: string) => Promise<void>
|
|
}) {
|
|
const [newIncident, setNewIncident] = useState<Partial<Incident>>({
|
|
title: '',
|
|
description: '',
|
|
severity: 'medium',
|
|
affectedDataCategories: [],
|
|
estimatedAffectedPersons: 0,
|
|
measures: [],
|
|
art34Required: false,
|
|
art34Justification: '',
|
|
})
|
|
|
|
async function addIncident() {
|
|
if (!newIncident.title) return
|
|
if (onAdd) {
|
|
await onAdd(newIncident)
|
|
} else {
|
|
const incident: Incident = {
|
|
id: `INC-${Date.now()}`,
|
|
title: newIncident.title || '',
|
|
description: newIncident.description || '',
|
|
detectedAt: new Date().toISOString(),
|
|
detectedBy: 'Admin',
|
|
status: 'detected',
|
|
severity: newIncident.severity as IncidentSeverity || 'medium',
|
|
affectedDataCategories: newIncident.affectedDataCategories || [],
|
|
estimatedAffectedPersons: newIncident.estimatedAffectedPersons || 0,
|
|
measures: newIncident.measures || [],
|
|
art34Required: newIncident.art34Required || false,
|
|
art34Justification: newIncident.art34Justification || '',
|
|
}
|
|
setIncidents(prev => [incident, ...prev])
|
|
}
|
|
setShowAdd(false)
|
|
setNewIncident({
|
|
title: '', description: '', severity: 'medium',
|
|
affectedDataCategories: [], estimatedAffectedPersons: 0,
|
|
measures: [], art34Required: false, art34Justification: '',
|
|
})
|
|
}
|
|
|
|
async function updateStatus(id: string, status: IncidentStatus) {
|
|
if (onStatusChange) {
|
|
await onStatusChange(id, status)
|
|
} else {
|
|
setIncidents(prev => prev.map(inc =>
|
|
inc.id === id
|
|
? {
|
|
...inc,
|
|
status,
|
|
...(status === 'reported' ? { reportedToAuthorityAt: new Date().toISOString() } : {}),
|
|
...(status === 'closed' ? { closedAt: new Date().toISOString(), closedBy: 'Admin' } : {}),
|
|
}
|
|
: inc
|
|
))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-lg font-semibold">Incident-Register (Art. 33 Abs. 5)</h3>
|
|
<p className="text-sm text-gray-500">Alle Datenpannen dokumentieren — auch nicht-meldepflichtige.</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowAdd(true)}
|
|
className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700"
|
|
>
|
|
+ Datenpanne melden
|
|
</button>
|
|
</div>
|
|
|
|
{/* Add Incident Form */}
|
|
{showAdd && (
|
|
<div className="bg-white rounded-lg border p-6 space-y-4">
|
|
<h4 className="font-medium">Neue Datenpanne erfassen</h4>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="col-span-2">
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Titel</label>
|
|
<input
|
|
type="text"
|
|
value={newIncident.title}
|
|
onChange={e => setNewIncident(prev => ({ ...prev, title: e.target.value }))}
|
|
placeholder="Kurzbeschreibung der Datenpanne"
|
|
className="w-full border rounded px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="col-span-2">
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
|
<textarea
|
|
value={newIncident.description}
|
|
onChange={e => setNewIncident(prev => ({ ...prev, description: e.target.value }))}
|
|
placeholder="Detaillierte Beschreibung: Was ist passiert, wie wurde es entdeckt?"
|
|
rows={3}
|
|
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">Schweregrad</label>
|
|
<select
|
|
value={newIncident.severity}
|
|
onChange={e => setNewIncident(prev => ({ ...prev, severity: e.target.value as IncidentSeverity }))}
|
|
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>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Geschaetzte Betroffene</label>
|
|
<input
|
|
type="number"
|
|
value={newIncident.estimatedAffectedPersons}
|
|
onChange={e => setNewIncident(prev => ({ ...prev, estimatedAffectedPersons: parseInt(e.target.value) || 0 }))}
|
|
className="w-full border rounded px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="col-span-2 flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={newIncident.art34Required}
|
|
onChange={e => setNewIncident(prev => ({ ...prev, art34Required: e.target.checked }))}
|
|
className="rounded"
|
|
/>
|
|
<label className="text-sm">Hohes Risiko fuer Betroffene (Art. 34 Benachrichtigungspflicht)</label>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2 justify-end">
|
|
<button onClick={() => setShowAdd(false)} className="px-4 py-2 border rounded-lg text-sm">
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
onClick={addIncident}
|
|
disabled={!newIncident.title}
|
|
className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm font-medium hover:bg-red-700 disabled:opacity-50"
|
|
>
|
|
Datenpanne erfassen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Incidents List */}
|
|
{incidents.length === 0 ? (
|
|
<div className="bg-white rounded-lg border p-12 text-center text-gray-500">
|
|
<p className="text-lg mb-2">Keine Datenpannen erfasst</p>
|
|
<p className="text-sm">Dokumentieren Sie hier alle Datenpannen — auch solche, die nicht meldepflichtig sind (Art. 33 Abs. 5).</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{incidents.map(incident => (
|
|
<div key={incident.id} className="bg-white rounded-lg border p-6">
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="text-xs text-gray-500 font-mono">{incident.id}</span>
|
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${INCIDENT_STATUS_COLORS[incident.status]}`}>
|
|
{INCIDENT_STATUS_LABELS[incident.status]}
|
|
</span>
|
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${SEVERITY_COLORS[incident.severity]}`}>
|
|
{SEVERITY_LABELS[incident.severity]}
|
|
</span>
|
|
{incident.art34Required && (
|
|
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
|
|
Art. 34
|
|
</span>
|
|
)}
|
|
</div>
|
|
<h4 className="font-medium">{incident.title}</h4>
|
|
</div>
|
|
{incident.status !== 'closed' && incident.status !== 'reported' && incident.status !== 'not_reportable' && (
|
|
<CountdownTimer detectedAt={incident.detectedAt} />
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-gray-600 mb-3">{incident.description}</p>
|
|
<div className="flex items-center gap-4 text-xs text-gray-500 mb-3">
|
|
<span>Entdeckt: {new Date(incident.detectedAt).toLocaleString('de-DE')}</span>
|
|
<span>Betroffene: ~{incident.estimatedAffectedPersons}</span>
|
|
{incident.reportedToAuthorityAt && (
|
|
<span>Gemeldet: {new Date(incident.reportedToAuthorityAt).toLocaleString('de-DE')}</span>
|
|
)}
|
|
</div>
|
|
{incident.status !== 'closed' && (
|
|
<div className="flex gap-2">
|
|
{incident.status === 'detected' && (
|
|
<button onClick={() => updateStatus(incident.id, 'classified')} className="text-xs px-3 py-1 bg-yellow-100 text-yellow-800 rounded hover:bg-yellow-200">
|
|
Klassifizieren
|
|
</button>
|
|
)}
|
|
{incident.status === 'classified' && (
|
|
<button onClick={() => updateStatus(incident.id, 'assessed')} className="text-xs px-3 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200">
|
|
Bewerten
|
|
</button>
|
|
)}
|
|
{incident.status === 'assessed' && (
|
|
<>
|
|
<button onClick={() => updateStatus(incident.id, 'reported')} className="text-xs px-3 py-1 bg-green-100 text-green-800 rounded hover:bg-green-200">
|
|
Als gemeldet markieren
|
|
</button>
|
|
<button onClick={() => updateStatus(incident.id, 'not_reportable')} className="text-xs px-3 py-1 bg-gray-100 text-gray-800 rounded hover:bg-gray-200">
|
|
Nicht meldepflichtig
|
|
</button>
|
|
</>
|
|
)}
|
|
{(incident.status === 'reported' || incident.status === 'not_reportable') && (
|
|
<button onClick={() => updateStatus(incident.id, 'closed')} className="text-xs px-3 py-1 bg-gray-100 text-gray-600 rounded hover:bg-gray-200">
|
|
Abschliessen
|
|
</button>
|
|
)}
|
|
{onDelete && (
|
|
<button
|
|
onClick={() => { if (window.confirm('Incident loeschen?')) onDelete(incident.id) }}
|
|
className="text-xs px-2 py-1 text-red-400 hover:text-red-600 hover:bg-red-50 rounded ml-auto"
|
|
>
|
|
Loeschen
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|