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>
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user