feat: Betrieb-Module → 100% — Echte CRUD-Flows, kein Mock-Data
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 37s
CI / test-python-backend-compliance (push) Successful in 34s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 18s
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 37s
CI / test-python-backend-compliance (push) Successful in 34s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 18s
Alle 7 Betrieb-Module von 30–75% auf 100% gebracht: **Gruppe 1 — UI-Ergänzungen (Backend bereits vorhanden):** - incidents/page.tsx: IncidentCreateModal + IncidentDetailDrawer (Status-Transitions) - whistleblower/page.tsx: WhistleblowerCreateModal + CaseDetailPanel (Kommentare, Zuweisung) - dsr/page.tsx: DSRCreateModal + DSRDetailPanel (Workflow-Timeline, Status-Buttons) - vendor-compliance/page.tsx: VendorCreateModal + "Neuer Vendor" Button **Gruppe 2 — Escalations Full Stack:** - Migration 011: compliance_escalations Tabelle - Backend: escalation_routes.py (7 Endpoints: list/create/get/update/status/stats/delete) - Proxy: /api/sdk/v1/escalations/[[...path]] → backend:8002 - Frontend: Mock-Array komplett ersetzt durch echte API + EscalationCreateModal + EscalationDetailDrawer **Gruppe 2 — Consent Templates:** - Migration 010: compliance_consent_email_templates + compliance_consent_gdpr_processes (7+7 Seed-Einträge) - Backend: consent_template_routes.py (GET/POST/PUT/DELETE Templates + GET/PUT GDPR-Prozesse) - Proxy: /api/sdk/v1/consent-templates/[[...path]] - Frontend: consent-management/page.tsx lädt Templates + Prozesse aus DB (ApiTemplateEditor, ApiGdprProcessEditor) **Gruppe 3 — Notfallplan:** - Migration 012: 4 Tabellen (contacts, scenarios, checklists, exercises) - Backend: notfallplan_routes.py (vollständiges CRUD + /stats) - Proxy: /api/sdk/v1/notfallplan/[[...path]] - Frontend: notfallplan/page.tsx — DB-backed Kontakte + Szenarien + Übungen, ContactCreateModal + ScenarioCreateModal **Infrastruktur:** - __init__.py: escalation_router + consent_template_router + notfallplan_router registriert - Deploy-Skripte: apply_escalations_migration.sh, apply_consent_templates_migration.sh, apply_notfallplan_migration.sh - Tests: 40 neue Tests (test_escalation_routes.py, test_consent_template_routes.py, test_notfallplan_routes.py) - flow-data.ts: Completion aller 7 Module auf 100% gesetzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
|
||||
@@ -11,263 +11,512 @@ import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
interface Escalation {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
type: 'data-breach' | 'dsr-overdue' | 'audit-finding' | 'compliance-gap' | 'security-incident'
|
||||
severity: 'critical' | 'high' | 'medium' | 'low'
|
||||
status: 'open' | 'in-progress' | 'resolved' | 'escalated'
|
||||
createdAt: Date
|
||||
deadline: Date | null
|
||||
assignedTo: string
|
||||
escalatedTo: string | null
|
||||
relatedItems: string[]
|
||||
actions: EscalationAction[]
|
||||
description: string | null
|
||||
priority: 'low' | 'medium' | 'high' | 'critical'
|
||||
status: 'open' | 'in_progress' | 'escalated' | 'resolved' | 'closed'
|
||||
category: string | null
|
||||
assignee: string | null
|
||||
reporter: string | null
|
||||
source_module: string | null
|
||||
due_date: string | null
|
||||
resolved_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface EscalationAction {
|
||||
id: string
|
||||
action: string
|
||||
performedBy: string
|
||||
performedAt: Date
|
||||
interface EscalationStats {
|
||||
by_status: Record<string, number>
|
||||
by_priority: Record<string, number>
|
||||
total: number
|
||||
active: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA
|
||||
// HELPERS
|
||||
// =============================================================================
|
||||
|
||||
const mockEscalations: Escalation[] = [
|
||||
{
|
||||
id: 'esc-001',
|
||||
title: 'Potenzielle Datenpanne - Kundendaten',
|
||||
description: 'Unberechtigter Zugriff auf Kundendatenbank festgestellt',
|
||||
type: 'data-breach',
|
||||
severity: 'critical',
|
||||
status: 'escalated',
|
||||
createdAt: new Date('2024-01-22'),
|
||||
deadline: new Date('2024-01-25'),
|
||||
assignedTo: 'IT Security',
|
||||
escalatedTo: 'CISO',
|
||||
relatedItems: ['INC-2024-001'],
|
||||
actions: [
|
||||
{ id: 'a1', action: 'Incident erkannt und gemeldet', performedBy: 'SOC Team', performedAt: new Date('2024-01-22T08:00:00') },
|
||||
{ id: 'a2', action: 'An CISO eskaliert', performedBy: 'IT Security', performedAt: new Date('2024-01-22T09:30:00') },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'esc-002',
|
||||
title: 'DSR-Anfrage ueberfaellig',
|
||||
description: 'Auskunftsanfrage von Max Mustermann ueberschreitet 30-Tage-Frist',
|
||||
type: 'dsr-overdue',
|
||||
severity: 'high',
|
||||
status: 'in-progress',
|
||||
createdAt: new Date('2024-01-20'),
|
||||
deadline: new Date('2024-01-23'),
|
||||
assignedTo: 'DSB Mueller',
|
||||
escalatedTo: null,
|
||||
relatedItems: ['DSR-001'],
|
||||
actions: [
|
||||
{ id: 'a1', action: 'Automatische Eskalation bei Fristueberschreitung', performedBy: 'System', performedAt: new Date('2024-01-20') },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'esc-003',
|
||||
title: 'Kritische Audit-Feststellung',
|
||||
description: 'Fehlende Auftragsverarbeitungsvertraege mit Cloud-Providern',
|
||||
type: 'audit-finding',
|
||||
severity: 'high',
|
||||
status: 'in-progress',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
deadline: new Date('2024-02-15'),
|
||||
assignedTo: 'Rechtsabteilung',
|
||||
escalatedTo: null,
|
||||
relatedItems: ['AUDIT-2024-Q1-003'],
|
||||
actions: [
|
||||
{ id: 'a1', action: 'Feststellung dokumentiert', performedBy: 'Auditor', performedAt: new Date('2024-01-15') },
|
||||
{ id: 'a2', action: 'An Rechtsabteilung zugewiesen', performedBy: 'DSB Mueller', performedAt: new Date('2024-01-16') },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'esc-004',
|
||||
title: 'AI Act Compliance-Luecke',
|
||||
description: 'Hochrisiko-KI-System ohne Risikomanagementsystem',
|
||||
type: 'compliance-gap',
|
||||
severity: 'high',
|
||||
status: 'open',
|
||||
createdAt: new Date('2024-01-18'),
|
||||
deadline: new Date('2024-03-01'),
|
||||
assignedTo: 'KI-Compliance Team',
|
||||
escalatedTo: null,
|
||||
relatedItems: ['AI-SYS-002'],
|
||||
actions: [],
|
||||
},
|
||||
{
|
||||
id: 'esc-005',
|
||||
title: 'Sicherheitsluecke in Anwendung',
|
||||
description: 'Kritische CVE in verwendeter Bibliothek entdeckt',
|
||||
type: 'security-incident',
|
||||
severity: 'medium',
|
||||
status: 'resolved',
|
||||
createdAt: new Date('2024-01-10'),
|
||||
deadline: new Date('2024-01-17'),
|
||||
assignedTo: 'Entwicklung',
|
||||
escalatedTo: null,
|
||||
relatedItems: ['CVE-2024-12345'],
|
||||
actions: [
|
||||
{ id: 'a1', action: 'CVE identifiziert', performedBy: 'Security Scanner', performedAt: new Date('2024-01-10') },
|
||||
{ id: 'a2', action: 'Patch entwickelt', performedBy: 'Entwicklung', performedAt: new Date('2024-01-12') },
|
||||
{ id: 'a3', action: 'Patch deployed', performedBy: 'DevOps', performedAt: new Date('2024-01-13') },
|
||||
{ id: 'a4', action: 'Eskalation geschlossen', performedBy: 'IT Security', performedAt: new Date('2024-01-14') },
|
||||
],
|
||||
},
|
||||
]
|
||||
const priorityColors: Record<string, string> = {
|
||||
critical: 'bg-red-500 text-white',
|
||||
high: 'bg-orange-500 text-white',
|
||||
medium: 'bg-yellow-500 text-white',
|
||||
low: 'bg-green-500 text-white',
|
||||
}
|
||||
|
||||
const priorityLabels: Record<string, string> = {
|
||||
critical: 'Kritisch',
|
||||
high: 'Hoch',
|
||||
medium: 'Mittel',
|
||||
low: 'Niedrig',
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
open: 'bg-blue-100 text-blue-700',
|
||||
in_progress: 'bg-yellow-100 text-yellow-700',
|
||||
escalated: 'bg-red-100 text-red-700',
|
||||
resolved: 'bg-green-100 text-green-700',
|
||||
closed: 'bg-gray-100 text-gray-600',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
open: 'Offen',
|
||||
in_progress: 'In Bearbeitung',
|
||||
escalated: 'Eskaliert',
|
||||
resolved: 'Geloest',
|
||||
closed: 'Geschlossen',
|
||||
}
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
dsgvo_breach: 'DSGVO-Verletzung',
|
||||
ai_act: 'AI Act',
|
||||
vendor: 'Vendor',
|
||||
internal: 'Intern',
|
||||
other: 'Sonstiges',
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleDateString('de-DE')
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// CREATE MODAL
|
||||
// =============================================================================
|
||||
|
||||
function EscalationCard({ escalation }: { escalation: Escalation }) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
interface CreateModalProps {
|
||||
onClose: () => void
|
||||
onCreated: () => void
|
||||
}
|
||||
|
||||
const typeLabels = {
|
||||
'data-breach': 'Datenpanne',
|
||||
'dsr-overdue': 'DSR ueberfaellig',
|
||||
'audit-finding': 'Audit-Feststellung',
|
||||
'compliance-gap': 'Compliance-Luecke',
|
||||
'security-incident': 'Sicherheitsvorfall',
|
||||
}
|
||||
function EscalationCreateModal({ onClose, onCreated }: CreateModalProps) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [priority, setPriority] = useState('medium')
|
||||
const [category, setCategory] = useState('')
|
||||
const [assignee, setAssignee] = useState('')
|
||||
const [dueDate, setDueDate] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const typeColors = {
|
||||
'data-breach': 'bg-red-100 text-red-700',
|
||||
'dsr-overdue': 'bg-orange-100 text-orange-700',
|
||||
'audit-finding': 'bg-yellow-100 text-yellow-700',
|
||||
'compliance-gap': 'bg-purple-100 text-purple-700',
|
||||
'security-incident': 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
|
||||
const severityColors = {
|
||||
critical: 'bg-red-500 text-white',
|
||||
high: 'bg-orange-500 text-white',
|
||||
medium: 'bg-yellow-500 text-white',
|
||||
low: 'bg-green-500 text-white',
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
open: 'bg-blue-100 text-blue-700',
|
||||
'in-progress': 'bg-yellow-100 text-yellow-700',
|
||||
resolved: 'bg-green-100 text-green-700',
|
||||
escalated: 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
open: 'Offen',
|
||||
'in-progress': 'In Bearbeitung',
|
||||
resolved: 'Geloest',
|
||||
escalated: 'Eskaliert',
|
||||
async function handleSave() {
|
||||
if (!title.trim()) {
|
||||
setError('Titel ist erforderlich.')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/escalations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
priority,
|
||||
category: category || null,
|
||||
assignee: assignee.trim() || null,
|
||||
due_date: dueDate || null,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.detail || err.error || 'Fehler beim Erstellen')
|
||||
}
|
||||
onCreated()
|
||||
onClose()
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
escalation.severity === 'critical' ? 'border-red-300' :
|
||||
escalation.severity === 'high' ? 'border-orange-300' :
|
||||
escalation.status === 'resolved' ? 'border-green-200' : 'border-gray-200'
|
||||
}`}>
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg">
|
||||
<div className="p-6 border-b border-gray-100">
|
||||
<h2 className="text-xl font-bold text-gray-900">Neue Eskalation erstellen</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-700 text-sm px-4 py-2 rounded-lg">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Titel <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Kurze Beschreibung der Eskalation"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Detaillierte Beschreibung…"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Prioritaet</label>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={e => setPriority(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<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">Kategorie</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">— Keine —</option>
|
||||
<option value="dsgvo_breach">DSGVO-Verletzung</option>
|
||||
<option value="ai_act">AI Act</option>
|
||||
<option value="vendor">Vendor</option>
|
||||
<option value="internal">Intern</option>
|
||||
<option value="other">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Zugewiesen an</label>
|
||||
<input
|
||||
type="text"
|
||||
value={assignee}
|
||||
onChange={e => setAssignee(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Name oder Team"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Faelligkeitsdatum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={e => setDueDate(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 border-t border-gray-100 flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichern…' : 'Eskalation erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DETAIL DRAWER
|
||||
// =============================================================================
|
||||
|
||||
interface DrawerProps {
|
||||
escalation: Escalation
|
||||
onClose: () => void
|
||||
onUpdated: () => void
|
||||
}
|
||||
|
||||
function EscalationDetailDrawer({ escalation, onClose, onUpdated }: DrawerProps) {
|
||||
const [editAssignee, setEditAssignee] = useState(escalation.assignee || '')
|
||||
const [editPriority, setEditPriority] = useState(escalation.priority)
|
||||
const [editDueDate, setEditDueDate] = useState(
|
||||
escalation.due_date ? escalation.due_date.slice(0, 10) : ''
|
||||
)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [statusSaving, setStatusSaving] = useState(false)
|
||||
|
||||
async function handleSaveEdit() {
|
||||
setSaving(true)
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/escalations/${escalation.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
assignee: editAssignee || null,
|
||||
priority: editPriority,
|
||||
due_date: editDueDate || null,
|
||||
}),
|
||||
})
|
||||
onUpdated()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(newStatus: string) {
|
||||
setStatusSaving(true)
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/escalations/${escalation.id}/status`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
})
|
||||
onUpdated()
|
||||
onClose()
|
||||
} finally {
|
||||
setStatusSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex justify-end">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/30" onClick={onClose} />
|
||||
{/* Panel */}
|
||||
<div className="relative w-full max-w-md bg-white shadow-2xl flex flex-col h-full overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-100 flex items-start justify-between">
|
||||
<div className="flex-1 pr-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${priorityColors[escalation.priority]}`}>
|
||||
{priorityLabels[escalation.priority]}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${statusColors[escalation.status]}`}>
|
||||
{statusLabels[escalation.status]}
|
||||
</span>
|
||||
{escalation.category && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-100 text-purple-700">
|
||||
{categoryLabels[escalation.category] || escalation.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-gray-900">{escalation.title}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors text-gray-500"
|
||||
>
|
||||
<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-6 flex-1">
|
||||
{/* Description */}
|
||||
{escalation.description && (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Beschreibung</h3>
|
||||
<p className="text-sm text-gray-700">{escalation.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meta */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">Erstellt</span>
|
||||
<p className="font-medium text-gray-800">{formatDate(escalation.created_at)}</p>
|
||||
</div>
|
||||
{escalation.reporter && (
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">Gemeldet von</span>
|
||||
<p className="font-medium text-gray-800">{escalation.reporter}</p>
|
||||
</div>
|
||||
)}
|
||||
{escalation.source_module && (
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">Quell-Modul</span>
|
||||
<p className="font-medium text-gray-800">{escalation.source_module}</p>
|
||||
</div>
|
||||
)}
|
||||
{escalation.resolved_at && (
|
||||
<div>
|
||||
<span className="text-gray-500 text-xs">Geloest am</span>
|
||||
<p className="font-medium text-green-700">{formatDate(escalation.resolved_at)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit fields */}
|
||||
<div className="border border-gray-200 rounded-xl p-4 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700">Bearbeiten</h3>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Zugewiesen an</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editAssignee}
|
||||
onChange={e => setEditAssignee(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="Name oder Team"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">Prioritaet</label>
|
||||
<select
|
||||
value={editPriority}
|
||||
onChange={e => setEditPriority(e.target.value as Escalation['priority'])}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<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-xs font-medium text-gray-500 mb-1">Faelligkeit</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editDueDate}
|
||||
onChange={e => setEditDueDate(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
disabled={saving}
|
||||
className="w-full py-2 text-sm bg-gray-800 text-white rounded-lg hover:bg-gray-900 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichern…' : 'Aenderungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status transitions */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Status-Aktionen</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{escalation.status === 'open' && (
|
||||
<button
|
||||
onClick={() => handleStatusChange('in_progress')}
|
||||
disabled={statusSaving}
|
||||
className="w-full py-2 text-sm bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
In Bearbeitung nehmen
|
||||
</button>
|
||||
)}
|
||||
{escalation.status === 'in_progress' && (
|
||||
<button
|
||||
onClick={() => handleStatusChange('escalated')}
|
||||
disabled={statusSaving}
|
||||
className="w-full py-2 text-sm bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Eskalieren
|
||||
</button>
|
||||
)}
|
||||
{(escalation.status === 'escalated' || escalation.status === 'in_progress') && (
|
||||
<button
|
||||
onClick={() => handleStatusChange('resolved')}
|
||||
disabled={statusSaving}
|
||||
className="w-full py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Loesen
|
||||
</button>
|
||||
)}
|
||||
{escalation.status === 'resolved' && (
|
||||
<button
|
||||
onClick={() => handleStatusChange('closed')}
|
||||
disabled={statusSaving}
|
||||
className="w-full py-2 text-sm bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
)}
|
||||
{escalation.status === 'closed' && (
|
||||
<p className="text-sm text-gray-400 text-center py-2">Eskalation ist geschlossen.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ESCALATION CARD
|
||||
// =============================================================================
|
||||
|
||||
interface CardProps {
|
||||
escalation: Escalation
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function EscalationCard({ escalation, onClick }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`bg-white rounded-xl border-2 p-6 cursor-pointer hover:shadow-md transition-shadow ${
|
||||
escalation.priority === 'critical' ? 'border-red-300' :
|
||||
escalation.priority === 'high' ? 'border-orange-300' :
|
||||
escalation.status === 'resolved' || escalation.status === 'closed' ? 'border-green-200' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${severityColors[escalation.severity]}`}>
|
||||
{escalation.severity.toUpperCase()}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${typeColors[escalation.type]}`}>
|
||||
{typeLabels[escalation.type]}
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${priorityColors[escalation.priority]}`}>
|
||||
{priorityLabels[escalation.priority]}
|
||||
</span>
|
||||
{escalation.category && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-purple-100 text-purple-700">
|
||||
{categoryLabels[escalation.category] || escalation.category}
|
||||
</span>
|
||||
)}
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[escalation.status]}`}>
|
||||
{statusLabels[escalation.status]}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{escalation.title}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{escalation.description}</p>
|
||||
{escalation.description && (
|
||||
<p className="text-sm text-gray-500 mt-1 line-clamp-2">{escalation.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<svg className="w-5 h-5 text-gray-400 ml-3 flex-shrink-0 mt-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Zugewiesen: </span>
|
||||
<span className="font-medium text-gray-700">{escalation.assignedTo}</span>
|
||||
</div>
|
||||
{escalation.escalatedTo && (
|
||||
{escalation.assignee && (
|
||||
<div>
|
||||
<span className="text-gray-500">Eskaliert an: </span>
|
||||
<span className="font-medium text-red-600">{escalation.escalatedTo}</span>
|
||||
<span className="text-gray-500">Zugewiesen: </span>
|
||||
<span className="font-medium text-gray-700">{escalation.assignee}</span>
|
||||
</div>
|
||||
)}
|
||||
{escalation.deadline && (
|
||||
{escalation.due_date && (
|
||||
<div>
|
||||
<span className="text-gray-500">Frist: </span>
|
||||
<span className="font-medium text-gray-700">{escalation.deadline.toLocaleDateString('de-DE')}</span>
|
||||
<span className="font-medium text-gray-700">{formatDate(escalation.due_date)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-gray-500">Erstellt: </span>
|
||||
<span className="font-medium text-gray-700">{escalation.createdAt.toLocaleDateString('de-DE')}</span>
|
||||
<span className="font-medium text-gray-700">{formatDate(escalation.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{escalation.relatedItems.length > 0 && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Verknuepft:</span>
|
||||
{escalation.relatedItems.map(item => (
|
||||
<span key={item} className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded font-mono">
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{escalation.actions.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-sm text-purple-600 hover:text-purple-700 flex items-center gap-1"
|
||||
>
|
||||
<span>{expanded ? 'Verlauf ausblenden' : `Verlauf anzeigen (${escalation.actions.length})`}</span>
|
||||
<svg className={`w-4 h-4 transition-transform ${expanded ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{escalation.actions.map(action => (
|
||||
<div key={action.id} className="flex items-start gap-3 text-sm p-2 bg-gray-50 rounded-lg">
|
||||
<div className="w-2 h-2 bg-purple-500 rounded-full mt-1.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-gray-700">{action.action}</p>
|
||||
<p className="text-gray-500 text-xs">
|
||||
{action.performedBy} - {action.performedAt.toLocaleString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">{escalation.id}</span>
|
||||
{escalation.status !== 'resolved' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
|
||||
Aktion hinzufuegen
|
||||
</button>
|
||||
{escalation.status !== 'escalated' && (
|
||||
<button className="px-3 py-1 text-sm text-orange-600 hover:bg-orange-50 rounded-lg transition-colors">
|
||||
Eskalieren
|
||||
</button>
|
||||
)}
|
||||
<button className="px-3 py-1 text-sm bg-green-50 text-green-700 hover:bg-green-100 rounded-lg transition-colors">
|
||||
Loesen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<span className="text-xs text-gray-400 font-mono">{escalation.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -279,16 +528,62 @@ function EscalationCard({ escalation }: { escalation: Escalation }) {
|
||||
|
||||
export default function EscalationsPage() {
|
||||
const { state } = useSDK()
|
||||
const [escalations] = useState<Escalation[]>(mockEscalations)
|
||||
const [escalations, setEscalations] = useState<Escalation[]>([])
|
||||
const [stats, setStats] = useState<EscalationStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [selectedEscalation, setSelectedEscalation] = useState<Escalation | null>(null)
|
||||
|
||||
const filteredEscalations = filter === 'all'
|
||||
async function loadEscalations() {
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: '100' })
|
||||
if (filter !== 'all' && ['open', 'in_progress', 'escalated', 'resolved', 'closed'].includes(filter)) {
|
||||
params.set('status', filter)
|
||||
} else if (filter !== 'all' && ['low', 'medium', 'high', 'critical'].includes(filter)) {
|
||||
params.set('priority', filter)
|
||||
}
|
||||
const res = await fetch(`/api/sdk/v1/escalations?${params}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setEscalations(data.items || [])
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load escalations', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/escalations/stats')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStats(data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load stats', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
setLoading(true)
|
||||
await Promise.all([loadEscalations(), loadStats()])
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadAll()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filter])
|
||||
|
||||
const criticalCount = stats?.by_priority?.critical ?? 0
|
||||
const escalatedCount = stats?.by_status?.escalated ?? 0
|
||||
const openCount = stats?.by_status?.open ?? 0
|
||||
const activeCount = stats?.active ?? 0
|
||||
|
||||
const filteredEscalations = filter === 'all' || ['open', 'in_progress', 'escalated', 'resolved', 'closed'].includes(filter) || ['low', 'medium', 'high', 'critical'].includes(filter)
|
||||
? escalations
|
||||
: escalations.filter(e => e.type === filter || e.status === filter || e.severity === filter)
|
||||
|
||||
const openCount = escalations.filter(e => e.status === 'open').length
|
||||
const criticalCount = escalations.filter(e => e.severity === 'critical' && e.status !== 'resolved').length
|
||||
const escalatedCount = escalations.filter(e => e.status === 'escalated').length
|
||||
: escalations
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['escalations']
|
||||
|
||||
@@ -302,7 +597,10 @@ export default function EscalationsPage() {
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<button className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||
<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>
|
||||
@@ -315,27 +613,27 @@ export default function EscalationsPage() {
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Gesamt aktiv</div>
|
||||
<div className="text-3xl font-bold text-gray-900">
|
||||
{escalations.filter(e => e.status !== 'resolved').length}
|
||||
{loading ? '…' : activeCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-red-200 p-6">
|
||||
<div className="text-sm text-red-600">Kritisch</div>
|
||||
<div className="text-3xl font-bold text-red-600">{criticalCount}</div>
|
||||
<div className="text-3xl font-bold text-red-600">{loading ? '…' : criticalCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||
<div className="text-sm text-orange-600">Eskaliert</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{escalatedCount}</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{loading ? '…' : escalatedCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-blue-200 p-6">
|
||||
<div className="text-sm text-blue-600">Offen</div>
|
||||
<div className="text-3xl font-bold text-blue-600">{openCount}</div>
|
||||
<div className="text-3xl font-bold text-blue-600">{loading ? '…' : openCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Critical Alert */}
|
||||
{criticalCount > 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">
|
||||
<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>
|
||||
@@ -350,52 +648,79 @@ export default function EscalationsPage() {
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'open', 'escalated', 'critical', 'data-breach', 'compliance-gap'].map(f => (
|
||||
{[
|
||||
{ key: 'all', label: 'Alle' },
|
||||
{ key: 'open', label: 'Offen' },
|
||||
{ key: 'in_progress', label: 'In Bearbeitung' },
|
||||
{ key: 'escalated', label: 'Eskaliert' },
|
||||
{ key: 'critical', label: 'Kritisch' },
|
||||
{ key: 'high', label: 'Hoch' },
|
||||
{ key: 'resolved', label: 'Geloest' },
|
||||
].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
key={f.key}
|
||||
onClick={() => setFilter(f.key)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
filter === f.key
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'open' ? 'Offen' :
|
||||
f === 'escalated' ? 'Eskaliert' :
|
||||
f === 'critical' ? 'Kritisch' :
|
||||
f === 'data-breach' ? 'Datenpannen' : 'Compliance-Luecken'}
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Escalations List */}
|
||||
<div className="space-y-4">
|
||||
{filteredEscalations
|
||||
.sort((a, b) => {
|
||||
// Sort by severity and status
|
||||
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 }
|
||||
const statusOrder = { escalated: 0, open: 1, 'in-progress': 2, resolved: 3 }
|
||||
const severityDiff = severityOrder[a.severity] - severityOrder[b.severity]
|
||||
if (severityDiff !== 0) return severityDiff
|
||||
return statusOrder[a.status] - statusOrder[b.status]
|
||||
})
|
||||
.map(escalation => (
|
||||
<EscalationCard key={escalation.id} escalation={escalation} />
|
||||
))}
|
||||
</div>
|
||||
{/* List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500 text-sm">Lade Eskalationen…</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredEscalations
|
||||
.sort((a, b) => {
|
||||
const priorityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 }
|
||||
const statusOrder: Record<string, number> = { escalated: 0, open: 1, in_progress: 2, resolved: 3, closed: 4 }
|
||||
const pd = (priorityOrder[a.priority] ?? 9) - (priorityOrder[b.priority] ?? 9)
|
||||
if (pd !== 0) return pd
|
||||
return (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9)
|
||||
})
|
||||
.map(esc => (
|
||||
<EscalationCard
|
||||
key={esc.id}
|
||||
escalation={esc}
|
||||
onClick={() => setSelectedEscalation(esc)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{filteredEscalations.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-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Eskalationen gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an.</p>
|
||||
{filteredEscalations.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-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Eskalationen gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder erstellen Sie eine neue Eskalation.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
{showCreateModal && (
|
||||
<EscalationCreateModal
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onCreated={loadAll}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedEscalation && (
|
||||
<EscalationDetailDrawer
|
||||
escalation={selectedEscalation}
|
||||
onClose={() => setSelectedEscalation(null)}
|
||||
onUpdated={loadAll}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user