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

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:
Benjamin Admin
2026-03-03 12:48:43 +01:00
parent 79b423e549
commit b19fc11737
24 changed files with 5472 additions and 529 deletions

View File

@@ -49,6 +49,164 @@ interface EmailTemplateData {
body: string
}
// ============================================================================
// Helper sub-components for API-backed inline editors
// ============================================================================
function ApiTemplateEditor({
template,
saving,
onSave,
onPreview,
}: {
template: { id: string; template_key: string; subject: string; body: string; language: string; is_active: boolean }
saving: boolean
onSave: (subject: string, body: string) => void
onPreview: (subject: string, body: string) => void
}) {
const [subject, setSubject] = useState(template.subject)
const [body, setBody] = useState(template.body)
const [expanded, setExpanded] = useState(false)
return (
<div className="border border-slate-200 rounded-lg bg-white overflow-hidden">
<div className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className={`w-2.5 h-2.5 rounded-full ${template.is_active ? 'bg-green-400' : 'bg-slate-300'}`} />
<div>
<span className="font-medium text-slate-900 font-mono text-sm">{template.template_key}</span>
<p className="text-sm text-slate-500 truncate max-w-xs">{subject}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs uppercase">{template.language}</span>
<button
onClick={() => onPreview(subject, body)}
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400"
>
Vorschau
</button>
<button
onClick={() => setExpanded(!expanded)}
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400"
>
{expanded ? 'Schliessen' : 'Bearbeiten'}
</button>
</div>
</div>
{expanded && (
<div className="border-t border-slate-200 p-4 space-y-3 bg-slate-50">
<div>
<label className="block text-xs font-medium text-slate-700 mb-1">Betreff</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-700 mb-1">Inhalt</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={8}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono"
/>
</div>
<div className="bg-white rounded-lg p-3 border border-slate-200">
<div className="text-xs font-medium text-slate-500 mb-1">Verfuegbare Platzhalter:</div>
<div className="flex flex-wrap gap-2">
{['{{name}}', '{{email}}', '{{date}}', '{{deadline}}', '{{company}}'].map(v => (
<span key={v} className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs font-mono">{v}</span>
))}
</div>
</div>
<div className="flex justify-end">
<button
onClick={() => onSave(subject, body)}
disabled={saving}
className="px-4 py-2 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg font-medium disabled:opacity-60"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
)}
</div>
)
}
function ApiGdprProcessEditor({
process,
saving,
onSave,
}: {
process: { id: string; process_key: string; title: string; description: string; legal_basis: string; retention_days: number; is_active: boolean }
saving: boolean
onSave: (title: string, description: string) => void
}) {
const [title, setTitle] = useState(process.title)
const [description, setDescription] = useState(process.description || '')
const [expanded, setExpanded] = useState(false)
return (
<div className="border border-slate-200 rounded-xl bg-white overflow-hidden">
<div className="p-4 flex items-start justify-between">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-purple-100 text-purple-700 rounded-lg flex items-center justify-center flex-shrink-0 font-mono text-xs font-bold">
{process.legal_basis?.replace('Art. ', '').replace(' DSGVO', '') || '?'}
</div>
<div>
<h4 className="font-semibold text-slate-900">{title}</h4>
<p className="text-sm text-slate-500">{description || 'Keine Beschreibung'}</p>
{process.retention_days && (
<span className="text-xs text-slate-400">Aufbewahrung: {process.retention_days} Tage</span>
)}
</div>
</div>
<button
onClick={() => setExpanded(!expanded)}
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400 flex-shrink-0"
>
{expanded ? 'Schliessen' : 'Bearbeiten'}
</button>
</div>
{expanded && (
<div className="border-t border-slate-200 p-4 space-y-3 bg-slate-50">
<div>
<label className="block text-xs font-medium text-slate-700 mb-1">Titel</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
<div className="flex justify-end">
<button
onClick={() => onSave(title, description)}
disabled={saving}
className="px-4 py-2 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg font-medium disabled:opacity-60"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
)}
</div>
)
}
export default function ConsentManagementPage() {
const { state } = useSDK()
const [activeTab, setActiveTab] = useState<Tab>('documents')
@@ -65,11 +223,19 @@ export default function ConsentManagementPage() {
const [dsrCounts, setDsrCounts] = useState<Record<string, number>>({})
const [dsrOverview, setDsrOverview] = useState<{ open: number; completed: number; in_progress: number; overdue: number }>({ open: 0, completed: 0, in_progress: 0, overdue: 0 })
// Email template editor state
// Email template editor state
const [editingTemplate, setEditingTemplate] = useState<EmailTemplateData | null>(null)
const [previewTemplate, setPreviewTemplate] = useState<EmailTemplateData | null>(null)
const [savedTemplates, setSavedTemplates] = useState<Record<string, EmailTemplateData>>({})
// API-backed email templates and GDPR processes
const [apiEmailTemplates, setApiEmailTemplates] = useState<Array<{id: string; template_key: string; subject: string; body: string; language: string; is_active: boolean}>>([])
const [apiGdprProcesses, setApiGdprProcesses] = useState<Array<{id: string; process_key: string; title: string; description: string; legal_basis: string; retention_days: number; is_active: boolean}>>([])
const [templatesLoading, setTemplatesLoading] = useState(false)
const [gdprLoading, setGdprLoading] = useState(false)
const [savingTemplateId, setSavingTemplateId] = useState<string | null>(null)
const [savingProcessId, setSavingProcessId] = useState<string | null>(null)
// Auth token (in production, get from auth context)
const [authToken, setAuthToken] = useState<string>('')
@@ -96,9 +262,80 @@ export default function ConsentManagementPage() {
loadStats()
} else if (activeTab === 'gdpr') {
loadGDPRData()
loadApiGdprProcesses()
} else if (activeTab === 'emails') {
loadApiEmailTemplates()
}
}, [activeTab, selectedDocument, authToken])
async function loadApiEmailTemplates() {
setTemplatesLoading(true)
try {
const res = await fetch('/api/sdk/v1/consent-templates')
if (res.ok) {
const data = await res.json()
setApiEmailTemplates(Array.isArray(data) ? data : [])
}
} catch (err) {
console.error('Failed to load email templates from API:', err)
} finally {
setTemplatesLoading(false)
}
}
async function loadApiGdprProcesses() {
setGdprLoading(true)
try {
const res = await fetch('/api/sdk/v1/consent-templates/gdpr-processes')
if (res.ok) {
const data = await res.json()
setApiGdprProcesses(Array.isArray(data) ? data : [])
}
} catch (err) {
console.error('Failed to load GDPR processes from API:', err)
} finally {
setGdprLoading(false)
}
}
async function saveApiEmailTemplate(template: {id: string; subject: string; body: string}) {
setSavingTemplateId(template.id)
try {
const res = await fetch(`/api/sdk/v1/consent-templates/${template.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subject: template.subject, body: template.body }),
})
if (res.ok) {
const updated = await res.json()
setApiEmailTemplates(prev => prev.map(t => t.id === updated.id ? updated : t))
}
} catch (err) {
console.error('Failed to save email template:', err)
} finally {
setSavingTemplateId(null)
}
}
async function saveApiGdprProcess(process: {id: string; title: string; description: string}) {
setSavingProcessId(process.id)
try {
const res = await fetch(`/api/sdk/v1/consent-templates/gdpr-processes/${process.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: process.title, description: process.description }),
})
if (res.ok) {
const updated = await res.json()
setApiGdprProcesses(prev => prev.map(p => p.id === updated.id ? updated : p))
}
} catch (err) {
console.error('Failed to save GDPR process:', err)
} finally {
setSavingProcessId(null)
}
}
async function loadDocuments() {
setLoading(true)
setError(null)
@@ -548,88 +785,114 @@ export default function ConsentManagementPage() {
</div>
)}
{/* Emails Tab - 16 Lifecycle Templates */}
{/* Emails Tab - API-backed + Lifecycle Templates */}
{activeTab === 'emails' && (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">E-Mail Vorlagen</h2>
<p className="text-sm text-slate-500 mt-1">16 Lifecycle-Vorlagen fuer automatisierte Kommunikation</p>
<p className="text-sm text-slate-500 mt-1">
{apiEmailTemplates.length > 0
? `${apiEmailTemplates.length} DSGVO-Vorlagen aus der Datenbank`
: '16 Lifecycle-Vorlagen fuer automatisierte Kommunikation'}
</p>
</div>
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
+ Neue Vorlage
</button>
</div>
{/* Category Filter */}
<div className="flex flex-wrap gap-2 mb-6">
<span className="text-sm text-slate-500 py-1">Filter:</span>
{emailCategories.map((cat) => (
<span key={cat.key} className={`px-3 py-1 rounded-full text-xs font-medium ${cat.color}`}>
{cat.label}
</span>
))}
</div>
{/* Templates grouped by category */}
{emailCategories.map((category) => (
<div key={category.key} className="mb-8">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-3 flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${category.color.split(' ')[0]}`}></span>
{category.label}
</h3>
<div className="grid gap-3">
{emailTemplates
.filter((t) => t.category === category.key)
.map((template) => (
<div
key={template.key}
className="border border-slate-200 rounded-lg p-4 flex items-center justify-between hover:border-purple-300 transition-colors bg-white"
>
<div className="flex items-center gap-4">
<div className={`w-10 h-10 rounded-lg ${category.color} flex items-center justify-center text-lg`}>
</div>
<div>
<h4 className="font-medium text-slate-900">{template.name}</h4>
<p className="text-sm text-slate-500">{template.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">
{savedTemplates[template.key] ? 'Angepasst' : 'Aktiv'}
</span>
<button
onClick={() => {
const existing = savedTemplates[template.key]
setEditingTemplate({
key: template.key,
subject: existing?.subject || `Betreff: ${template.name}`,
body: existing?.body || `Sehr geehrte(r) {{name}},\n\n${template.description}\n\nMit freundlichen Gruessen\nIhr Datenschutz-Team`,
})
}}
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400"
>
Bearbeiten
</button>
<button
onClick={() => {
const existing = savedTemplates[template.key]
setPreviewTemplate({
key: template.key,
subject: existing?.subject || `Betreff: ${template.name}`,
body: existing?.body || `Sehr geehrte(r) {{name}},\n\n${template.description}\n\nMit freundlichen Gruessen\nIhr Datenschutz-Team`,
})
}}
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400"
>
Vorschau
</button>
</div>
</div>
))}
</div>
{/* API-backed templates section */}
{templatesLoading ? (
<div className="text-center py-8 text-slate-500">Lade Vorlagen aus der Datenbank...</div>
) : apiEmailTemplates.length > 0 ? (
<div className="space-y-4 mb-8">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-3">DSGVO-Pflichtvorlagen</h3>
{apiEmailTemplates.map((template) => (
<ApiTemplateEditor
key={template.id}
template={template}
saving={savingTemplateId === template.id}
onSave={(subject, body) => saveApiEmailTemplate({ id: template.id, subject, body })}
onPreview={(subject, body) => setPreviewTemplate({ key: template.template_key, subject, body })}
/>
))}
</div>
))}
) : null}
{/* Category Filter for static templates */}
{apiEmailTemplates.length === 0 && (
<>
<div className="flex flex-wrap gap-2 mb-6">
<span className="text-sm text-slate-500 py-1">Filter:</span>
{emailCategories.map((cat) => (
<span key={cat.key} className={`px-3 py-1 rounded-full text-xs font-medium ${cat.color}`}>
{cat.label}
</span>
))}
</div>
{/* Templates grouped by category (fallback when no API data) */}
{emailCategories.map((category) => (
<div key={category.key} className="mb-8">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-3 flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${category.color.split(' ')[0]}`}></span>
{category.label}
</h3>
<div className="grid gap-3">
{emailTemplates
.filter((t) => t.category === category.key)
.map((template) => (
<div
key={template.key}
className="border border-slate-200 rounded-lg p-4 flex items-center justify-between hover:border-purple-300 transition-colors bg-white"
>
<div className="flex items-center gap-4">
<div className={`w-10 h-10 rounded-lg ${category.color} flex items-center justify-center text-lg`}>
</div>
<div>
<h4 className="font-medium text-slate-900">{template.name}</h4>
<p className="text-sm text-slate-500">{template.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">
{savedTemplates[template.key] ? 'Angepasst' : 'Aktiv'}
</span>
<button
onClick={() => {
const existing = savedTemplates[template.key]
setEditingTemplate({
key: template.key,
subject: existing?.subject || `Betreff: ${template.name}`,
body: existing?.body || `Sehr geehrte(r) {{name}},\n\n${template.description}\n\nMit freundlichen Gruessen\nIhr Datenschutz-Team`,
})
}}
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400"
>
Bearbeiten
</button>
<button
onClick={() => {
const existing = savedTemplates[template.key]
setPreviewTemplate({
key: template.key,
subject: existing?.subject || `Betreff: ${template.name}`,
body: existing?.body || `Sehr geehrte(r) {{name}},\n\n${template.description}\n\nMit freundlichen Gruessen\nIhr Datenschutz-Team`,
})
}}
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400"
>
Vorschau
</button>
</div>
</div>
))}
</div>
</div>
))}
</>
)}
</div>
)}
@@ -659,8 +922,26 @@ export default function ConsentManagementPage() {
</div>
</div>
{/* GDPR Process Cards */}
{/* API-backed GDPR Processes */}
{gdprLoading ? (
<div className="text-center py-8 text-slate-500">Lade DSGVO-Prozesse...</div>
) : apiGdprProcesses.length > 0 ? (
<div className="space-y-4 mb-8">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-3">Konfigurierte Prozesse</h3>
{apiGdprProcesses.map((process) => (
<ApiGdprProcessEditor
key={process.id}
process={process}
saving={savingProcessId === process.id}
onSave={(title, description) => saveApiGdprProcess({ id: process.id, title, description })}
/>
))}
</div>
) : null}
{/* Static GDPR Process Cards (always shown as reference) */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-3">DSGVO Artikel-Uebersicht</h3>
{gdprProcesses.map((process) => (
<div
key={process.article}