Agent-completed splits committed after agents hit rate limits before committing their work. All 4 pages now under 500 LOC: - consent-management: 1303 -> 193 LOC (+ 7 _components, _hooks, _data, _types) - control-library: 1210 -> 298 LOC (+ _components, _types) - incidents: 1150 -> 373 LOC (+ _components) - training: 1127 -> 366 LOC (+ _components) Verification: next build clean (142 pages generated). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
374 lines
17 KiB
TypeScript
374 lines
17 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
|
import { useSDK } from '@/lib/sdk'
|
|
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
|
import {
|
|
Incident,
|
|
IncidentSeverity,
|
|
IncidentStatus,
|
|
IncidentCategory,
|
|
IncidentStatistics,
|
|
getHoursUntil72hDeadline,
|
|
is72hDeadlineExpired
|
|
} from '@/lib/sdk/incidents/types'
|
|
import { fetchSDKIncidentList, createMockIncidents, createMockStatistics } from '@/lib/sdk/incidents/api'
|
|
import { TabNavigation, type Tab, type TabId } from './_components/TabNavigation'
|
|
import { StatCard } from './_components/StatCard'
|
|
import { FilterBar } from './_components/FilterBar'
|
|
import { IncidentCard } from './_components/IncidentCard'
|
|
import { IncidentCreateModal } from './_components/IncidentCreateModal'
|
|
import { IncidentDetailDrawer } from './_components/IncidentDetailDrawer'
|
|
|
|
// =============================================================================
|
|
// MAIN PAGE
|
|
// =============================================================================
|
|
|
|
export default function IncidentsPage() {
|
|
const { state } = useSDK()
|
|
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
|
const [incidents, setIncidents] = useState<Incident[]>([])
|
|
const [statistics, setStatistics] = useState<IncidentStatistics | null>(null)
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
|
const [selectedIncident, setSelectedIncident] = useState<Incident | null>(null)
|
|
|
|
// Filters
|
|
const [selectedSeverity, setSelectedSeverity] = useState<IncidentSeverity | 'all'>('all')
|
|
const [selectedStatus, setSelectedStatus] = useState<IncidentStatus | 'all'>('all')
|
|
const [selectedCategory, setSelectedCategory] = useState<IncidentCategory | 'all'>('all')
|
|
|
|
// Load data (extracted as useCallback so it can be called from modals)
|
|
const loadData = useCallback(async () => {
|
|
setIsLoading(true)
|
|
try {
|
|
const { incidents: loadedIncidents, statistics: loadedStats } = await fetchSDKIncidentList()
|
|
setIncidents(loadedIncidents)
|
|
setStatistics(loadedStats)
|
|
} catch (error) {
|
|
console.error('Fehler beim Laden der Incident-Daten:', error)
|
|
// Fallback auf Mock-Daten
|
|
setIncidents(createMockIncidents())
|
|
setStatistics(createMockStatistics())
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
loadData()
|
|
}, [loadData])
|
|
|
|
// Calculate tab counts
|
|
const tabCounts = useMemo(() => {
|
|
return {
|
|
active: incidents.filter(i =>
|
|
i.status === 'detected' || i.status === 'assessment' ||
|
|
i.status === 'containment' || i.status === 'remediation'
|
|
).length,
|
|
notification: incidents.filter(i =>
|
|
i.status === 'notification_required' || i.status === 'notification_sent' ||
|
|
(i.authorityNotification !== null && i.authorityNotification.status === 'pending')
|
|
).length,
|
|
closed: incidents.filter(i => i.status === 'closed').length,
|
|
deadlineExpired: incidents.filter(i => {
|
|
if (i.status === 'closed') return false
|
|
if (i.authorityNotification && (i.authorityNotification.status === 'submitted' || i.authorityNotification.status === 'acknowledged')) return false
|
|
if (i.riskAssessment && !i.riskAssessment.notificationRequired) return false
|
|
return is72hDeadlineExpired(i.detectedAt)
|
|
}).length,
|
|
deadlineApproaching: incidents.filter(i => {
|
|
if (i.status === 'closed') return false
|
|
if (i.authorityNotification && (i.authorityNotification.status === 'submitted' || i.authorityNotification.status === 'acknowledged')) return false
|
|
const hours = getHoursUntil72hDeadline(i.detectedAt)
|
|
return hours > 0 && hours <= 24
|
|
}).length
|
|
}
|
|
}, [incidents])
|
|
|
|
// Filter incidents based on active tab and filters
|
|
const filteredIncidents = useMemo(() => {
|
|
let filtered = [...incidents]
|
|
|
|
// Tab-based filtering
|
|
if (activeTab === 'active') {
|
|
filtered = filtered.filter(i =>
|
|
i.status === 'detected' || i.status === 'assessment' ||
|
|
i.status === 'containment' || i.status === 'remediation'
|
|
)
|
|
} else if (activeTab === 'notification') {
|
|
filtered = filtered.filter(i =>
|
|
i.status === 'notification_required' || i.status === 'notification_sent' ||
|
|
(i.authorityNotification !== null && i.authorityNotification.status === 'pending')
|
|
)
|
|
} else if (activeTab === 'closed') {
|
|
filtered = filtered.filter(i => i.status === 'closed')
|
|
}
|
|
|
|
// Severity filter
|
|
if (selectedSeverity !== 'all') {
|
|
filtered = filtered.filter(i => i.severity === selectedSeverity)
|
|
}
|
|
|
|
// Status filter
|
|
if (selectedStatus !== 'all') {
|
|
filtered = filtered.filter(i => i.status === selectedStatus)
|
|
}
|
|
|
|
// Category filter
|
|
if (selectedCategory !== 'all') {
|
|
filtered = filtered.filter(i => i.category === selectedCategory)
|
|
}
|
|
|
|
// Sort: most urgent first (overdue > deadline approaching > severity > detected time)
|
|
const severityOrder: Record<IncidentSeverity, number> = { critical: 0, high: 1, medium: 2, low: 3 }
|
|
return filtered.sort((a, b) => {
|
|
// Closed always at the end
|
|
if (a.status === 'closed' !== (b.status === 'closed')) return a.status === 'closed' ? 1 : -1
|
|
|
|
// Overdue first
|
|
const aExpired = is72hDeadlineExpired(a.detectedAt)
|
|
const bExpired = is72hDeadlineExpired(b.detectedAt)
|
|
if (aExpired !== bExpired) return aExpired ? -1 : 1
|
|
|
|
// Then by severity
|
|
if (severityOrder[a.severity] !== severityOrder[b.severity]) {
|
|
return severityOrder[a.severity] - severityOrder[b.severity]
|
|
}
|
|
|
|
// Then by deadline urgency
|
|
return getHoursUntil72hDeadline(a.detectedAt) - getHoursUntil72hDeadline(b.detectedAt)
|
|
})
|
|
}, [incidents, activeTab, selectedSeverity, selectedStatus, selectedCategory])
|
|
|
|
const tabs: Tab[] = [
|
|
{ id: 'overview', label: 'Uebersicht' },
|
|
{ id: 'active', label: 'Aktiv', count: tabCounts.active, countColor: 'bg-orange-100 text-orange-600' },
|
|
{ id: 'notification', label: 'Meldepflichtig', count: tabCounts.notification, countColor: 'bg-red-100 text-red-600' },
|
|
{ id: 'closed', label: 'Abgeschlossen', count: tabCounts.closed, countColor: 'bg-green-100 text-green-600' },
|
|
{ id: 'settings', label: 'Einstellungen' }
|
|
]
|
|
|
|
const stepInfo = STEP_EXPLANATIONS['incidents']
|
|
|
|
const clearFilters = () => {
|
|
setSelectedSeverity('all')
|
|
setSelectedStatus('all')
|
|
setSelectedCategory('all')
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Step Header */}
|
|
<StepHeader
|
|
stepId="incidents"
|
|
title={stepInfo.title}
|
|
description={stepInfo.description}
|
|
explanation={stepInfo.explanation}
|
|
tips={stepInfo.tips}
|
|
>
|
|
<button
|
|
onClick={() => setShowCreateModal(true)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
</svg>
|
|
Vorfall melden
|
|
</button>
|
|
</StepHeader>
|
|
|
|
{/* Tab Navigation */}
|
|
<TabNavigation
|
|
tabs={tabs}
|
|
activeTab={activeTab}
|
|
onTabChange={setActiveTab}
|
|
/>
|
|
|
|
{/* Loading State */}
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<svg className="animate-spin w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
|
</svg>
|
|
</div>
|
|
) : activeTab === 'settings' ? (
|
|
/* Settings Tab */
|
|
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
|
|
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
|
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-900">Einstellungen</h3>
|
|
<p className="mt-2 text-gray-500">
|
|
Incident-Management-Einstellungen, Eskalationswege und Meldevorlagen
|
|
werden in einer spaeteren Version verfuegbar sein.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Statistics (Overview Tab) */}
|
|
{activeTab === 'overview' && statistics && (
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<StatCard
|
|
label="Gesamt Vorfaelle"
|
|
value={statistics.totalIncidents}
|
|
color="gray"
|
|
/>
|
|
<StatCard
|
|
label="Offene Vorfaelle"
|
|
value={statistics.openIncidents}
|
|
color="orange"
|
|
/>
|
|
<StatCard
|
|
label="Meldungen ausstehend"
|
|
value={statistics.notificationsPending}
|
|
color={statistics.notificationsPending > 0 ? 'red' : 'green'}
|
|
/>
|
|
<StatCard
|
|
label="Durchschn. Reaktionszeit"
|
|
value={`${statistics.averageResponseTimeHours}h`}
|
|
color="purple"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Critical Alert: 72h deadline approaching or expired */}
|
|
{(tabCounts.deadlineExpired > 0 || tabCounts.deadlineApproaching > 0) && (
|
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-4">
|
|
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
|
|
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
</div>
|
|
<div className="flex-1">
|
|
<h4 className="font-medium text-red-800">
|
|
{tabCounts.deadlineExpired > 0
|
|
? `Achtung: ${tabCounts.deadlineExpired} ueberfaellige Meldung(en) - 72-Stunden-Frist ueberschritten!`
|
|
: `Warnung: ${tabCounts.deadlineApproaching} Meldung(en) mit ablaufender 72-Stunden-Frist`
|
|
}
|
|
</h4>
|
|
<p className="text-sm text-red-600">
|
|
{tabCounts.deadlineExpired > 0
|
|
? 'Die gesetzliche Meldefrist nach Art. 33 DSGVO ist abgelaufen. Handeln Sie umgehend, um Bussgelder zu vermeiden. Verspaetete Meldungen muessen begruendet werden.'
|
|
: 'Die 72-Stunden-Meldefrist nach Art. 33 DSGVO laeuft in Kuerze ab. Fuehren Sie eine Risikobewertung durch und entscheiden Sie ueber die Meldepflicht.'
|
|
}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => {
|
|
setActiveTab('active')
|
|
setSelectedStatus('all')
|
|
}}
|
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium"
|
|
>
|
|
Anzeigen
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Info Box (Overview Tab) */}
|
|
{activeTab === 'overview' && (
|
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
|
<div className="flex items-start gap-3">
|
|
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<div>
|
|
<h4 className="font-medium text-blue-800">Art. 33/34 DSGVO - 72-Stunden-Meldepflicht</h4>
|
|
<p className="text-sm text-blue-600 mt-1">
|
|
Nach Art. 33 DSGVO muessen Datenschutzverletzungen innerhalb von 72 Stunden
|
|
an die zustaendige Aufsichtsbehoerde gemeldet werden, sofern ein Risiko fuer
|
|
die Rechte und Freiheiten der betroffenen Personen besteht. Bei hohem Risiko
|
|
muessen gemaess Art. 34 DSGVO auch die betroffenen Personen benachrichtigt werden.
|
|
Alle Vorfaelle sind unabhaengig von der Meldepflicht zu dokumentieren (Art. 33 Abs. 5).
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Filters */}
|
|
<FilterBar
|
|
selectedSeverity={selectedSeverity}
|
|
selectedStatus={selectedStatus}
|
|
selectedCategory={selectedCategory}
|
|
onSeverityChange={setSelectedSeverity}
|
|
onStatusChange={setSelectedStatus}
|
|
onCategoryChange={setSelectedCategory}
|
|
onClear={clearFilters}
|
|
/>
|
|
|
|
{/* Incidents List */}
|
|
<div className="space-y-4">
|
|
{filteredIncidents.map(incident => (
|
|
<IncidentCard
|
|
key={incident.id}
|
|
incident={incident}
|
|
onClick={() => setSelectedIncident(incident)}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Empty State */}
|
|
{filteredIncidents.length === 0 && (
|
|
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
|
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
|
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
</svg>
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-900">Keine Vorfaelle gefunden</h3>
|
|
<p className="mt-2 text-gray-500">
|
|
{selectedSeverity !== 'all' || selectedStatus !== 'all' || selectedCategory !== 'all'
|
|
? 'Passen Sie die Filter an oder'
|
|
: 'Es sind noch keine Vorfaelle erfasst worden.'
|
|
}
|
|
</p>
|
|
{(selectedSeverity !== 'all' || selectedStatus !== 'all' || selectedCategory !== 'all') ? (
|
|
<button
|
|
onClick={clearFilters}
|
|
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
|
>
|
|
Filter zuruecksetzen
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => setShowCreateModal(true)}
|
|
className="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
</svg>
|
|
Ersten Vorfall erfassen
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Create Modal */}
|
|
{showCreateModal && (
|
|
<IncidentCreateModal
|
|
onClose={() => setShowCreateModal(false)}
|
|
onSuccess={() => { setShowCreateModal(false); loadData() }}
|
|
/>
|
|
)}
|
|
|
|
{/* Detail Drawer */}
|
|
{selectedIncident && (
|
|
<IncidentDetailDrawer
|
|
incident={selectedIncident}
|
|
onClose={() => setSelectedIncident(null)}
|
|
onStatusChange={() => { setSelectedIncident(null); loadData() }}
|
|
onDeleted={() => { setSelectedIncident(null); loadData() }}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|