refactor(admin): split consent-management, control-library, incidents, training pages
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>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export function ConsentTemplateCreateModal({
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [templateKey, setTemplateKey] = useState('')
|
||||
const [subject, setSubject] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [language, setLanguage] = useState('de')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSave() {
|
||||
if (!templateKey.trim()) {
|
||||
setError('Template-Key ist erforderlich.')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/consent-templates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
template_key: templateKey.trim(),
|
||||
subject: subject.trim(),
|
||||
body: body.trim(),
|
||||
language,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.detail || data.message || `Fehler: ${res.status}`)
|
||||
}
|
||||
onSuccess()
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">Neue E-Mail Vorlage</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600">
|
||||
<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-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Template-Key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={templateKey}
|
||||
onChange={e => setTemplateKey(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="z.B. dsr_confirmation"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Sprache</label>
|
||||
<select
|
||||
value={language}
|
||||
onChange={e => setLanguage(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm 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 focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="E-Mail Betreff"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Inhalt</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-none"
|
||||
placeholder="E-Mail Inhalt..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-slate-200 flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-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...' : 'Vorlage erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import type { Document, Tab } from '../_types'
|
||||
|
||||
export function DocumentsTab({
|
||||
loading,
|
||||
documents,
|
||||
setSelectedDocument,
|
||||
setActiveTab,
|
||||
}: {
|
||||
loading: boolean
|
||||
documents: Document[]
|
||||
setSelectedDocument: (id: string) => void
|
||||
setActiveTab: (t: Tab) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Dokumente verwalten</h2>
|
||||
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
|
||||
+ Neues Dokument
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-500">Lade Dokumente...</div>
|
||||
) : documents.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
Keine Dokumente vorhanden
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Typ</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Name</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Beschreibung</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Pflicht</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Erstellt</th>
|
||||
<th className="text-right py-3 px-4 text-sm font-medium text-slate-500">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{documents.map((doc) => (
|
||||
<tr key={doc.id} className="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td className="py-3 px-4">
|
||||
<span className="px-2 py-1 bg-slate-100 text-slate-700 rounded text-xs font-medium">
|
||||
{doc.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-medium text-slate-900">{doc.name}</td>
|
||||
<td className="py-3 px-4 text-slate-600 text-sm">{doc.description}</td>
|
||||
<td className="py-3 px-4">
|
||||
{doc.mandatory ? (
|
||||
<span className="text-green-600">Ja</span>
|
||||
) : (
|
||||
<span className="text-slate-400">Nein</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-slate-500">
|
||||
{new Date(doc.created_at).toLocaleDateString('de-DE')}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedDocument(doc.id)
|
||||
setActiveTab('versions')
|
||||
}}
|
||||
className="text-purple-600 hover:text-purple-700 text-sm font-medium mr-3"
|
||||
>
|
||||
Versionen
|
||||
</button>
|
||||
<button className="text-slate-500 hover:text-slate-700 text-sm">
|
||||
Bearbeiten
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import type { EmailTemplateData } from '../_types'
|
||||
|
||||
export function EmailTemplateEditModal({
|
||||
editingTemplate,
|
||||
onChange,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
editingTemplate: EmailTemplateData
|
||||
onChange: (tpl: EmailTemplateData) => void
|
||||
onClose: () => void
|
||||
onSave: (tpl: EmailTemplateData) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">E-Mail Vorlage bearbeiten</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600">
|
||||
<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-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Betreff</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingTemplate.subject}
|
||||
onChange={(e) => onChange({ ...editingTemplate, subject: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Inhalt</label>
|
||||
<textarea
|
||||
value={editingTemplate.body}
|
||||
onChange={(e) => onChange({ ...editingTemplate, body: e.target.value })}
|
||||
rows={12}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<div className="text-xs font-medium text-slate-500 mb-1">Verfuegbare Platzhalter:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['{{name}}', '{{email}}', '{{referenceNumber}}', '{{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>
|
||||
<div className="px-6 py-4 border-t border-slate-200 flex justify-end gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-lg">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSave(editingTemplate)}
|
||||
className="px-4 py-2 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg font-medium"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmailTemplatePreviewModal({
|
||||
previewTemplate,
|
||||
onClose,
|
||||
}: {
|
||||
previewTemplate: EmailTemplateData
|
||||
onClose: () => void
|
||||
}) {
|
||||
const substitute = (text: string) =>
|
||||
text
|
||||
.replace(/\{\{name\}\}/g, 'Max Mustermann')
|
||||
.replace(/\{\{email\}\}/g, 'max@example.de')
|
||||
.replace(/\{\{referenceNumber\}\}/g, 'DSR-2025-000001')
|
||||
.replace(/\{\{date\}\}/g, new Date().toLocaleDateString('de-DE'))
|
||||
.replace(/\{\{deadline\}\}/g, '30 Tage')
|
||||
.replace(/\{\{company\}\}/g, 'BreakPilot GmbH')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">Vorschau</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600">
|
||||
<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-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Betreff:</div>
|
||||
<div className="font-medium text-slate-900">
|
||||
{substitute(previewTemplate.subject)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-slate-200 rounded-lg p-4 whitespace-pre-wrap text-sm text-slate-700">
|
||||
{substitute(previewTemplate.body)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-slate-200 flex justify-end">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-lg">
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import type { ApiEmailTemplate, EmailTemplateData } from '../_types'
|
||||
import { emailTemplates, emailCategories } from '../_data'
|
||||
import { ApiTemplateEditor } from './ApiTemplateEditor'
|
||||
|
||||
export function EmailsTab({
|
||||
apiEmailTemplates,
|
||||
templatesLoading,
|
||||
savingTemplateId,
|
||||
savedTemplates,
|
||||
setShowCreateTemplateModal,
|
||||
saveApiEmailTemplate,
|
||||
setPreviewTemplate,
|
||||
setEditingTemplate,
|
||||
}: {
|
||||
apiEmailTemplates: ApiEmailTemplate[]
|
||||
templatesLoading: boolean
|
||||
savingTemplateId: string | null
|
||||
savedTemplates: Record<string, EmailTemplateData>
|
||||
setShowCreateTemplateModal: (v: boolean) => void
|
||||
saveApiEmailTemplate: (t: { id: string; subject: string; body: string }) => void
|
||||
setPreviewTemplate: (t: EmailTemplateData | null) => void
|
||||
setEditingTemplate: (t: EmailTemplateData | null) => void
|
||||
}) {
|
||||
return (
|
||||
<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">
|
||||
{apiEmailTemplates.length > 0
|
||||
? `${apiEmailTemplates.length} DSGVO-Vorlagen aus der Datenbank`
|
||||
: '16 Lifecycle-Vorlagen fuer automatisierte Kommunikation'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateTemplateModal(true)}
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { ApiGdprProcess, DsrOverview } from '../_types'
|
||||
import { gdprProcesses } from '../_data'
|
||||
import { ApiGdprProcessEditor } from './ApiGdprProcessEditor'
|
||||
|
||||
export function GdprTab({
|
||||
router,
|
||||
apiGdprProcesses,
|
||||
gdprLoading,
|
||||
savingProcessId,
|
||||
saveApiGdprProcess,
|
||||
dsrCounts,
|
||||
dsrOverview,
|
||||
}: {
|
||||
router: { push: (path: string) => void }
|
||||
apiGdprProcesses: ApiGdprProcess[]
|
||||
gdprLoading: boolean
|
||||
savingProcessId: string | null
|
||||
saveApiGdprProcess: (p: { id: string; title: string; description: string }) => void
|
||||
dsrCounts: Record<string, number>
|
||||
dsrOverview: DsrOverview
|
||||
}) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">DSGVO Betroffenenrechte</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">Artikel 15-21 Prozesse und Vorlagen</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/sdk/dsr')}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
+ DSR Anfrage erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">*</span>
|
||||
<div>
|
||||
<h4 className="font-medium text-purple-900">Data Subject Rights (DSR)</h4>
|
||||
<p className="text-sm text-purple-700 mt-1">
|
||||
Hier verwalten Sie alle DSGVO-Anfragen. Jeder Artikel hat definierte Prozesse, SLAs und automatisierte Workflows.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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}
|
||||
className="border border-slate-200 rounded-xl p-5 hover:border-purple-300 transition-colors bg-white"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-purple-100 text-purple-700 rounded-lg flex items-center justify-center font-bold text-lg">
|
||||
{process.article}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-slate-900">{process.title}</h3>
|
||||
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">Aktiv</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mb-3">{process.description}</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{process.actions.map((action, idx) => (
|
||||
<span key={idx} className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs">
|
||||
{action}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* SLA */}
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-slate-500">
|
||||
SLA: <span className="font-medium text-slate-700">{process.sla}</span>
|
||||
</span>
|
||||
<span className="text-slate-300">|</span>
|
||||
<span className="text-slate-500">
|
||||
Offene Anfragen: <span className={`font-medium ${(dsrCounts[process.article] || 0) > 0 ? 'text-orange-600' : 'text-slate-700'}`}>{dsrCounts[process.article] || 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link
|
||||
href={`/sdk/dsr?type=${process.article === '15' ? 'access' : process.article === '16' ? 'rectification' : process.article === '17' ? 'erasure' : process.article === '18' ? 'restriction' : process.article === '20' ? 'portability' : 'objection'}`}
|
||||
className="px-3 py-1.5 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg text-center"
|
||||
>
|
||||
Anfragen
|
||||
</Link>
|
||||
<button 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">
|
||||
Vorlage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* DSR Request Statistics */}
|
||||
<div className="mt-8 pt-6 border-t border-slate-200">
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-4">DSR Uebersicht</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-center">
|
||||
<div className={`text-2xl font-bold ${dsrOverview.open > 0 ? 'text-blue-600' : 'text-slate-900'}`}>{dsrOverview.open}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">Offen</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-700">{dsrOverview.completed}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">Erledigt</div>
|
||||
</div>
|
||||
<div className="bg-yellow-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-yellow-700">{dsrOverview.in_progress}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">In Bearbeitung</div>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-4 text-center">
|
||||
<div className={`text-2xl font-bold ${dsrOverview.overdue > 0 ? 'text-red-700' : 'text-slate-400'}`}>{dsrOverview.overdue}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">Ueberfaellig</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import type { ConsentStats } from '../_types'
|
||||
|
||||
export function StatsTab({ consentStats }: { consentStats: ConsentStats }) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-6">Statistiken</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div className="bg-slate-50 rounded-xl p-6">
|
||||
<div className="text-3xl font-bold text-slate-900">{consentStats.activeConsents}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">Aktive Zustimmungen</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-xl p-6">
|
||||
<div className="text-3xl font-bold text-slate-900">{consentStats.documentCount}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">Dokumente</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-xl p-6">
|
||||
<div className={`text-3xl font-bold ${consentStats.openDSRs > 0 ? 'text-orange-600' : 'text-slate-900'}`}>
|
||||
{consentStats.openDSRs}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 mt-1">Offene DSR-Anfragen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-slate-200 rounded-lg p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Zustimmungsrate nach Dokument</h3>
|
||||
<div className="text-center py-8 text-slate-400 text-sm">
|
||||
Diagramm wird in einer zukuenftigen Version verfuegbar sein
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client'
|
||||
|
||||
import type { Document, Version } from '../_types'
|
||||
|
||||
export function VersionsTab({
|
||||
loading,
|
||||
documents,
|
||||
versions,
|
||||
selectedDocument,
|
||||
setSelectedDocument,
|
||||
}: {
|
||||
loading: boolean
|
||||
documents: Document[]
|
||||
versions: Version[]
|
||||
selectedDocument: string
|
||||
setSelectedDocument: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Versionen</h2>
|
||||
<select
|
||||
value={selectedDocument}
|
||||
onChange={(e) => setSelectedDocument(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">Dokument auswaehlen...</option>
|
||||
{documents.map((doc) => (
|
||||
<option key={doc.id} value={doc.id}>
|
||||
{doc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{selectedDocument && (
|
||||
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
|
||||
+ Neue Version
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedDocument ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
Bitte waehlen Sie ein Dokument aus
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="text-center py-12 text-slate-500">Lade Versionen...</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
Keine Versionen vorhanden
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{versions.map((version) => (
|
||||
<div
|
||||
key={version.id}
|
||||
className="border border-slate-200 rounded-lg p-4 hover:border-purple-300 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-slate-900">v{version.version}</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">
|
||||
{version.language.toUpperCase()}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs ${
|
||||
version.status === 'published'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: version.status === 'draft'
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{version.status}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-slate-700">{version.title}</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Erstellt: {new Date(version.created_at).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button 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>
|
||||
{version.status === 'draft' && (
|
||||
<button className="px-3 py-1.5 text-sm text-white bg-green-600 hover:bg-green-700 rounded-lg">
|
||||
Veroeffentlichen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
80
admin-compliance/app/sdk/consent-management/_data.ts
Normal file
80
admin-compliance/app/sdk/consent-management/_data.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// 16 Lifecycle Email Templates
|
||||
export const emailTemplates = [
|
||||
{ name: 'Willkommens-E-Mail', key: 'welcome', category: 'onboarding', description: 'Wird bei Registrierung versendet' },
|
||||
{ name: 'E-Mail Bestaetigung', key: 'email_verification', category: 'onboarding', description: 'Bestaetigungslink fuer E-Mail-Adresse' },
|
||||
{ name: 'Konto aktiviert', key: 'account_activated', category: 'onboarding', description: 'Bestaetigung der Kontoaktivierung' },
|
||||
{ name: 'Passwort zuruecksetzen', key: 'password_reset', category: 'security', description: 'Link zum Zuruecksetzen des Passworts' },
|
||||
{ name: 'Passwort geaendert', key: 'password_changed', category: 'security', description: 'Bestaetigung der Passwortaenderung' },
|
||||
{ name: 'Neue Anmeldung', key: 'new_login', category: 'security', description: 'Benachrichtigung ueber Anmeldung von neuem Geraet' },
|
||||
{ name: '2FA aktiviert', key: '2fa_enabled', category: 'security', description: 'Bestaetigung der 2FA-Aktivierung' },
|
||||
{ name: 'Neue Dokumentversion', key: 'new_document_version', category: 'consent', description: 'Info ueber neue Dokumentversion zur Zustimmung' },
|
||||
{ name: 'Zustimmung erteilt', key: 'consent_given', category: 'consent', description: 'Bestaetigung der erteilten Zustimmung' },
|
||||
{ name: 'Zustimmung widerrufen', key: 'consent_withdrawn', category: 'consent', description: 'Bestaetigung des Widerrufs' },
|
||||
{ name: 'DSR Anfrage erhalten', key: 'dsr_received', category: 'gdpr', description: 'Bestaetigung des Eingangs einer DSGVO-Anfrage' },
|
||||
{ name: 'Datenexport bereit', key: 'export_ready', category: 'gdpr', description: 'Benachrichtigung ueber fertigen Datenexport' },
|
||||
{ name: 'Daten geloescht', key: 'data_deleted', category: 'gdpr', description: 'Bestaetigung der Datenloeschung' },
|
||||
{ name: 'Daten berichtigt', key: 'data_rectified', category: 'gdpr', description: 'Bestaetigung der Datenberichtigung' },
|
||||
{ name: 'Konto deaktiviert', key: 'account_deactivated', category: 'lifecycle', description: 'Konto wurde deaktiviert' },
|
||||
{ name: 'Konto geloescht', key: 'account_deleted', category: 'lifecycle', description: 'Bestaetigung der Kontoloeschung' },
|
||||
]
|
||||
|
||||
// GDPR Article 15-21 Processes
|
||||
export const gdprProcesses = [
|
||||
{
|
||||
article: '15',
|
||||
title: 'Auskunftsrecht',
|
||||
description: 'Recht auf Bestaetigung und Auskunft ueber verarbeitete personenbezogene Daten',
|
||||
actions: ['Datenexport generieren', 'Verarbeitungszwecke auflisten', 'Empfaenger auflisten'],
|
||||
sla: '30 Tage',
|
||||
},
|
||||
{
|
||||
article: '16',
|
||||
title: 'Recht auf Berichtigung',
|
||||
description: 'Recht auf Berichtigung unrichtiger personenbezogener Daten',
|
||||
actions: ['Daten bearbeiten', 'Aenderungshistorie fuehren', 'Benachrichtigung senden'],
|
||||
sla: '30 Tage',
|
||||
},
|
||||
{
|
||||
article: '17',
|
||||
title: 'Recht auf Loeschung ("Vergessenwerden")',
|
||||
description: 'Recht auf Loeschung personenbezogener Daten unter bestimmten Voraussetzungen',
|
||||
actions: ['Loeschantrag pruefen', 'Daten loeschen', 'Aufbewahrungsfristen pruefen', 'Loeschbestaetigung senden'],
|
||||
sla: '30 Tage',
|
||||
},
|
||||
{
|
||||
article: '18',
|
||||
title: 'Recht auf Einschraenkung der Verarbeitung',
|
||||
description: 'Recht auf Markierung von Daten zur eingeschraenkten Verarbeitung',
|
||||
actions: ['Daten markieren', 'Verarbeitung einschraenken', 'Benachrichtigung bei Aufhebung'],
|
||||
sla: '30 Tage',
|
||||
},
|
||||
{
|
||||
article: '19',
|
||||
title: 'Mitteilungspflicht',
|
||||
description: 'Pflicht zur Mitteilung von Berichtigung, Loeschung oder Einschraenkung an Empfaenger',
|
||||
actions: ['Empfaenger ermitteln', 'Mitteilungen versenden', 'Protokollierung'],
|
||||
sla: 'Unverzueglich',
|
||||
},
|
||||
{
|
||||
article: '20',
|
||||
title: 'Recht auf Datenuebertragbarkeit',
|
||||
description: 'Recht auf Erhalt der Daten in strukturiertem, maschinenlesbarem Format',
|
||||
actions: ['JSON/CSV Export', 'API-Schnittstelle', 'Direkte Uebertragung'],
|
||||
sla: '30 Tage',
|
||||
},
|
||||
{
|
||||
article: '21',
|
||||
title: 'Widerspruchsrecht',
|
||||
description: 'Recht auf Widerspruch gegen Verarbeitung aus berechtigtem Interesse oder Direktwerbung',
|
||||
actions: ['Widerspruch erfassen', 'Verarbeitung stoppen', 'Marketing-Opt-out'],
|
||||
sla: 'Unverzueglich',
|
||||
},
|
||||
]
|
||||
|
||||
export const emailCategories = [
|
||||
{ key: 'onboarding', label: 'Onboarding', color: 'bg-blue-100 text-blue-700' },
|
||||
{ key: 'security', label: 'Sicherheit', color: 'bg-red-100 text-red-700' },
|
||||
{ key: 'consent', label: 'Zustimmung', color: 'bg-green-100 text-green-700' },
|
||||
{ key: 'gdpr', label: 'DSGVO', color: 'bg-purple-100 text-purple-700' },
|
||||
{ key: 'lifecycle', label: 'Lifecycle', color: 'bg-orange-100 text-orange-700' },
|
||||
]
|
||||
@@ -0,0 +1,291 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { API_BASE } from '../_types'
|
||||
import type {
|
||||
Tab, Document, Version, ApiEmailTemplate, ApiGdprProcess,
|
||||
ConsentStats, DsrOverview, EmailTemplateData,
|
||||
} from '../_types'
|
||||
|
||||
export function useConsentData(activeTab: Tab, selectedDocument: string) {
|
||||
const [documents, setDocuments] = useState<Document[]>([])
|
||||
const [versions, setVersions] = useState<Version[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Stats state
|
||||
const [consentStats, setConsentStats] = useState<ConsentStats>({ activeConsents: 0, documentCount: 0, openDSRs: 0 })
|
||||
|
||||
// GDPR tab state
|
||||
const [dsrCounts, setDsrCounts] = useState<Record<string, number>>({})
|
||||
const [dsrOverview, setDsrOverview] = useState<DsrOverview>({ open: 0, completed: 0, in_progress: 0, overdue: 0 })
|
||||
|
||||
// Email template editor state
|
||||
const [savedTemplates, setSavedTemplates] = useState<Record<string, EmailTemplateData>>({})
|
||||
|
||||
// API-backed email templates and GDPR processes
|
||||
const [apiEmailTemplates, setApiEmailTemplates] = useState<ApiEmailTemplate[]>([])
|
||||
const [apiGdprProcesses, setApiGdprProcesses] = useState<ApiGdprProcess[]>([])
|
||||
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>('')
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('bp_admin_token')
|
||||
if (token) {
|
||||
setAuthToken(token)
|
||||
}
|
||||
// Load saved email templates from localStorage
|
||||
try {
|
||||
const saved = localStorage.getItem('sdk-email-templates')
|
||||
if (saved) {
|
||||
setSavedTemplates(JSON.parse(saved))
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'documents') {
|
||||
loadDocuments()
|
||||
} else if (activeTab === 'versions' && selectedDocument) {
|
||||
loadVersions(selectedDocument)
|
||||
} else if (activeTab === 'stats') {
|
||||
loadStats()
|
||||
} else if (activeTab === 'gdpr') {
|
||||
loadGDPRData()
|
||||
loadApiGdprProcesses()
|
||||
} else if (activeTab === 'emails') {
|
||||
loadApiEmailTemplates()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/documents`, {
|
||||
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setDocuments(data.documents || [])
|
||||
} else {
|
||||
const errorData = await res.json().catch(() => ({}))
|
||||
setError(errorData.error || 'Fehler beim Laden der Dokumente')
|
||||
}
|
||||
} catch {
|
||||
setError('Verbindungsfehler zum Server')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVersions(docId: string) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/documents/${docId}/versions`, {
|
||||
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setVersions(data.versions || [])
|
||||
} else {
|
||||
const errorData = await res.json().catch(() => ({}))
|
||||
setError(errorData.error || 'Fehler beim Laden der Versionen')
|
||||
}
|
||||
} catch {
|
||||
setError('Verbindungsfehler zum Server')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const token = localStorage.getItem('bp_admin_token')
|
||||
const [statsRes, docsRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/stats`, {
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
|
||||
}),
|
||||
fetch(`${API_BASE}/documents`, {
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
|
||||
}),
|
||||
])
|
||||
|
||||
let activeConsents = 0
|
||||
let documentCount = 0
|
||||
let openDSRs = 0
|
||||
|
||||
if (statsRes.ok) {
|
||||
const statsData = await statsRes.json()
|
||||
activeConsents = statsData.total_consents || statsData.active_consents || 0
|
||||
}
|
||||
|
||||
if (docsRes.ok) {
|
||||
const docsData = await docsRes.json()
|
||||
documentCount = (docsData.documents || []).length
|
||||
}
|
||||
|
||||
// Try to get DSR count
|
||||
try {
|
||||
const dsrRes = await fetch('/api/sdk/v1/compliance/dsr', {
|
||||
headers: {
|
||||
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
|
||||
'X-User-ID': localStorage.getItem('bp_user_id') || '',
|
||||
}
|
||||
})
|
||||
if (dsrRes.ok) {
|
||||
const dsrData = await dsrRes.json()
|
||||
const dsrs = dsrData.dsrs || []
|
||||
openDSRs = dsrs.filter((r: any) => r.status !== 'completed' && r.status !== 'rejected').length
|
||||
}
|
||||
} catch { /* DSR endpoint might not be available */ }
|
||||
|
||||
setConsentStats({ activeConsents, documentCount, openDSRs })
|
||||
} catch (err) {
|
||||
console.error('Failed to load stats:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGDPRData() {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/dsr', {
|
||||
headers: {
|
||||
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
|
||||
'X-User-ID': localStorage.getItem('bp_user_id') || '',
|
||||
}
|
||||
})
|
||||
if (!res.ok) return
|
||||
|
||||
const data = await res.json()
|
||||
const dsrs = data.dsrs || []
|
||||
const now = new Date()
|
||||
|
||||
// Count per article type
|
||||
const counts: Record<string, number> = {}
|
||||
const typeMapping: Record<string, string> = {
|
||||
'access': '15',
|
||||
'rectification': '16',
|
||||
'erasure': '17',
|
||||
'restriction': '18',
|
||||
'portability': '20',
|
||||
'objection': '21',
|
||||
}
|
||||
|
||||
for (const dsr of dsrs) {
|
||||
if (dsr.status === 'completed' || dsr.status === 'rejected') continue
|
||||
const article = typeMapping[dsr.request_type]
|
||||
if (article) {
|
||||
counts[article] = (counts[article] || 0) + 1
|
||||
}
|
||||
}
|
||||
setDsrCounts(counts)
|
||||
|
||||
// Calculate overview
|
||||
const open = dsrs.filter((r: any) => r.status === 'received' || r.status === 'verified').length
|
||||
const completed = dsrs.filter((r: any) => r.status === 'completed').length
|
||||
const in_progress = dsrs.filter((r: any) => r.status === 'in_progress').length
|
||||
const overdue = dsrs.filter((r: any) => {
|
||||
if (r.status === 'completed' || r.status === 'rejected') return false
|
||||
const deadline = r.extended_deadline_at ? new Date(r.extended_deadline_at) : new Date(r.deadline_at)
|
||||
return deadline < now
|
||||
}).length
|
||||
|
||||
setDsrOverview({ open, completed, in_progress, overdue })
|
||||
} catch (err) {
|
||||
console.error('Failed to load GDPR data:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function saveEmailTemplate(template: EmailTemplateData) {
|
||||
const updated = { ...savedTemplates, [template.key]: template }
|
||||
setSavedTemplates(updated)
|
||||
localStorage.setItem('sdk-email-templates', JSON.stringify(updated))
|
||||
}
|
||||
|
||||
return {
|
||||
documents, versions, loading, error, setError,
|
||||
consentStats, dsrCounts, dsrOverview,
|
||||
savedTemplates, saveEmailTemplate,
|
||||
apiEmailTemplates, apiGdprProcesses,
|
||||
templatesLoading, gdprLoading,
|
||||
savingTemplateId, savingProcessId,
|
||||
saveApiEmailTemplate, saveApiGdprProcess,
|
||||
loadApiEmailTemplates,
|
||||
authToken, setAuthToken,
|
||||
}
|
||||
}
|
||||
63
admin-compliance/app/sdk/consent-management/_types.ts
Normal file
63
admin-compliance/app/sdk/consent-management/_types.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export const API_BASE = '/api/admin/consent'
|
||||
|
||||
export type Tab = 'documents' | 'versions' | 'emails' | 'gdpr' | 'stats'
|
||||
|
||||
export interface Document {
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
mandatory: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Version {
|
||||
id: string
|
||||
document_id: string
|
||||
version: string
|
||||
language: string
|
||||
title: string
|
||||
content: string
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Email template editor types
|
||||
export interface EmailTemplateData {
|
||||
key: string
|
||||
subject: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export interface ApiEmailTemplate {
|
||||
id: string
|
||||
template_key: string
|
||||
subject: string
|
||||
body: string
|
||||
language: string
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface ApiGdprProcess {
|
||||
id: string
|
||||
process_key: string
|
||||
title: string
|
||||
description: string
|
||||
legal_basis: string
|
||||
retention_days: number
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface ConsentStats {
|
||||
activeConsents: number
|
||||
documentCount: number
|
||||
openDSRs: number
|
||||
}
|
||||
|
||||
export interface DsrOverview {
|
||||
open: number
|
||||
completed: number
|
||||
in_progress: number
|
||||
overdue: number
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
import { AlertTriangle, CheckCircle2, Info } from 'lucide-react'
|
||||
|
||||
const SEVERITY_CONFIG: Record<string, { bg: string; label: string; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
critical: { bg: 'bg-red-100 text-red-800', label: 'Kritisch', icon: AlertTriangle },
|
||||
high: { bg: 'bg-orange-100 text-orange-800', label: 'Hoch', icon: AlertTriangle },
|
||||
medium: { bg: 'bg-yellow-100 text-yellow-800', label: 'Mittel', icon: Info },
|
||||
low: { bg: 'bg-green-100 text-green-800', label: 'Niedrig', icon: CheckCircle2 },
|
||||
}
|
||||
|
||||
export function SeverityBadge({ severity }: { severity: string }) {
|
||||
const config = SEVERITY_CONFIG[severity] || SEVERITY_CONFIG.medium
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>
|
||||
<Icon className="w-3 h-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function StateBadge({ state }: { state: string }) {
|
||||
const config: Record<string, string> = {
|
||||
draft: 'bg-gray-100 text-gray-600',
|
||||
review: 'bg-blue-100 text-blue-700',
|
||||
approved: 'bg-green-100 text-green-700',
|
||||
deprecated: 'bg-red-100 text-red-600',
|
||||
needs_review: 'bg-yellow-100 text-yellow-800',
|
||||
too_close: 'bg-red-100 text-red-700',
|
||||
duplicate: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
needs_review: 'Review noetig',
|
||||
too_close: 'Zu aehnlich',
|
||||
duplicate: 'Duplikat',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config[state] || config.draft}`}>
|
||||
{labels[state] || state}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function LicenseRuleBadge({ rule }: { rule: number | null | undefined }) {
|
||||
if (!rule) return null
|
||||
const config: Record<number, { bg: string; label: string }> = {
|
||||
1: { bg: 'bg-green-100 text-green-700', label: 'Free Use' },
|
||||
2: { bg: 'bg-blue-100 text-blue-700', label: 'Zitation' },
|
||||
3: { bg: 'bg-amber-100 text-amber-700', label: 'Reformuliert' },
|
||||
}
|
||||
const c = config[rule]
|
||||
if (!c) return null
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${c.bg}`}>{c.label}</span>
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Shield, ArrowLeft, ExternalLink, CheckCircle2, Lock,
|
||||
FileText, BookOpen, Scale, Pencil, Trash2, Eye, Clock,
|
||||
} from 'lucide-react'
|
||||
import type { CanonicalControl } from '../_types'
|
||||
import { EFFORT_LABELS } from '../_types'
|
||||
import { SeverityBadge, StateBadge, LicenseRuleBadge } from './Badges'
|
||||
|
||||
export function ControlDetailView({
|
||||
ctrl,
|
||||
onBack,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReview,
|
||||
}: {
|
||||
ctrl: CanonicalControl
|
||||
onBack: () => void
|
||||
onEdit: () => void
|
||||
onDelete: (controlId: string) => void
|
||||
onReview: (controlId: string, action: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<button onClick={onBack} className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700">
|
||||
<ArrowLeft className="w-4 h-4" /> Zurueck zur Uebersicht
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onEdit} className="flex items-center gap-1 px-3 py-1.5 text-sm text-purple-600 border border-purple-300 rounded-lg hover:bg-purple-50">
|
||||
<Pencil className="w-3.5 h-3.5" /> Bearbeiten
|
||||
</button>
|
||||
<button onClick={() => onDelete(ctrl.control_id)} className="flex items-center gap-1 px-3 py-1.5 text-sm text-red-600 border border-red-300 rounded-lg hover:bg-red-50">
|
||||
<Trash2 className="w-3.5 h-3.5" /> Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<Shield className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-mono text-purple-600">{ctrl.control_id}</span>
|
||||
<SeverityBadge severity={ctrl.severity} />
|
||||
<StateBadge state={ctrl.release_state} />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-gray-900">{ctrl.title}</h1>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
|
||||
{ctrl.risk_score !== null && <span>Risiko-Score: {ctrl.risk_score}/10</span>}
|
||||
{ctrl.implementation_effort && <span>Aufwand: {EFFORT_LABELS[ctrl.implementation_effort] || ctrl.implementation_effort}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Objective & Rationale */}
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Ziel</h3>
|
||||
<p className="text-sm text-gray-700 bg-gray-50 rounded-lg p-4">{ctrl.objective}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Begruendung</h3>
|
||||
<p className="text-sm text-gray-700 bg-gray-50 rounded-lg p-4">{ctrl.rationale}</p>
|
||||
</section>
|
||||
|
||||
{/* Scope */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Geltungsbereich</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{ctrl.scope.platforms && ctrl.scope.platforms.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">Plattformen</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ctrl.scope.platforms.map(p => (
|
||||
<span key={p} className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs">{p}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ctrl.scope.components && ctrl.scope.components.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">Komponenten</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ctrl.scope.components.map(c => (
|
||||
<span key={c} className="px-2 py-0.5 bg-purple-50 text-purple-700 rounded text-xs">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ctrl.scope.data_classes && ctrl.scope.data_classes.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">Datenklassen</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ctrl.scope.data_classes.map(d => (
|
||||
<span key={d} className="px-2 py-0.5 bg-amber-50 text-amber-700 rounded text-xs">{d}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Requirements */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
||||
<ol className="space-y-2">
|
||||
{ctrl.requirements.map((req, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<span className="flex-shrink-0 w-5 h-5 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center text-xs font-medium mt-0.5">{i + 1}</span>
|
||||
{req}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Test Procedure */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
||||
<ol className="space-y-2">
|
||||
{ctrl.test_procedure.map((step, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Evidence */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Nachweisanforderungen</h3>
|
||||
<div className="space-y-2">
|
||||
{ctrl.evidence.map((ev, i) => (
|
||||
<div key={i} className="flex items-start gap-2 p-3 bg-gray-50 rounded-lg">
|
||||
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-500 uppercase">{ev.type}</span>
|
||||
<p className="text-sm text-gray-700">{ev.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Open Anchors — THE KEY SECTION */}
|
||||
<section className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<BookOpen className="w-4 h-4 text-green-700" />
|
||||
<h3 className="text-sm font-semibold text-green-900">Open-Source-Referenzen</h3>
|
||||
<span className="text-xs text-green-600">({ctrl.open_anchors.length} Quellen)</span>
|
||||
</div>
|
||||
<p className="text-xs text-green-700 mb-3">
|
||||
Dieses Control basiert auf frei verfuegbarem Wissen. Alle Referenzen sind offen und oeffentlich zugaenglich.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{ctrl.open_anchors.map((anchor, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-2 bg-white rounded border border-green-100">
|
||||
<Scale className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-semibold text-green-800">{anchor.framework}</span>
|
||||
<p className="text-sm text-gray-700">{anchor.ref}</p>
|
||||
</div>
|
||||
<a
|
||||
href={anchor.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 flex-shrink-0"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Quelle
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tags */}
|
||||
{ctrl.tags.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Tags</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ctrl.tags.map(tag => (
|
||||
<span key={tag} className="px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* License & Citation Info */}
|
||||
{ctrl.license_rule && (
|
||||
<section className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Scale className="w-4 h-4 text-blue-700" />
|
||||
<h3 className="text-sm font-semibold text-blue-900">Lizenzinformationen</h3>
|
||||
<LicenseRuleBadge rule={ctrl.license_rule} />
|
||||
</div>
|
||||
{ctrl.source_citation && (
|
||||
<div className="text-xs text-blue-800 space-y-1">
|
||||
<p><span className="font-medium">Quelle:</span> {ctrl.source_citation.source}</p>
|
||||
{ctrl.source_citation.license && <p><span className="font-medium">Lizenz:</span> {ctrl.source_citation.license}</p>}
|
||||
{ctrl.source_citation.license_notice && <p><span className="font-medium">Hinweis:</span> {ctrl.source_citation.license_notice}</p>}
|
||||
{ctrl.source_citation.url && (
|
||||
<a href={ctrl.source_citation.url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 text-blue-600 hover:text-blue-800">
|
||||
<ExternalLink className="w-3 h-3" /> Originalquelle
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{ctrl.source_original_text && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs text-blue-600 cursor-pointer hover:text-blue-800">Originaltext anzeigen</summary>
|
||||
<p className="mt-1 text-xs text-gray-700 bg-white rounded p-2 border border-blue-100 max-h-40 overflow-y-auto">{ctrl.source_original_text}</p>
|
||||
</details>
|
||||
)}
|
||||
{ctrl.license_rule === 3 && (
|
||||
<p className="text-xs text-amber-700 mt-2 flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" />
|
||||
Eigenstaendig formuliert — keine Originalquelle gespeichert
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Generation Metadata (internal) */}
|
||||
{ctrl.generation_metadata && (
|
||||
<section className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-gray-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-700">Generierungsdetails (intern)</h3>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 space-y-1">
|
||||
<p>Pfad: {String(ctrl.generation_metadata.processing_path || '-')}</p>
|
||||
{ctrl.generation_metadata.similarity_status && (
|
||||
<p className="text-red-600">Similarity: {String(ctrl.generation_metadata.similarity_status)}</p>
|
||||
)}
|
||||
{Array.isArray(ctrl.generation_metadata.similar_controls) && (
|
||||
<div>
|
||||
<p className="font-medium">Aehnliche Controls:</p>
|
||||
{(ctrl.generation_metadata.similar_controls as Array<Record<string, unknown>>).map((s, i) => (
|
||||
<p key={i} className="ml-2">{String(s.control_id)} — {String(s.title)} ({String(s.similarity)})</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Review Actions */}
|
||||
{['needs_review', 'too_close', 'duplicate'].includes(ctrl.release_state) && (
|
||||
<section className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Eye className="w-4 h-4 text-yellow-700" />
|
||||
<h3 className="text-sm font-semibold text-yellow-900">Review erforderlich</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onReview(ctrl.control_id, 'approve')}
|
||||
className="px-3 py-1.5 text-sm text-white bg-green-600 rounded-lg hover:bg-green-700"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5 inline mr-1" />
|
||||
Akzeptieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onReview(ctrl.control_id, 'reject')}
|
||||
className="px-3 py-1.5 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 inline mr-1" />
|
||||
Ablehnen
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5 inline mr-1" />
|
||||
Ueberarbeiten
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { BookOpen, Trash2, Save, X } from 'lucide-react'
|
||||
import type { ControlFormData } from '../_types'
|
||||
|
||||
export function ControlForm({
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
saving,
|
||||
}: {
|
||||
initial: ControlFormData
|
||||
onSave: (data: ControlFormData) => void
|
||||
onCancel: () => void
|
||||
saving: boolean
|
||||
}) {
|
||||
const [form, setForm] = useState(initial)
|
||||
const [tagInput, setTagInput] = useState(initial.tags.join(', '))
|
||||
const [platformInput, setPlatformInput] = useState((initial.scope.platforms || []).join(', '))
|
||||
const [componentInput, setComponentInput] = useState((initial.scope.components || []).join(', '))
|
||||
const [dataClassInput, setDataClassInput] = useState((initial.scope.data_classes || []).join(', '))
|
||||
|
||||
const handleSave = () => {
|
||||
const data = {
|
||||
...form,
|
||||
tags: tagInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
scope: {
|
||||
platforms: platformInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
components: componentInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
data_classes: dataClassInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
},
|
||||
requirements: form.requirements.filter(r => r.trim()),
|
||||
test_procedure: form.test_procedure.filter(r => r.trim()),
|
||||
evidence: form.evidence.filter(e => e.type.trim() || e.description.trim()),
|
||||
open_anchors: form.open_anchors.filter(a => a.framework.trim() || a.ref.trim()),
|
||||
}
|
||||
onSave(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{initial.control_id ? `Control ${initial.control_id} bearbeiten` : 'Neues Control erstellen'}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onCancel} className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
<X className="w-4 h-4 inline mr-1" />Abbrechen
|
||||
</button>
|
||||
<button onClick={handleSave} disabled={saving} className="px-3 py-1.5 text-sm text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
<Save className="w-4 h-4 inline mr-1" />{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic fields */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Control-ID *</label>
|
||||
<input
|
||||
value={form.control_id}
|
||||
onChange={e => setForm({ ...form, control_id: e.target.value.toUpperCase() })}
|
||||
placeholder="AUTH-003"
|
||||
disabled={!!initial.control_id}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none disabled:bg-gray-100"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Format: DOMAIN-NNN (z.B. AUTH-003, NET-005)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Titel *</label>
|
||||
<input
|
||||
value={form.title}
|
||||
onChange={e => setForm({ ...form, title: e.target.value })}
|
||||
placeholder="Control-Titel"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Schweregrad</label>
|
||||
<select value={form.severity} onChange={e => setForm({ ...form, severity: e.target.value })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<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-600 mb-1">Risiko-Score (0-10)</label>
|
||||
<input
|
||||
type="number" min="0" max="10" step="0.5"
|
||||
value={form.risk_score ?? ''}
|
||||
onChange={e => setForm({ ...form, risk_score: e.target.value ? parseFloat(e.target.value) : null })}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Aufwand</label>
|
||||
<select value={form.implementation_effort || ''} onChange={e => setForm({ ...form, implementation_effort: e.target.value || null })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="">-</option>
|
||||
<option value="s">Klein (S)</option>
|
||||
<option value="m">Mittel (M)</option>
|
||||
<option value="l">Gross (L)</option>
|
||||
<option value="xl">Sehr gross (XL)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Objective & Rationale */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Ziel *</label>
|
||||
<textarea
|
||||
value={form.objective}
|
||||
onChange={e => setForm({ ...form, objective: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Begruendung *</label>
|
||||
<textarea
|
||||
value={form.rationale}
|
||||
onChange={e => setForm({ ...form, rationale: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scope */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Plattformen (komma-getrennt)</label>
|
||||
<input value={platformInput} onChange={e => setPlatformInput(e.target.value)} placeholder="web, mobile, api" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Komponenten (komma-getrennt)</label>
|
||||
<input value={componentInput} onChange={e => setComponentInput(e.target.value)} placeholder="auth-service, gateway" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Datenklassen (komma-getrennt)</label>
|
||||
<input value={dataClassInput} onChange={e => setDataClassInput(e.target.value)} placeholder="credentials, tokens" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirements */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-medium text-gray-600">Anforderungen</label>
|
||||
<button onClick={() => setForm({ ...form, requirements: [...form.requirements, ''] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
|
||||
</div>
|
||||
{form.requirements.map((req, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<span className="text-xs text-gray-400 mt-2.5">{i + 1}.</span>
|
||||
<input
|
||||
value={req}
|
||||
onChange={e => { const r = [...form.requirements]; r[i] = e.target.value; setForm({ ...form, requirements: r }) }}
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, requirements: form.requirements.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Test Procedure */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-medium text-gray-600">Pruefverfahren</label>
|
||||
<button onClick={() => setForm({ ...form, test_procedure: [...form.test_procedure, ''] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
|
||||
</div>
|
||||
{form.test_procedure.map((step, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<span className="text-xs text-gray-400 mt-2.5">{i + 1}.</span>
|
||||
<input
|
||||
value={step}
|
||||
onChange={e => { const t = [...form.test_procedure]; t[i] = e.target.value; setForm({ ...form, test_procedure: t }) }}
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, test_procedure: form.test_procedure.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Evidence */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-medium text-gray-600">Nachweisanforderungen</label>
|
||||
<button onClick={() => setForm({ ...form, evidence: [...form.evidence, { type: '', description: '' }] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
|
||||
</div>
|
||||
{form.evidence.map((ev, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<input
|
||||
value={ev.type}
|
||||
onChange={e => { const evs = [...form.evidence]; evs[i] = { ...evs[i], type: e.target.value }; setForm({ ...form, evidence: evs }) }}
|
||||
placeholder="Typ (z.B. config, test_result)"
|
||||
className="w-32 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<input
|
||||
value={ev.description}
|
||||
onChange={e => { const evs = [...form.evidence]; evs[i] = { ...evs[i], description: e.target.value }; setForm({ ...form, evidence: evs }) }}
|
||||
placeholder="Beschreibung"
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, evidence: form.evidence.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Open Anchors */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-green-700" />
|
||||
<label className="text-xs font-semibold text-green-900">Open-Source-Referenzen *</label>
|
||||
</div>
|
||||
<button onClick={() => setForm({ ...form, open_anchors: [...form.open_anchors, { framework: '', ref: '', url: '' }] })} className="text-xs text-green-700 hover:text-green-900">+ Hinzufuegen</button>
|
||||
</div>
|
||||
<p className="text-xs text-green-600 mb-3">Jedes Control braucht mindestens eine offene Referenz (OWASP, NIST, ENISA, etc.)</p>
|
||||
{form.open_anchors.map((anchor, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<input
|
||||
value={anchor.framework}
|
||||
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], framework: e.target.value }; setForm({ ...form, open_anchors: a }) }}
|
||||
placeholder="Framework (z.B. OWASP ASVS)"
|
||||
className="w-40 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
|
||||
/>
|
||||
<input
|
||||
value={anchor.ref}
|
||||
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], ref: e.target.value }; setForm({ ...form, open_anchors: a }) }}
|
||||
placeholder="Referenz (z.B. V2.8)"
|
||||
className="w-48 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
|
||||
/>
|
||||
<input
|
||||
value={anchor.url}
|
||||
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], url: e.target.value }; setForm({ ...form, open_anchors: a }) }}
|
||||
placeholder="https://..."
|
||||
className="flex-1 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, open_anchors: form.open_anchors.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tags & State */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Tags (komma-getrennt)</label>
|
||||
<input value={tagInput} onChange={e => setTagInput(e.target.value)} placeholder="mfa, auth, iam" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Status</label>
|
||||
<select value={form.release_state} onChange={e => setForm({ ...form, release_state: e.target.value })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="review">Review</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="deprecated">Deprecated</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Shield, Search, ChevronRight, Filter, Lock,
|
||||
BookOpen, Plus, Zap, BarChart3,
|
||||
} from 'lucide-react'
|
||||
import type { CanonicalControl, Framework } from '../_types'
|
||||
import { SeverityBadge, StateBadge, LicenseRuleBadge } from './Badges'
|
||||
|
||||
export function ControlListView({
|
||||
controls,
|
||||
filteredControls,
|
||||
frameworks,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
severityFilter,
|
||||
setSeverityFilter,
|
||||
domainFilter,
|
||||
setDomainFilter,
|
||||
stateFilter,
|
||||
setStateFilter,
|
||||
domains,
|
||||
showStats,
|
||||
toggleStats,
|
||||
processedStats,
|
||||
onOpenGenerator,
|
||||
onCreate,
|
||||
onSelect,
|
||||
}: {
|
||||
controls: CanonicalControl[]
|
||||
filteredControls: CanonicalControl[]
|
||||
frameworks: Framework[]
|
||||
searchQuery: string
|
||||
setSearchQuery: (v: string) => void
|
||||
severityFilter: string
|
||||
setSeverityFilter: (v: string) => void
|
||||
domainFilter: string
|
||||
setDomainFilter: (v: string) => void
|
||||
stateFilter: string
|
||||
setStateFilter: (v: string) => void
|
||||
domains: string[]
|
||||
showStats: boolean
|
||||
toggleStats: () => void
|
||||
processedStats: Array<Record<string, unknown>>
|
||||
onOpenGenerator: () => void
|
||||
onCreate: () => void
|
||||
onSelect: (ctrl: CanonicalControl) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-6 h-6 text-purple-600" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Canonical Control Library</h1>
|
||||
<p className="text-xs text-gray-500">
|
||||
{controls.length} unabhaengig formulierte Security Controls —{' '}
|
||||
{controls.reduce((sum, c) => sum + c.open_anchors.length, 0)} Open-Source-Referenzen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={toggleStats}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
Stats
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenGenerator}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-amber-600 rounded-lg hover:bg-amber-700"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
Generator
|
||||
</button>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-purple-600 rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Neues Control
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Frameworks */}
|
||||
{frameworks.length > 0 && (
|
||||
<div className="mb-4 p-3 bg-purple-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-xs text-purple-700">
|
||||
<Lock className="w-3 h-3" />
|
||||
<span className="font-medium">{frameworks[0]?.name} v{frameworks[0]?.version}</span>
|
||||
<span className="text-purple-500">—</span>
|
||||
<span>{frameworks[0]?.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Controls durchsuchen..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<select
|
||||
value={severityFilter}
|
||||
onChange={e => setSeverityFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Alle Schweregrade</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="low">Niedrig</option>
|
||||
</select>
|
||||
<select
|
||||
value={domainFilter}
|
||||
onChange={e => setDomainFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Alle Domains</option>
|
||||
{domains.map(d => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={stateFilter}
|
||||
onChange={e => setStateFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Alle Status</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="needs_review">Review noetig</option>
|
||||
<option value="too_close">Zu aehnlich</option>
|
||||
<option value="duplicate">Duplikat</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Processing Stats */}
|
||||
{showStats && processedStats.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
|
||||
<h4 className="text-xs font-semibold text-gray-700 mb-2">Verarbeitungsfortschritt</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{processedStats.map((s, i) => (
|
||||
<div key={i} className="text-xs">
|
||||
<span className="font-medium text-gray-700">{String(s.collection)}</span>
|
||||
<div className="flex gap-2 mt-1 text-gray-500">
|
||||
<span>{String(s.processed_chunks)} verarbeitet</span>
|
||||
<span>{String(s.direct_adopted)} direkt</span>
|
||||
<span>{String(s.llm_reformed)} reformuliert</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Control List */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="space-y-3">
|
||||
{filteredControls.map(ctrl => (
|
||||
<button
|
||||
key={ctrl.control_id}
|
||||
onClick={() => onSelect(ctrl)}
|
||||
className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:border-purple-300 hover:shadow-sm transition-all group"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{ctrl.control_id}</span>
|
||||
<SeverityBadge severity={ctrl.severity} />
|
||||
<StateBadge state={ctrl.release_state} />
|
||||
<LicenseRuleBadge rule={ctrl.license_rule} />
|
||||
{ctrl.risk_score !== null && (
|
||||
<span className="text-xs text-gray-400">Score: {ctrl.risk_score}</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-900 group-hover:text-purple-700">{ctrl.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{ctrl.objective}</p>
|
||||
|
||||
{/* Open anchors summary */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<BookOpen className="w-3 h-3 text-green-600" />
|
||||
<span className="text-xs text-green-700">
|
||||
{ctrl.open_anchors.length} Open-Source-Referenzen:
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{ctrl.open_anchors.map(a => a.framework).filter((v, i, arr) => arr.indexOf(v) === i).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-purple-500 flex-shrink-0 mt-1 ml-4" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{filteredControls.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
{controls.length === 0
|
||||
? 'Noch keine Controls vorhanden. Klicke auf "Neues Control" um zu starten.'
|
||||
: 'Keine Controls gefunden.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import { Zap, X, RefreshCw } from 'lucide-react'
|
||||
|
||||
export function GeneratorModal({
|
||||
genDomain,
|
||||
setGenDomain,
|
||||
genMaxControls,
|
||||
setGenMaxControls,
|
||||
genDryRun,
|
||||
setGenDryRun,
|
||||
generating,
|
||||
genResult,
|
||||
onGenerate,
|
||||
onClose,
|
||||
}: {
|
||||
genDomain: string
|
||||
setGenDomain: (v: string) => void
|
||||
genMaxControls: number
|
||||
setGenMaxControls: (v: number) => void
|
||||
genDryRun: boolean
|
||||
setGenDryRun: (v: boolean) => void
|
||||
generating: boolean
|
||||
genResult: Record<string, unknown> | null
|
||||
onGenerate: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6 mx-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-amber-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">Control Generator</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Domain (optional)</label>
|
||||
<select value={genDomain} onChange={e => setGenDomain(e.target.value)} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="">Alle Domains</option>
|
||||
<option value="AUTH">AUTH — Authentifizierung</option>
|
||||
<option value="CRYPT">CRYPT — Kryptographie</option>
|
||||
<option value="NET">NET — Netzwerk</option>
|
||||
<option value="DATA">DATA — Datenschutz</option>
|
||||
<option value="LOG">LOG — Logging</option>
|
||||
<option value="ACC">ACC — Zugriffskontrolle</option>
|
||||
<option value="SEC">SEC — Sicherheit</option>
|
||||
<option value="INC">INC — Incident Response</option>
|
||||
<option value="AI">AI — Kuenstliche Intelligenz</option>
|
||||
<option value="COMP">COMP — Compliance</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Max. Controls: {genMaxControls}</label>
|
||||
<input
|
||||
type="range" min="1" max="100" step="1"
|
||||
value={genMaxControls}
|
||||
onChange={e => setGenMaxControls(parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="dryRun"
|
||||
checked={genDryRun}
|
||||
onChange={e => setGenDryRun(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="dryRun" className="text-sm text-gray-700">Dry Run (Vorschau ohne Speicherung)</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onGenerate}
|
||||
disabled={generating}
|
||||
className="w-full py-2 text-sm text-white bg-amber-600 rounded-lg hover:bg-amber-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{generating ? (
|
||||
<><RefreshCw className="w-4 h-4 animate-spin" /> Generiere...</>
|
||||
) : (
|
||||
<><Zap className="w-4 h-4" /> Generierung starten</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Results */}
|
||||
{genResult && (
|
||||
<div className={`p-4 rounded-lg text-sm ${genResult.status === 'error' ? 'bg-red-50 text-red-800' : 'bg-green-50 text-green-800'}`}>
|
||||
<p className="font-medium mb-1">{String(genResult.message || genResult.status)}</p>
|
||||
{genResult.status !== 'error' && (
|
||||
<div className="grid grid-cols-2 gap-1 text-xs mt-2">
|
||||
<span>Chunks gescannt: {String(genResult.total_chunks_scanned)}</span>
|
||||
<span>Controls generiert: {String(genResult.controls_generated)}</span>
|
||||
<span>Verifiziert: {String(genResult.controls_verified)}</span>
|
||||
<span>Review noetig: {String(genResult.controls_needs_review)}</span>
|
||||
<span>Zu aehnlich: {String(genResult.controls_too_close)}</span>
|
||||
<span>Duplikate: {String(genResult.controls_duplicates_found)}</span>
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(genResult.errors) && (genResult.errors as string[]).length > 0 && (
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
{(genResult.errors as string[]).slice(0, 3).map((e, i) => <p key={i}>{e}</p>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
admin-compliance/app/sdk/control-library/_types.ts
Normal file
87
admin-compliance/app/sdk/control-library/_types.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface OpenAnchor {
|
||||
framework: string
|
||||
ref: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface EvidenceItem {
|
||||
type: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface CanonicalControl {
|
||||
id: string
|
||||
framework_id: string
|
||||
control_id: string
|
||||
title: string
|
||||
objective: string
|
||||
rationale: string
|
||||
scope: {
|
||||
platforms?: string[]
|
||||
components?: string[]
|
||||
data_classes?: string[]
|
||||
}
|
||||
requirements: string[]
|
||||
test_procedure: string[]
|
||||
evidence: EvidenceItem[]
|
||||
severity: string
|
||||
risk_score: number | null
|
||||
implementation_effort: string | null
|
||||
evidence_confidence: number | null
|
||||
open_anchors: OpenAnchor[]
|
||||
release_state: string
|
||||
tags: string[]
|
||||
license_rule?: number | null
|
||||
source_original_text?: string | null
|
||||
source_citation?: Record<string, string> | null
|
||||
customer_visible?: boolean
|
||||
generation_metadata?: Record<string, unknown> | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Framework {
|
||||
id: string
|
||||
framework_id: string
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
release_state: string
|
||||
}
|
||||
|
||||
export const EFFORT_LABELS: Record<string, string> = {
|
||||
s: 'Klein (S)',
|
||||
m: 'Mittel (M)',
|
||||
l: 'Gross (L)',
|
||||
xl: 'Sehr gross (XL)',
|
||||
}
|
||||
|
||||
export const BACKEND_URL = '/api/sdk/v1/canonical'
|
||||
|
||||
export const EMPTY_CONTROL = {
|
||||
framework_id: 'bp_security_v1',
|
||||
control_id: '',
|
||||
title: '',
|
||||
objective: '',
|
||||
rationale: '',
|
||||
scope: { platforms: [] as string[], components: [] as string[], data_classes: [] as string[] },
|
||||
requirements: [''],
|
||||
test_procedure: [''],
|
||||
evidence: [{ type: '', description: '' }],
|
||||
severity: 'medium',
|
||||
risk_score: null as number | null,
|
||||
implementation_effort: 'm' as string | null,
|
||||
open_anchors: [{ framework: '', ref: '', url: '' }],
|
||||
release_state: 'draft',
|
||||
tags: [] as string[],
|
||||
}
|
||||
|
||||
export type ControlFormData = typeof EMPTY_CONTROL
|
||||
|
||||
export function getDomain(controlId: string): string {
|
||||
return controlId.split('-')[0] || ''
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
Incident,
|
||||
getHoursUntil72hDeadline,
|
||||
is72hDeadlineExpired
|
||||
} from '@/lib/sdk/incidents/types'
|
||||
|
||||
/**
|
||||
* 72h-Countdown-Anzeige mit visueller Farbkodierung
|
||||
* Gruen > 48h, Gelb > 24h, Orange > 12h, Rot < 12h oder abgelaufen
|
||||
*/
|
||||
export function CountdownTimer({ incident }: { incident: Incident }) {
|
||||
const hoursRemaining = getHoursUntil72hDeadline(incident.detectedAt)
|
||||
const expired = is72hDeadlineExpired(incident.detectedAt)
|
||||
|
||||
// Nicht relevant fuer abgeschlossene Vorfaelle
|
||||
if (incident.status === 'closed') return null
|
||||
|
||||
// Bereits gemeldet
|
||||
if (incident.authorityNotification && (incident.authorityNotification.status === 'submitted' || incident.authorityNotification.status === 'acknowledged')) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Gemeldet
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Keine Meldepflicht festgestellt
|
||||
if (incident.riskAssessment && !incident.riskAssessment.notificationRequired) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 rounded-full">
|
||||
Keine Meldepflicht
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Abgelaufen
|
||||
if (expired) {
|
||||
const overdueHours = Math.abs(hoursRemaining)
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-bold bg-red-100 text-red-700 rounded-full animate-pulse">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{overdueHours.toFixed(0)}h ueberfaellig
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Farbkodierung: gruen > 48h, gelb > 24h, orange > 12h, rot < 12h
|
||||
let colorClass: string
|
||||
if (hoursRemaining > 48) {
|
||||
colorClass = 'bg-green-100 text-green-700'
|
||||
} else if (hoursRemaining > 24) {
|
||||
colorClass = 'bg-yellow-100 text-yellow-700'
|
||||
} else if (hoursRemaining > 12) {
|
||||
colorClass = 'bg-orange-100 text-orange-700'
|
||||
} else {
|
||||
colorClass = 'bg-red-100 text-red-700'
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-bold rounded-full ${colorClass}`}>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{hoursRemaining.toFixed(0)}h verbleibend
|
||||
</span>
|
||||
)
|
||||
}
|
||||
83
admin-compliance/app/sdk/incidents/_components/FilterBar.tsx
Normal file
83
admin-compliance/app/sdk/incidents/_components/FilterBar.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
IncidentSeverity,
|
||||
IncidentStatus,
|
||||
IncidentCategory,
|
||||
INCIDENT_SEVERITY_INFO,
|
||||
INCIDENT_STATUS_INFO,
|
||||
INCIDENT_CATEGORY_INFO
|
||||
} from '@/lib/sdk/incidents/types'
|
||||
|
||||
export function FilterBar({
|
||||
selectedSeverity,
|
||||
selectedStatus,
|
||||
selectedCategory,
|
||||
onSeverityChange,
|
||||
onStatusChange,
|
||||
onCategoryChange,
|
||||
onClear
|
||||
}: {
|
||||
selectedSeverity: IncidentSeverity | 'all'
|
||||
selectedStatus: IncidentStatus | 'all'
|
||||
selectedCategory: IncidentCategory | 'all'
|
||||
onSeverityChange: (severity: IncidentSeverity | 'all') => void
|
||||
onStatusChange: (status: IncidentStatus | 'all') => void
|
||||
onCategoryChange: (category: IncidentCategory | 'all') => void
|
||||
onClear: () => void
|
||||
}) {
|
||||
const hasFilters = selectedSeverity !== 'all' || selectedStatus !== 'all' || selectedCategory !== 'all'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
|
||||
{/* Severity Filter */}
|
||||
<select
|
||||
value={selectedSeverity}
|
||||
onChange={(e) => onSeverityChange(e.target.value as IncidentSeverity | 'all')}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="all">Alle Schweregrade</option>
|
||||
{Object.entries(INCIDENT_SEVERITY_INFO).map(([severity, info]) => (
|
||||
<option key={severity} value={severity}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Status Filter */}
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => onStatusChange(e.target.value as IncidentStatus | 'all')}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="all">Alle Status</option>
|
||||
{Object.entries(INCIDENT_STATUS_INFO).map(([status, info]) => (
|
||||
<option key={status} value={status}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Category Filter */}
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => onCategoryChange(e.target.value as IncidentCategory | 'all')}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="all">Alle Kategorien</option>
|
||||
{Object.entries(INCIDENT_CATEGORY_INFO).map(([cat, info]) => (
|
||||
<option key={cat} value={cat}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
125
admin-compliance/app/sdk/incidents/_components/IncidentCard.tsx
Normal file
125
admin-compliance/app/sdk/incidents/_components/IncidentCard.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
Incident,
|
||||
IncidentSeverity,
|
||||
INCIDENT_SEVERITY_INFO,
|
||||
INCIDENT_STATUS_INFO,
|
||||
INCIDENT_CATEGORY_INFO,
|
||||
is72hDeadlineExpired
|
||||
} from '@/lib/sdk/incidents/types'
|
||||
import { CountdownTimer } from './CountdownTimer'
|
||||
|
||||
function Badge({ bgColor, color, label }: { bgColor: string; color: string; label: string }) {
|
||||
return <span className={`px-2 py-1 text-xs rounded-full ${bgColor} ${color}`}>{label}</span>
|
||||
}
|
||||
|
||||
export function IncidentCard({ incident, onClick }: { incident: Incident; onClick?: () => void }) {
|
||||
const severityInfo = INCIDENT_SEVERITY_INFO[incident.severity]
|
||||
const statusInfo = INCIDENT_STATUS_INFO[incident.status]
|
||||
const categoryInfo = INCIDENT_CATEGORY_INFO[incident.category]
|
||||
|
||||
const expired = is72hDeadlineExpired(incident.detectedAt)
|
||||
const isNotified = incident.authorityNotification && (incident.authorityNotification.status === 'submitted' || incident.authorityNotification.status === 'acknowledged')
|
||||
|
||||
const severityBorderColors: Record<IncidentSeverity, string> = {
|
||||
critical: 'border-red-300 hover:border-red-400',
|
||||
high: 'border-orange-300 hover:border-orange-400',
|
||||
medium: 'border-yellow-300 hover:border-yellow-400',
|
||||
low: 'border-green-200 hover:border-green-300'
|
||||
}
|
||||
|
||||
const borderColor = incident.status === 'closed'
|
||||
? 'border-green-200 hover:border-green-300'
|
||||
: expired && !isNotified
|
||||
? 'border-red-400 hover:border-red-500'
|
||||
: severityBorderColors[incident.severity]
|
||||
|
||||
const measuresCount = incident.measures.length
|
||||
const completedMeasures = incident.measures.filter(m => m.status === 'completed').length
|
||||
|
||||
return (
|
||||
<div onClick={onClick} className="cursor-pointer">
|
||||
<div className={`
|
||||
bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer
|
||||
${borderColor}
|
||||
`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header Badges */}
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="text-xs text-gray-500 font-mono">
|
||||
{incident.referenceNumber}
|
||||
</span>
|
||||
<Badge bgColor={severityInfo.bgColor} color={severityInfo.color} label={severityInfo.label} />
|
||||
<Badge bgColor={categoryInfo.bgColor} color={categoryInfo.color} label={`${categoryInfo.icon} ${categoryInfo.label}`} />
|
||||
<Badge bgColor={statusInfo.bgColor} color={statusInfo.color} label={statusInfo.label} />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{incident.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
|
||||
{incident.description}
|
||||
</p>
|
||||
|
||||
{/* 72h Countdown - prominent */}
|
||||
<div className="mt-3">
|
||||
<CountdownTimer incident={incident} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Key Numbers */}
|
||||
<div className="text-right ml-4 flex-shrink-0">
|
||||
<div className="text-sm text-gray-500">
|
||||
Betroffene
|
||||
</div>
|
||||
<div className="text-xl font-bold text-gray-900">
|
||||
{incident.estimatedAffectedPersons.toLocaleString('de-DE')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{new Date(incident.detectedAt).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
{completedMeasures}/{measuresCount} Massnahmen
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{incident.timeline.length} Eintraege
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">
|
||||
{incident.assignedTo
|
||||
? `Zugewiesen: ${incident.assignedTo}`
|
||||
: 'Nicht zugewiesen'
|
||||
}
|
||||
</span>
|
||||
{incident.status !== 'closed' ? (
|
||||
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
|
||||
Bearbeiten
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Details
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
export function IncidentCreateModal({
|
||||
onClose,
|
||||
onSuccess
|
||||
}: {
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [category, setCategory] = useState('data_breach')
|
||||
const [severity, setSeverity] = useState('medium')
|
||||
const [description, setDescription] = useState('')
|
||||
const [detectedBy, setDetectedBy] = useState('')
|
||||
const [affectedSystems, setAffectedSystems] = useState('')
|
||||
const [estimatedAffectedPersons, setEstimatedAffectedPersons] = useState('0')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!title.trim()) {
|
||||
setError('Titel ist erforderlich.')
|
||||
return
|
||||
}
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/incidents', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
category,
|
||||
severity,
|
||||
description,
|
||||
detectedBy,
|
||||
affectedSystems: affectedSystems.split(',').map(s => s.trim()).filter(Boolean),
|
||||
estimatedAffectedPersons: Number(estimatedAffectedPersons)
|
||||
})
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.detail || data.message || `Fehler: ${res.status}`)
|
||||
}
|
||||
onSuccess()
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4 p-6 z-10 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Neuen Vorfall erfassen</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 rounded 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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Title */}
|
||||
<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 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
placeholder="Kurze Beschreibung des Vorfalls"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<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 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
>
|
||||
<option value="data_breach">Datenpanne</option>
|
||||
<option value="unauthorized_access">Unbefugter Zugriff</option>
|
||||
<option value="data_loss">Datenverlust</option>
|
||||
<option value="system_compromise">Systemkompromittierung</option>
|
||||
<option value="phishing">Phishing</option>
|
||||
<option value="ransomware">Ransomware</option>
|
||||
<option value="insider_threat">Insider-Bedrohung</option>
|
||||
<option value="physical_breach">Physischer Vorfall</option>
|
||||
<option value="other">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Severity */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Schweregrad</label>
|
||||
<select
|
||||
value={severity}
|
||||
onChange={e => setSeverity(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
>
|
||||
<option value="low">Niedrig (1)</option>
|
||||
<option value="medium">Mittel (2)</option>
|
||||
<option value="high">Hoch (3)</option>
|
||||
<option value="critical">Kritisch (4)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<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 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
placeholder="Detaillierte Beschreibung des Vorfalls"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Detected By */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Entdeckt von</label>
|
||||
<input
|
||||
type="text"
|
||||
value={detectedBy}
|
||||
onChange={e => setDetectedBy(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
placeholder="Name / Team / System"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Affected Systems */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Betroffene Systeme
|
||||
<span className="ml-1 text-xs text-gray-400">(Kommagetrennt)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={affectedSystems}
|
||||
onChange={e => setAffectedSystems(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
placeholder="z.B. CRM, E-Mail-Server, Datenbank"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Estimated Affected Persons */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Geschaetzte betroffene Personen</label>
|
||||
<input
|
||||
type="number"
|
||||
value={estimatedAffectedPersons}
|
||||
onChange={e => setEstimatedAffectedPersons(e.target.value)}
|
||||
min={0}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isSaving && (
|
||||
<svg className="animate-spin w-4 h-4" 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>
|
||||
)}
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
Incident,
|
||||
INCIDENT_SEVERITY_INFO,
|
||||
INCIDENT_STATUS_INFO,
|
||||
INCIDENT_CATEGORY_INFO
|
||||
} from '@/lib/sdk/incidents/types'
|
||||
import { CountdownTimer } from './CountdownTimer'
|
||||
|
||||
const STATUS_TRANSITIONS: Record<string, { label: string; nextStatus: string } | null> = {
|
||||
detected: { label: 'Bewertung starten', nextStatus: 'assessment' },
|
||||
assessment: { label: 'Eindaemmung starten', nextStatus: 'containment' },
|
||||
containment: { label: 'Meldepflicht pruefen', nextStatus: 'notification_required' },
|
||||
notification_required: { label: 'Gemeldet', nextStatus: 'notification_sent' },
|
||||
notification_sent: { label: 'Behebung starten', nextStatus: 'remediation' },
|
||||
remediation: { label: 'Abschliessen', nextStatus: 'closed' },
|
||||
closed: null
|
||||
}
|
||||
|
||||
export function IncidentDetailDrawer({
|
||||
incident,
|
||||
onClose,
|
||||
onStatusChange,
|
||||
onDeleted,
|
||||
}: {
|
||||
incident: Incident
|
||||
onClose: () => void
|
||||
onStatusChange: () => void
|
||||
onDeleted?: () => void
|
||||
}) {
|
||||
const [isChangingStatus, setIsChangingStatus] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const handleDeleteIncident = async () => {
|
||||
if (!window.confirm(`Incident "${incident.title}" wirklich löschen?`)) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/incidents/${incident.id}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error(`Fehler: ${res.status}`)
|
||||
onDeleted ? onDeleted() : onClose()
|
||||
} catch (err) {
|
||||
console.error('Löschen fehlgeschlagen:', err)
|
||||
alert('Löschen fehlgeschlagen.')
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
const severityInfo = INCIDENT_SEVERITY_INFO[incident.severity]
|
||||
const statusInfo = INCIDENT_STATUS_INFO[incident.status]
|
||||
const categoryInfo = INCIDENT_CATEGORY_INFO[incident.category]
|
||||
const transition = STATUS_TRANSITIONS[incident.status]
|
||||
|
||||
const handleStatusChange = async (newStatus: string) => {
|
||||
setIsChangingStatus(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/incidents/${incident.id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus })
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`Fehler: ${res.status}`)
|
||||
}
|
||||
onStatusChange()
|
||||
} catch (err) {
|
||||
console.error('Status-Aenderung fehlgeschlagen:', err)
|
||||
} finally {
|
||||
setIsChangingStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/30"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Drawer */}
|
||||
<div className="fixed right-0 top-0 h-full w-[600px] bg-white shadow-2xl z-10 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 sticky top-0 bg-white z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${severityInfo.bgColor} ${severityInfo.color}`}>
|
||||
{severityInfo.label}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusInfo.bgColor} ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 rounded 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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 font-mono mb-1">{incident.referenceNumber}</p>
|
||||
<h2 className="text-xl font-semibold text-gray-900">{incident.title}</h2>
|
||||
</div>
|
||||
|
||||
{/* Status Transition */}
|
||||
{transition && (
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<p className="text-sm text-purple-700 mb-3">Naechster Schritt:</p>
|
||||
<button
|
||||
onClick={() => handleStatusChange(transition.nextStatus)}
|
||||
disabled={isChangingStatus}
|
||||
className="px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isChangingStatus && (
|
||||
<svg className="animate-spin w-4 h-4" 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>
|
||||
)}
|
||||
{transition.label}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Kategorie</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{categoryInfo.icon} {categoryInfo.label}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Schweregrad</p>
|
||||
<p className="text-sm font-medium text-gray-900">{severityInfo.label}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Status</p>
|
||||
<p className="text-sm font-medium text-gray-900">{statusInfo.label}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Entdeckt am</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{new Date(incident.detectedAt).toLocaleString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
{incident.detectedBy && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Entdeckt von</p>
|
||||
<p className="text-sm font-medium text-gray-900">{incident.detectedBy}</p>
|
||||
</div>
|
||||
)}
|
||||
{incident.assignedTo && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Zugewiesen an</p>
|
||||
<p className="text-sm font-medium text-gray-900">{incident.assignedTo}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Betroffene Personen (geschaetzt)</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{incident.estimatedAffectedPersons.toLocaleString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{incident.description && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-2">Beschreibung</p>
|
||||
<p className="text-sm text-gray-700 leading-relaxed bg-gray-50 rounded-lg p-3">
|
||||
{incident.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Affected Systems */}
|
||||
{incident.affectedSystems && incident.affectedSystems.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-2">Betroffene Systeme</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{incident.affectedSystems.map((sys, idx) => (
|
||||
<span key={idx} className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded-full">
|
||||
{sys}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 72h Countdown */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-2">72h-Meldefrist</p>
|
||||
<CountdownTimer incident={incident} />
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
<button
|
||||
onClick={handleDeleteIncident}
|
||||
disabled={isDeleting}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? 'Löschen...' : 'Löschen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
admin-compliance/app/sdk/incidents/_components/StatCard.tsx
Normal file
52
admin-compliance/app/sdk/incidents/_components/StatCard.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
color = 'gray',
|
||||
icon,
|
||||
trend
|
||||
}: {
|
||||
label: string
|
||||
value: number | string
|
||||
color?: 'gray' | 'blue' | 'yellow' | 'red' | 'green' | 'purple' | 'orange'
|
||||
icon?: React.ReactNode
|
||||
trend?: { value: number; label: string }
|
||||
}) {
|
||||
const colorClasses: Record<string, string> = {
|
||||
gray: 'border-gray-200 text-gray-900',
|
||||
blue: 'border-blue-200 text-blue-600',
|
||||
yellow: 'border-yellow-200 text-yellow-600',
|
||||
red: 'border-red-200 text-red-600',
|
||||
green: 'border-green-200 text-green-600',
|
||||
purple: 'border-purple-200 text-purple-600',
|
||||
orange: 'border-orange-200 text-orange-600'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border ${colorClasses[color]} p-6`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className={`text-sm ${color === 'gray' ? 'text-gray-500' : ''}`}>
|
||||
{label}
|
||||
</div>
|
||||
<div className={`text-3xl font-bold mt-1 ${colorClasses[color].split(' ')[1]}`}>
|
||||
{value}
|
||||
</div>
|
||||
{trend && (
|
||||
<div className={`text-xs mt-1 ${trend.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{trend.value >= 0 ? '+' : ''}{trend.value} {trend.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{icon && (
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center bg-gray-50">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export type TabId = 'overview' | 'active' | 'notification' | 'closed' | 'settings'
|
||||
|
||||
export interface Tab {
|
||||
id: TabId
|
||||
label: string
|
||||
count?: number
|
||||
countColor?: string
|
||||
}
|
||||
|
||||
export function TabNavigation({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange
|
||||
}: {
|
||||
tabs: Tab[]
|
||||
activeTab: TabId
|
||||
onTabChange: (tab: TabId) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-1 -mb-px" aria-label="Tabs">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`
|
||||
px-4 py-3 text-sm font-medium border-b-2 transition-colors
|
||||
${activeTab === tab.id
|
||||
? 'border-purple-600 text-purple-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{tab.label}
|
||||
{tab.count !== undefined && tab.count > 0 && (
|
||||
<span className={`
|
||||
px-2 py-0.5 text-xs rounded-full
|
||||
${tab.countColor || 'bg-gray-100 text-gray-600'}
|
||||
`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,793 +9,16 @@ import {
|
||||
IncidentStatus,
|
||||
IncidentCategory,
|
||||
IncidentStatistics,
|
||||
INCIDENT_SEVERITY_INFO,
|
||||
INCIDENT_STATUS_INFO,
|
||||
INCIDENT_CATEGORY_INFO,
|
||||
getHoursUntil72hDeadline,
|
||||
is72hDeadlineExpired
|
||||
} from '@/lib/sdk/incidents/types'
|
||||
import { fetchSDKIncidentList, createMockIncidents, createMockStatistics } from '@/lib/sdk/incidents/api'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type TabId = 'overview' | 'active' | 'notification' | 'closed' | 'settings'
|
||||
|
||||
interface Tab {
|
||||
id: TabId
|
||||
label: string
|
||||
count?: number
|
||||
countColor?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function TabNavigation({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange
|
||||
}: {
|
||||
tabs: Tab[]
|
||||
activeTab: TabId
|
||||
onTabChange: (tab: TabId) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-1 -mb-px" aria-label="Tabs">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`
|
||||
px-4 py-3 text-sm font-medium border-b-2 transition-colors
|
||||
${activeTab === tab.id
|
||||
? 'border-purple-600 text-purple-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{tab.label}
|
||||
{tab.count !== undefined && tab.count > 0 && (
|
||||
<span className={`
|
||||
px-2 py-0.5 text-xs rounded-full
|
||||
${tab.countColor || 'bg-gray-100 text-gray-600'}
|
||||
`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
color = 'gray',
|
||||
icon,
|
||||
trend
|
||||
}: {
|
||||
label: string
|
||||
value: number | string
|
||||
color?: 'gray' | 'blue' | 'yellow' | 'red' | 'green' | 'purple' | 'orange'
|
||||
icon?: React.ReactNode
|
||||
trend?: { value: number; label: string }
|
||||
}) {
|
||||
const colorClasses: Record<string, string> = {
|
||||
gray: 'border-gray-200 text-gray-900',
|
||||
blue: 'border-blue-200 text-blue-600',
|
||||
yellow: 'border-yellow-200 text-yellow-600',
|
||||
red: 'border-red-200 text-red-600',
|
||||
green: 'border-green-200 text-green-600',
|
||||
purple: 'border-purple-200 text-purple-600',
|
||||
orange: 'border-orange-200 text-orange-600'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border ${colorClasses[color]} p-6`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className={`text-sm ${color === 'gray' ? 'text-gray-500' : ''}`}>
|
||||
{label}
|
||||
</div>
|
||||
<div className={`text-3xl font-bold mt-1 ${colorClasses[color].split(' ')[1]}`}>
|
||||
{value}
|
||||
</div>
|
||||
{trend && (
|
||||
<div className={`text-xs mt-1 ${trend.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{trend.value >= 0 ? '+' : ''}{trend.value} {trend.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{icon && (
|
||||
<div className="w-10 h-10 rounded-lg flex items-center justify-center bg-gray-50">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterBar({
|
||||
selectedSeverity,
|
||||
selectedStatus,
|
||||
selectedCategory,
|
||||
onSeverityChange,
|
||||
onStatusChange,
|
||||
onCategoryChange,
|
||||
onClear
|
||||
}: {
|
||||
selectedSeverity: IncidentSeverity | 'all'
|
||||
selectedStatus: IncidentStatus | 'all'
|
||||
selectedCategory: IncidentCategory | 'all'
|
||||
onSeverityChange: (severity: IncidentSeverity | 'all') => void
|
||||
onStatusChange: (status: IncidentStatus | 'all') => void
|
||||
onCategoryChange: (category: IncidentCategory | 'all') => void
|
||||
onClear: () => void
|
||||
}) {
|
||||
const hasFilters = selectedSeverity !== 'all' || selectedStatus !== 'all' || selectedCategory !== 'all'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
|
||||
{/* Severity Filter */}
|
||||
<select
|
||||
value={selectedSeverity}
|
||||
onChange={(e) => onSeverityChange(e.target.value as IncidentSeverity | 'all')}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="all">Alle Schweregrade</option>
|
||||
{Object.entries(INCIDENT_SEVERITY_INFO).map(([severity, info]) => (
|
||||
<option key={severity} value={severity}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Status Filter */}
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => onStatusChange(e.target.value as IncidentStatus | 'all')}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="all">Alle Status</option>
|
||||
{Object.entries(INCIDENT_STATUS_INFO).map(([status, info]) => (
|
||||
<option key={status} value={status}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Category Filter */}
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => onCategoryChange(e.target.value as IncidentCategory | 'all')}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="all">Alle Kategorien</option>
|
||||
{Object.entries(INCIDENT_CATEGORY_INFO).map(([cat, info]) => (
|
||||
<option key={cat} value={cat}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 72h-Countdown-Anzeige mit visueller Farbkodierung
|
||||
* Gruen > 48h, Gelb > 24h, Orange > 12h, Rot < 12h oder abgelaufen
|
||||
*/
|
||||
function CountdownTimer({ incident }: { incident: Incident }) {
|
||||
const hoursRemaining = getHoursUntil72hDeadline(incident.detectedAt)
|
||||
const expired = is72hDeadlineExpired(incident.detectedAt)
|
||||
|
||||
// Nicht relevant fuer abgeschlossene Vorfaelle
|
||||
if (incident.status === 'closed') return null
|
||||
|
||||
// Bereits gemeldet
|
||||
if (incident.authorityNotification && (incident.authorityNotification.status === 'submitted' || incident.authorityNotification.status === 'acknowledged')) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Gemeldet
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Keine Meldepflicht festgestellt
|
||||
if (incident.riskAssessment && !incident.riskAssessment.notificationRequired) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 rounded-full">
|
||||
Keine Meldepflicht
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Abgelaufen
|
||||
if (expired) {
|
||||
const overdueHours = Math.abs(hoursRemaining)
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs font-bold bg-red-100 text-red-700 rounded-full animate-pulse">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{overdueHours.toFixed(0)}h ueberfaellig
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Farbkodierung: gruen > 48h, gelb > 24h, orange > 12h, rot < 12h
|
||||
let colorClass: string
|
||||
if (hoursRemaining > 48) {
|
||||
colorClass = 'bg-green-100 text-green-700'
|
||||
} else if (hoursRemaining > 24) {
|
||||
colorClass = 'bg-yellow-100 text-yellow-700'
|
||||
} else if (hoursRemaining > 12) {
|
||||
colorClass = 'bg-orange-100 text-orange-700'
|
||||
} else {
|
||||
colorClass = 'bg-red-100 text-red-700'
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 text-xs font-bold rounded-full ${colorClass}`}>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{hoursRemaining.toFixed(0)}h verbleibend
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Badge({ bgColor, color, label }: { bgColor: string; color: string; label: string }) {
|
||||
return <span className={`px-2 py-1 text-xs rounded-full ${bgColor} ${color}`}>{label}</span>
|
||||
}
|
||||
|
||||
function IncidentCard({ incident, onClick }: { incident: Incident; onClick?: () => void }) {
|
||||
const severityInfo = INCIDENT_SEVERITY_INFO[incident.severity]
|
||||
const statusInfo = INCIDENT_STATUS_INFO[incident.status]
|
||||
const categoryInfo = INCIDENT_CATEGORY_INFO[incident.category]
|
||||
|
||||
const expired = is72hDeadlineExpired(incident.detectedAt)
|
||||
const isNotified = incident.authorityNotification && (incident.authorityNotification.status === 'submitted' || incident.authorityNotification.status === 'acknowledged')
|
||||
|
||||
const severityBorderColors: Record<IncidentSeverity, string> = {
|
||||
critical: 'border-red-300 hover:border-red-400',
|
||||
high: 'border-orange-300 hover:border-orange-400',
|
||||
medium: 'border-yellow-300 hover:border-yellow-400',
|
||||
low: 'border-green-200 hover:border-green-300'
|
||||
}
|
||||
|
||||
const borderColor = incident.status === 'closed'
|
||||
? 'border-green-200 hover:border-green-300'
|
||||
: expired && !isNotified
|
||||
? 'border-red-400 hover:border-red-500'
|
||||
: severityBorderColors[incident.severity]
|
||||
|
||||
const measuresCount = incident.measures.length
|
||||
const completedMeasures = incident.measures.filter(m => m.status === 'completed').length
|
||||
|
||||
return (
|
||||
<div onClick={onClick} className="cursor-pointer">
|
||||
<div className={`
|
||||
bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer
|
||||
${borderColor}
|
||||
`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header Badges */}
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="text-xs text-gray-500 font-mono">
|
||||
{incident.referenceNumber}
|
||||
</span>
|
||||
<Badge bgColor={severityInfo.bgColor} color={severityInfo.color} label={severityInfo.label} />
|
||||
<Badge bgColor={categoryInfo.bgColor} color={categoryInfo.color} label={`${categoryInfo.icon} ${categoryInfo.label}`} />
|
||||
<Badge bgColor={statusInfo.bgColor} color={statusInfo.color} label={statusInfo.label} />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{incident.title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
|
||||
{incident.description}
|
||||
</p>
|
||||
|
||||
{/* 72h Countdown - prominent */}
|
||||
<div className="mt-3">
|
||||
<CountdownTimer incident={incident} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Key Numbers */}
|
||||
<div className="text-right ml-4 flex-shrink-0">
|
||||
<div className="text-sm text-gray-500">
|
||||
Betroffene
|
||||
</div>
|
||||
<div className="text-xl font-bold text-gray-900">
|
||||
{incident.estimatedAffectedPersons.toLocaleString('de-DE')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
{new Date(incident.detectedAt).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
{completedMeasures}/{measuresCount} Massnahmen
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{incident.timeline.length} Eintraege
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">
|
||||
{incident.assignedTo
|
||||
? `Zugewiesen: ${incident.assignedTo}`
|
||||
: 'Nicht zugewiesen'
|
||||
}
|
||||
</span>
|
||||
{incident.status !== 'closed' ? (
|
||||
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
|
||||
Bearbeiten
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Details
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INCIDENT CREATE MODAL
|
||||
// =============================================================================
|
||||
|
||||
function IncidentCreateModal({
|
||||
onClose,
|
||||
onSuccess
|
||||
}: {
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [category, setCategory] = useState('data_breach')
|
||||
const [severity, setSeverity] = useState('medium')
|
||||
const [description, setDescription] = useState('')
|
||||
const [detectedBy, setDetectedBy] = useState('')
|
||||
const [affectedSystems, setAffectedSystems] = useState('')
|
||||
const [estimatedAffectedPersons, setEstimatedAffectedPersons] = useState('0')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!title.trim()) {
|
||||
setError('Titel ist erforderlich.')
|
||||
return
|
||||
}
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/incidents', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
category,
|
||||
severity,
|
||||
description,
|
||||
detectedBy,
|
||||
affectedSystems: affectedSystems.split(',').map(s => s.trim()).filter(Boolean),
|
||||
estimatedAffectedPersons: Number(estimatedAffectedPersons)
|
||||
})
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.detail || data.message || `Fehler: ${res.status}`)
|
||||
}
|
||||
onSuccess()
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4 p-6 z-10 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Neuen Vorfall erfassen</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 rounded 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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Title */}
|
||||
<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 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
placeholder="Kurze Beschreibung des Vorfalls"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<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 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
>
|
||||
<option value="data_breach">Datenpanne</option>
|
||||
<option value="unauthorized_access">Unbefugter Zugriff</option>
|
||||
<option value="data_loss">Datenverlust</option>
|
||||
<option value="system_compromise">Systemkompromittierung</option>
|
||||
<option value="phishing">Phishing</option>
|
||||
<option value="ransomware">Ransomware</option>
|
||||
<option value="insider_threat">Insider-Bedrohung</option>
|
||||
<option value="physical_breach">Physischer Vorfall</option>
|
||||
<option value="other">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Severity */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Schweregrad</label>
|
||||
<select
|
||||
value={severity}
|
||||
onChange={e => setSeverity(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
>
|
||||
<option value="low">Niedrig (1)</option>
|
||||
<option value="medium">Mittel (2)</option>
|
||||
<option value="high">Hoch (3)</option>
|
||||
<option value="critical">Kritisch (4)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<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 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
placeholder="Detaillierte Beschreibung des Vorfalls"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Detected By */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Entdeckt von</label>
|
||||
<input
|
||||
type="text"
|
||||
value={detectedBy}
|
||||
onChange={e => setDetectedBy(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
placeholder="Name / Team / System"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Affected Systems */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Betroffene Systeme
|
||||
<span className="ml-1 text-xs text-gray-400">(Kommagetrennt)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={affectedSystems}
|
||||
onChange={e => setAffectedSystems(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
placeholder="z.B. CRM, E-Mail-Server, Datenbank"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Estimated Affected Persons */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Geschaetzte betroffene Personen</label>
|
||||
<input
|
||||
type="number"
|
||||
value={estimatedAffectedPersons}
|
||||
onChange={e => setEstimatedAffectedPersons(e.target.value)}
|
||||
min={0}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isSaving && (
|
||||
<svg className="animate-spin w-4 h-4" 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>
|
||||
)}
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INCIDENT DETAIL DRAWER
|
||||
// =============================================================================
|
||||
|
||||
const STATUS_TRANSITIONS: Record<string, { label: string; nextStatus: string } | null> = {
|
||||
detected: { label: 'Bewertung starten', nextStatus: 'assessment' },
|
||||
assessment: { label: 'Eindaemmung starten', nextStatus: 'containment' },
|
||||
containment: { label: 'Meldepflicht pruefen', nextStatus: 'notification_required' },
|
||||
notification_required: { label: 'Gemeldet', nextStatus: 'notification_sent' },
|
||||
notification_sent: { label: 'Behebung starten', nextStatus: 'remediation' },
|
||||
remediation: { label: 'Abschliessen', nextStatus: 'closed' },
|
||||
closed: null
|
||||
}
|
||||
|
||||
function IncidentDetailDrawer({
|
||||
incident,
|
||||
onClose,
|
||||
onStatusChange,
|
||||
onDeleted,
|
||||
}: {
|
||||
incident: Incident
|
||||
onClose: () => void
|
||||
onStatusChange: () => void
|
||||
onDeleted?: () => void
|
||||
}) {
|
||||
const [isChangingStatus, setIsChangingStatus] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const handleDeleteIncident = async () => {
|
||||
if (!window.confirm(`Incident "${incident.title}" wirklich löschen?`)) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/incidents/${incident.id}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error(`Fehler: ${res.status}`)
|
||||
onDeleted ? onDeleted() : onClose()
|
||||
} catch (err) {
|
||||
console.error('Löschen fehlgeschlagen:', err)
|
||||
alert('Löschen fehlgeschlagen.')
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
const severityInfo = INCIDENT_SEVERITY_INFO[incident.severity]
|
||||
const statusInfo = INCIDENT_STATUS_INFO[incident.status]
|
||||
const categoryInfo = INCIDENT_CATEGORY_INFO[incident.category]
|
||||
const transition = STATUS_TRANSITIONS[incident.status]
|
||||
|
||||
const handleStatusChange = async (newStatus: string) => {
|
||||
setIsChangingStatus(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/incidents/${incident.id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: newStatus })
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`Fehler: ${res.status}`)
|
||||
}
|
||||
onStatusChange()
|
||||
} catch (err) {
|
||||
console.error('Status-Aenderung fehlgeschlagen:', err)
|
||||
} finally {
|
||||
setIsChangingStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/30"
|
||||
onClick={onClose}
|
||||
/>
|
||||
{/* Drawer */}
|
||||
<div className="fixed right-0 top-0 h-full w-[600px] bg-white shadow-2xl z-10 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 sticky top-0 bg-white z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${severityInfo.bgColor} ${severityInfo.color}`}>
|
||||
{severityInfo.label}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusInfo.bgColor} ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 rounded 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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 font-mono mb-1">{incident.referenceNumber}</p>
|
||||
<h2 className="text-xl font-semibold text-gray-900">{incident.title}</h2>
|
||||
</div>
|
||||
|
||||
{/* Status Transition */}
|
||||
{transition && (
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<p className="text-sm text-purple-700 mb-3">Naechster Schritt:</p>
|
||||
<button
|
||||
onClick={() => handleStatusChange(transition.nextStatus)}
|
||||
disabled={isChangingStatus}
|
||||
className="px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isChangingStatus && (
|
||||
<svg className="animate-spin w-4 h-4" 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>
|
||||
)}
|
||||
{transition.label}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Kategorie</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{categoryInfo.icon} {categoryInfo.label}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Schweregrad</p>
|
||||
<p className="text-sm font-medium text-gray-900">{severityInfo.label}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Status</p>
|
||||
<p className="text-sm font-medium text-gray-900">{statusInfo.label}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Entdeckt am</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{new Date(incident.detectedAt).toLocaleString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
{incident.detectedBy && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Entdeckt von</p>
|
||||
<p className="text-sm font-medium text-gray-900">{incident.detectedBy}</p>
|
||||
</div>
|
||||
)}
|
||||
{incident.assignedTo && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Zugewiesen an</p>
|
||||
<p className="text-sm font-medium text-gray-900">{incident.assignedTo}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">Betroffene Personen (geschaetzt)</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{incident.estimatedAffectedPersons.toLocaleString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{incident.description && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-2">Beschreibung</p>
|
||||
<p className="text-sm text-gray-700 leading-relaxed bg-gray-50 rounded-lg p-3">
|
||||
{incident.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Affected Systems */}
|
||||
{incident.affectedSystems && incident.affectedSystems.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-2">Betroffene Systeme</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{incident.affectedSystems.map((sys, idx) => (
|
||||
<span key={idx} className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded-full">
|
||||
{sys}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 72h Countdown */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-2">72h-Meldefrist</p>
|
||||
<CountdownTimer incident={incident} />
|
||||
</div>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="pt-2 border-t border-gray-100">
|
||||
<button
|
||||
onClick={handleDeleteIncident}
|
||||
disabled={isDeleting}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? 'Löschen...' : 'Löschen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { startAssignment, completeAssignment, updateAssignment } from '@/lib/sdk/training/api'
|
||||
import type { TrainingAssignment } from '@/lib/sdk/training/types'
|
||||
|
||||
export function AssignmentDetailDrawer({ assignment, onClose, onSaved }: {
|
||||
assignment: TrainingAssignment
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [deadline, setDeadline] = useState(assignment.deadline ? assignment.deadline.split('T')[0] : '')
|
||||
const [savingDeadline, setSavingDeadline] = useState(false)
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleAction = async (action: () => Promise<unknown>) => {
|
||||
setActionLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await action()
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler')
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveDeadline = async () => {
|
||||
setSavingDeadline(true)
|
||||
setError(null)
|
||||
try {
|
||||
await updateAssignment(assignment.id, { deadline: new Date(deadline).toISOString() })
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
|
||||
setSavingDeadline(false)
|
||||
}
|
||||
}
|
||||
|
||||
const statusActions: Record<string, { label: string; action: () => Promise<unknown> } | null> = {
|
||||
pending: { label: 'Starten', action: () => startAssignment(assignment.id) },
|
||||
in_progress: { label: 'Als abgeschlossen markieren', action: () => completeAssignment(assignment.id) },
|
||||
overdue: { label: 'Als erledigt markieren', action: () => completeAssignment(assignment.id) },
|
||||
completed: null,
|
||||
expired: null,
|
||||
}
|
||||
const currentAction = statusActions[assignment.status] || null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-start justify-end">
|
||||
<div className="h-full w-full max-w-lg bg-white shadow-xl flex flex-col">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{assignment.module_title || assignment.module_code}</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{assignment.module_code}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<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="flex-1 overflow-y-auto p-6 space-y-5">
|
||||
{/* Employee */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="font-medium text-gray-900">{assignment.user_name}</div>
|
||||
<div className="text-sm text-gray-500">{assignment.user_email}</div>
|
||||
{assignment.role_code && <div className="text-xs text-gray-400 mt-1">Rolle: {assignment.role_code}</div>}
|
||||
</div>
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between"><span className="text-gray-500">Erstellt:</span><span>{new Date(assignment.created_at).toLocaleString('de-DE')}</span></div>
|
||||
{assignment.started_at && <div className="flex justify-between"><span className="text-gray-500">Gestartet:</span><span>{new Date(assignment.started_at).toLocaleString('de-DE')}</span></div>}
|
||||
{assignment.completed_at && <div className="flex justify-between"><span className="text-gray-500">Abgeschlossen:</span><span className="text-green-600">{new Date(assignment.completed_at).toLocaleString('de-DE')}</span></div>}
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-500">Fortschritt</span>
|
||||
<span className="font-medium">{assignment.progress_percent}%</span>
|
||||
</div>
|
||||
<div className="w-full h-3 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div className="bg-blue-500 h-full rounded-full transition-all" style={{ width: `${assignment.progress_percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quiz Score */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Quiz-Score</span>
|
||||
{assignment.quiz_score != null ? (
|
||||
<span className={`px-2 py-1 rounded text-sm font-medium ${assignment.quiz_passed ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||
{assignment.quiz_score.toFixed(0)}% {assignment.quiz_passed ? '(Bestanden)' : '(Nicht bestanden)'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 rounded text-sm bg-gray-100 text-gray-500">Noch kein Quiz</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Escalation */}
|
||||
{assignment.escalation_level > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Eskalationslevel</span>
|
||||
<span className="px-2 py-1 rounded text-sm font-medium bg-orange-100 text-orange-700">
|
||||
Level {assignment.escalation_level}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Certificate */}
|
||||
{assignment.certificate_id && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Zertifikat</span>
|
||||
<span className="text-sm text-blue-600">Zertifikat vorhanden</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Action */}
|
||||
{currentAction && (
|
||||
<button
|
||||
onClick={() => handleAction(currentAction.action)}
|
||||
disabled={actionLoading}
|
||||
className="w-full px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{actionLoading ? 'Bitte warten...' : currentAction.label}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Deadline Edit */}
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Deadline bearbeiten</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={deadline}
|
||||
onChange={e => setDeadline(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveDeadline}
|
||||
disabled={savingDeadline || !deadline}
|
||||
className="px-4 py-2 text-sm bg-gray-800 text-white rounded-lg hover:bg-gray-900 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{savingDeadline ? 'Speichern...' : 'Deadline speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import type { TrainingAssignment } from '@/lib/sdk/training/types'
|
||||
import { STATUS_LABELS, STATUS_COLORS } from '@/lib/sdk/training/types'
|
||||
|
||||
interface AssignmentsTabProps {
|
||||
assignments: TrainingAssignment[]
|
||||
statusFilter: string
|
||||
onStatusFilterChange: (value: string) => void
|
||||
onAssignmentClick: (a: TrainingAssignment) => void
|
||||
}
|
||||
|
||||
export function AssignmentsTab({ assignments, statusFilter, onStatusFilterChange, onAssignmentClick }: AssignmentsTabProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<select value={statusFilter} onChange={e => onStatusFilterChange(e.target.value)} className="px-3 py-1.5 text-sm border rounded-lg bg-white">
|
||||
<option value="">Alle Status</option>
|
||||
{Object.entries(STATUS_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Modul</th>
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Mitarbeiter</th>
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Rolle</th>
|
||||
<th className="text-center p-2 border font-medium text-gray-700">Fortschritt</th>
|
||||
<th className="text-center p-2 border font-medium text-gray-700">Status</th>
|
||||
<th className="text-center p-2 border font-medium text-gray-700">Quiz</th>
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Deadline</th>
|
||||
<th className="text-center p-2 border font-medium text-gray-700">Eskalation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{assignments.map(a => (
|
||||
<tr
|
||||
key={a.id}
|
||||
onClick={() => onAssignmentClick(a)}
|
||||
className="border-b hover:bg-gray-50 cursor-pointer"
|
||||
>
|
||||
<td className="p-2 border">
|
||||
<div className="font-medium">{a.module_title || a.module_code}</div>
|
||||
<div className="text-xs text-gray-500">{a.module_code}</div>
|
||||
</td>
|
||||
<td className="p-2 border">
|
||||
<div>{a.user_name}</div>
|
||||
<div className="text-xs text-gray-500">{a.user_email}</div>
|
||||
</td>
|
||||
<td className="p-2 border text-xs">{a.role_code || '-'}</td>
|
||||
<td className="p-2 border text-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-blue-500 h-2 rounded-full" style={{ width: `${a.progress_percent}%` }} />
|
||||
</div>
|
||||
<span className="text-xs w-8">{a.progress_percent}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2 border text-center">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${STATUS_COLORS[a.status]?.bg || ''} ${STATUS_COLORS[a.status]?.text || ''}`}>
|
||||
{STATUS_LABELS[a.status] || a.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2 border text-center text-xs">
|
||||
{a.quiz_score != null ? (
|
||||
<span className={`font-medium ${a.quiz_passed ? 'text-green-600' : 'text-red-600'}`}>{a.quiz_score.toFixed(0)}%</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="p-2 border text-xs">{new Date(a.deadline).toLocaleDateString('de-DE')}</td>
|
||||
<td className="p-2 border text-center">
|
||||
{a.escalation_level > 0 ? <span className="px-2 py-0.5 rounded text-xs bg-red-100 text-red-700">L{a.escalation_level}</span> : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{assignments.length === 0 && <p className="text-center text-gray-500 py-8">Keine Zuweisungen</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
37
admin-compliance/app/sdk/training/_components/AuditTab.tsx
Normal file
37
admin-compliance/app/sdk/training/_components/AuditTab.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import type { AuditLogEntry } from '@/lib/sdk/training/types'
|
||||
|
||||
interface AuditTabProps {
|
||||
auditLog: AuditLogEntry[]
|
||||
}
|
||||
|
||||
export function AuditTab({ auditLog }: AuditTabProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Zeitpunkt</th>
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Aktion</th>
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Entitaet</th>
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{auditLog.map(entry => (
|
||||
<tr key={entry.id} className="border-b hover:bg-gray-50">
|
||||
<td className="p-2 border text-xs text-gray-600">{new Date(entry.created_at).toLocaleString('de-DE')}</td>
|
||||
<td className="p-2 border"><span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-700">{entry.action}</span></td>
|
||||
<td className="p-2 border text-xs">{entry.entity_type}</td>
|
||||
<td className="p-2 border text-xs text-gray-600">{JSON.stringify(entry.details).substring(0, 100)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{auditLog.length === 0 && <p className="text-center text-gray-500 py-8">Keine Audit-Eintraege</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
148
admin-compliance/app/sdk/training/_components/ContentTab.tsx
Normal file
148
admin-compliance/app/sdk/training/_components/ContentTab.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
'use client'
|
||||
|
||||
import type { TrainingModule, ModuleContent, TrainingMedia } from '@/lib/sdk/training/types'
|
||||
import AudioPlayer from '@/components/training/AudioPlayer'
|
||||
import VideoPlayer from '@/components/training/VideoPlayer'
|
||||
import ScriptPreview from '@/components/training/ScriptPreview'
|
||||
|
||||
interface ContentTabProps {
|
||||
modules: TrainingModule[]
|
||||
selectedModuleId: string
|
||||
onSelectModule: (id: string) => void
|
||||
generatedContent: ModuleContent | null
|
||||
generating: boolean
|
||||
bulkGenerating: boolean
|
||||
bulkResult: { generated: number; skipped: number; errors: string[] } | null
|
||||
moduleMedia: TrainingMedia[]
|
||||
onGenerateContent: () => void
|
||||
onGenerateQuiz: () => void
|
||||
onBulkContent: () => void
|
||||
onBulkQuiz: () => void
|
||||
onPublishContent: (contentId: string) => void
|
||||
onReloadMedia: () => void
|
||||
}
|
||||
|
||||
export function ContentTab({
|
||||
modules,
|
||||
selectedModuleId,
|
||||
onSelectModule,
|
||||
generatedContent,
|
||||
generating,
|
||||
bulkGenerating,
|
||||
bulkResult,
|
||||
moduleMedia,
|
||||
onGenerateContent,
|
||||
onGenerateQuiz,
|
||||
onBulkContent,
|
||||
onBulkQuiz,
|
||||
onPublishContent,
|
||||
onReloadMedia,
|
||||
}: ContentTabProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Bulk Generation */}
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Bulk-Generierung</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">Generiere Inhalte und Quiz-Fragen fuer alle Module auf einmal</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onBulkContent}
|
||||
disabled={bulkGenerating}
|
||||
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{bulkGenerating ? 'Generiere...' : 'Alle Inhalte generieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onBulkQuiz}
|
||||
disabled={bulkGenerating}
|
||||
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{bulkGenerating ? 'Generiere...' : 'Alle Quizfragen generieren'}
|
||||
</button>
|
||||
</div>
|
||||
{bulkResult && (
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg text-sm">
|
||||
<div className="flex gap-6">
|
||||
<span className="text-green-700">Generiert: {bulkResult.generated}</span>
|
||||
<span className="text-gray-500">Uebersprungen: {bulkResult.skipped}</span>
|
||||
{bulkResult.errors?.length > 0 && (
|
||||
<span className="text-red-600">Fehler: {bulkResult.errors.length}</span>
|
||||
)}
|
||||
</div>
|
||||
{bulkResult.errors?.length > 0 && (
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
{bulkResult.errors.map((err, i) => <div key={i}>{err}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">LLM-Content-Generator</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">Generiere Schulungsinhalte und Quiz-Fragen automatisch via KI</p>
|
||||
<div className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-gray-600 block mb-1">Modul auswaehlen</label>
|
||||
<select
|
||||
value={selectedModuleId}
|
||||
onChange={e => onSelectModule(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border rounded-lg bg-white"
|
||||
>
|
||||
<option value="">Modul waehlen...</option>
|
||||
{modules.map(m => <option key={m.id} value={m.id}>{m.module_code} - {m.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={onGenerateContent} disabled={!selectedModuleId || generating} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
||||
{generating ? 'Generiere...' : 'Inhalt generieren'}
|
||||
</button>
|
||||
<button onClick={onGenerateQuiz} disabled={!selectedModuleId || generating} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
{generating ? 'Generiere...' : 'Quiz generieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{generatedContent && (
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700">Generierter Inhalt (v{generatedContent.version})</h3>
|
||||
<p className="text-xs text-gray-500">Generiert von: {generatedContent.generated_by} ({generatedContent.llm_model})</p>
|
||||
</div>
|
||||
{!generatedContent.is_published ? (
|
||||
<button onClick={() => onPublishContent(generatedContent.id)} className="px-3 py-1.5 text-xs bg-green-600 text-white rounded hover:bg-green-700">Veroeffentlichen</button>
|
||||
) : (
|
||||
<span className="px-3 py-1.5 text-xs bg-green-100 text-green-700 rounded">Veroeffentlicht</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="prose prose-sm max-w-none border rounded p-4 bg-gray-50 max-h-96 overflow-y-auto">
|
||||
<pre className="whitespace-pre-wrap text-sm text-gray-800">{generatedContent.content_body}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audio Player */}
|
||||
{selectedModuleId && generatedContent?.is_published && (
|
||||
<AudioPlayer
|
||||
moduleId={selectedModuleId}
|
||||
audio={moduleMedia.find(m => m.media_type === 'audio') || null}
|
||||
onMediaUpdate={onReloadMedia}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Video Player */}
|
||||
{selectedModuleId && generatedContent?.is_published && (
|
||||
<VideoPlayer
|
||||
moduleId={selectedModuleId}
|
||||
video={moduleMedia.find(m => m.media_type === 'video') || null}
|
||||
onMediaUpdate={onReloadMedia}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Script Preview */}
|
||||
{selectedModuleId && generatedContent?.is_published && (
|
||||
<ScriptPreview moduleId={selectedModuleId} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
18
admin-compliance/app/sdk/training/_components/KPICard.tsx
Normal file
18
admin-compliance/app/sdk/training/_components/KPICard.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
export function KPICard({ label, value, color }: { label: string; value: string | number; color?: string }) {
|
||||
const colorMap: Record<string, string> = {
|
||||
green: 'bg-green-50 border-green-200',
|
||||
yellow: 'bg-yellow-50 border-yellow-200',
|
||||
red: 'bg-red-50 border-red-200',
|
||||
}
|
||||
const textMap: Record<string, string> = {
|
||||
green: 'text-green-700',
|
||||
yellow: 'text-yellow-700',
|
||||
red: 'text-red-700',
|
||||
}
|
||||
return (
|
||||
<div className={`border rounded-lg p-4 ${color ? colorMap[color] || 'bg-white border-gray-200' : 'bg-white border-gray-200'}`}>
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
<p className={`text-2xl font-bold mt-1 ${color ? textMap[color] || 'text-gray-900' : 'text-gray-900'}`}>{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { setMatrixEntry } from '@/lib/sdk/training/api'
|
||||
import type { TrainingModule } from '@/lib/sdk/training/types'
|
||||
|
||||
export function MatrixAddModal({ roleCode, modules, onClose, onSaved }: {
|
||||
roleCode: string
|
||||
modules: TrainingModule[]
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const activeModules = modules.filter(m => m.is_active).sort((a, b) => a.module_code.localeCompare(b.module_code))
|
||||
const [moduleId, setModuleId] = useState(activeModules[0]?.id || '')
|
||||
const [isMandatory, setIsMandatory] = useState(true)
|
||||
const [priority, setPriority] = useState(1)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!moduleId) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await setMatrixEntry({ role_code: roleCode, module_id: moduleId, is_mandatory: isMandatory, priority })
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Hinzufuegen')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Modul zu Rolle hinzufuegen</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<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-4">
|
||||
<p className="text-sm text-gray-500">Rolle: <span className="font-medium text-gray-900">{roleCode}</span></p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Modul</label>
|
||||
<select value={moduleId} onChange={e => setModuleId(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
{activeModules.map(m => <option key={m.id} value={m.id}>{m.module_code} — {m.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700">Pflichtmodul</label>
|
||||
<button onClick={() => setIsMandatory(!isMandatory)} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isMandatory ? 'bg-blue-600' : 'bg-gray-200'}`}>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${isMandatory ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Prioritaet</label>
|
||||
<input type="number" min={1} value={priority} onChange={e => setPriority(Number(e.target.value))} className="w-24 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
|
||||
<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 || !moduleId} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
|
||||
{saving ? 'Hinzufuegen...' : 'Hinzufuegen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
68
admin-compliance/app/sdk/training/_components/MatrixTab.tsx
Normal file
68
admin-compliance/app/sdk/training/_components/MatrixTab.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import type { MatrixResponse } from '@/lib/sdk/training/types'
|
||||
import { ROLE_LABELS, ALL_ROLES } from '@/lib/sdk/training/types'
|
||||
|
||||
interface MatrixTabProps {
|
||||
matrix: MatrixResponse
|
||||
onDeleteEntry: (roleCode: string, moduleId: string) => void
|
||||
onAddEntry: (roleCode: string) => void
|
||||
}
|
||||
|
||||
export function MatrixTab({ matrix, onDeleteEntry, onAddEntry }: MatrixTabProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">Compliance Training Matrix (CTM): Welche Rollen benoetigen welche Schulungsmodule</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="text-left p-2 border font-medium text-gray-700 w-48">Rolle</th>
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Module</th>
|
||||
<th className="text-center p-2 border font-medium text-gray-700 w-20">Anzahl</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ALL_ROLES.map(role => {
|
||||
const entries = matrix.entries[role] || []
|
||||
return (
|
||||
<tr key={role} className="border-b hover:bg-gray-50">
|
||||
<td className="p-2 border font-medium">
|
||||
<span className="text-gray-900">{role}</span>
|
||||
<span className="text-gray-500 ml-1 text-xs">{ROLE_LABELS[role]}</span>
|
||||
</td>
|
||||
<td className="p-2 border">
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{entries.map(e => (
|
||||
<span
|
||||
key={e.id || e.module_id}
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full ${e.is_mandatory ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}
|
||||
title={`${e.module_title} (${e.is_mandatory ? 'Pflicht' : 'Optional'})`}
|
||||
>
|
||||
{e.is_mandatory ? '🔴' : '🔵'} {e.module_code}
|
||||
<button
|
||||
onClick={() => onDeleteEntry(role, e.module_id)}
|
||||
className="ml-1 text-gray-400 hover:text-red-500 font-bold leading-none"
|
||||
title="Zuordnung entfernen"
|
||||
>×</button>
|
||||
</span>
|
||||
))}
|
||||
{entries.length === 0 && <span className="text-gray-400 text-xs">Keine Module</span>}
|
||||
<button
|
||||
onClick={() => onAddEntry(role)}
|
||||
className="px-2 py-1 text-xs text-blue-600 border border-blue-300 rounded-full hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
+ Hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2 border text-center">{entries.length}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createModule } from '@/lib/sdk/training/api'
|
||||
import { REGULATION_LABELS, FREQUENCY_LABELS } from '@/lib/sdk/training/types'
|
||||
|
||||
export function ModuleCreateModal({ onClose, onSaved }: { onClose: () => void; onSaved: () => void }) {
|
||||
const [moduleCode, setModuleCode] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [regulationArea, setRegulationArea] = useState('dsgvo')
|
||||
const [frequencyType, setFrequencyType] = useState('annual')
|
||||
const [durationMinutes, setDurationMinutes] = useState(45)
|
||||
const [passThreshold, setPassThreshold] = useState(70)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!moduleCode || !title) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await createModule({ module_code: moduleCode, title, description, regulation_area: regulationArea, frequency_type: frequencyType, duration_minutes: durationMinutes, pass_threshold: passThreshold })
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Erstellen')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Neues Trainingsmodul</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<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-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Modul-Code *</label>
|
||||
<input type="text" value={moduleCode} onChange={e => setModuleCode(e.target.value.toUpperCase())} placeholder="DSGVO-BASICS" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Regelungsbereich</label>
|
||||
<select value={regulationArea} onChange={e => setRegulationArea(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
{Object.entries(REGULATION_LABELS).map(([k, l]) => <option key={k} value={k}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-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-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</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={2} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Frequenz</label>
|
||||
<select value={frequencyType} onChange={e => setFrequencyType(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
{Object.entries(FREQUENCY_LABELS).map(([k, l]) => <option key={k} value={k}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Dauer (Min.)</label>
|
||||
<input type="number" min={1} value={durationMinutes} onChange={e => setDurationMinutes(Number(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Bestehensgrenze (%)</label>
|
||||
<input type="number" min={0} max={100} value={passThreshold} onChange={e => setPassThreshold(Number(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
|
||||
<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 || !moduleCode || !title} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
|
||||
{saving ? 'Erstelle...' : 'Modul erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { updateModule, deleteModule } from '@/lib/sdk/training/api'
|
||||
import type { TrainingModule } from '@/lib/sdk/training/types'
|
||||
import { REGULATION_LABELS, REGULATION_COLORS } from '@/lib/sdk/training/types'
|
||||
|
||||
export function ModuleEditDrawer({ module, onClose, onSaved }: { module: TrainingModule; onClose: () => void; onSaved: () => void }) {
|
||||
const [title, setTitle] = useState(module.title)
|
||||
const [description, setDescription] = useState(module.description || '')
|
||||
const [durationMinutes, setDurationMinutes] = useState(module.duration_minutes)
|
||||
const [passThreshold, setPassThreshold] = useState(module.pass_threshold)
|
||||
const [isActive, setIsActive] = useState(module.is_active)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await updateModule(module.id, { title, description, duration_minutes: durationMinutes, pass_threshold: passThreshold, is_active: isActive })
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm(`Modul "${module.title}" wirklich loeschen?`)) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteModule(module.id)
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Loeschen')
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-start justify-end">
|
||||
<div className="h-full w-full max-w-lg bg-white shadow-xl flex flex-col">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${REGULATION_COLORS[module.regulation_area]?.bg || 'bg-gray-100'} ${REGULATION_COLORS[module.regulation_area]?.text || 'text-gray-700'}`}>
|
||||
{REGULATION_LABELS[module.regulation_area] || module.regulation_area}
|
||||
</span>
|
||||
{module.nis2_relevant && <span className="text-xs px-1.5 py-0.5 bg-purple-100 text-purple-700 rounded">NIS2</span>}
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mt-1">{module.module_code}</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<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="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-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-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</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 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Dauer (Minuten)</label>
|
||||
<input type="number" min={1} value={durationMinutes} onChange={e => setDurationMinutes(Number(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Bestehensgrenze (%)</label>
|
||||
<input type="number" min={0} max={100} value={passThreshold} onChange={e => setPassThreshold(Number(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-sm font-medium text-gray-700">Modul aktiv</span>
|
||||
<button
|
||||
onClick={() => setIsActive(!isActive)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isActive ? 'bg-blue-600' : 'bg-gray-200'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${isActive ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
<div className="p-6 border-t border-gray-200 space-y-3">
|
||||
<button onClick={handleSave} disabled={saving} className="w-full px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
|
||||
{saving ? 'Speichern...' : 'Aenderungen speichern'}
|
||||
</button>
|
||||
<button onClick={handleDelete} disabled={deleting} className="w-full px-4 py-2 text-sm bg-red-50 text-red-600 border border-red-200 rounded-lg hover:bg-red-100 disabled:opacity-50 transition-colors">
|
||||
{deleting ? 'Loeschen...' : 'Modul loeschen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
68
admin-compliance/app/sdk/training/_components/ModulesTab.tsx
Normal file
68
admin-compliance/app/sdk/training/_components/ModulesTab.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import type { TrainingModule } from '@/lib/sdk/training/types'
|
||||
import { REGULATION_LABELS, REGULATION_COLORS, FREQUENCY_LABELS } from '@/lib/sdk/training/types'
|
||||
|
||||
interface ModulesTabProps {
|
||||
modules: TrainingModule[]
|
||||
regulationFilter: string
|
||||
onRegulationFilterChange: (value: string) => void
|
||||
onCreateClick: () => void
|
||||
onModuleClick: (m: TrainingModule) => void
|
||||
}
|
||||
|
||||
export function ModulesTab({ modules, regulationFilter, onRegulationFilterChange, onCreateClick, onModuleClick }: ModulesTabProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3 items-center">
|
||||
<select value={regulationFilter} onChange={e => onRegulationFilterChange(e.target.value)} className="px-3 py-1.5 text-sm border rounded-lg bg-white">
|
||||
<option value="">Alle Bereiche</option>
|
||||
{Object.entries(REGULATION_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={onCreateClick}
|
||||
className="ml-auto px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
+ Neues Modul
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{modules.map(m => (
|
||||
<div
|
||||
key={m.id}
|
||||
onClick={() => onModuleClick(m)}
|
||||
className="bg-white border rounded-lg p-4 hover:shadow-md cursor-pointer transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${REGULATION_COLORS[m.regulation_area]?.bg || 'bg-gray-100'} ${REGULATION_COLORS[m.regulation_area]?.text || 'text-gray-700'}`}>
|
||||
{REGULATION_LABELS[m.regulation_area] || m.regulation_area}
|
||||
</span>
|
||||
<h3 className="mt-2 font-medium text-gray-900">{m.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{m.module_code}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{m.nis2_relevant && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-purple-100 text-purple-700 rounded">NIS2</span>
|
||||
)}
|
||||
{!m.is_active && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-gray-100 text-gray-500 rounded">Inaktiv</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{m.description && <p className="text-sm text-gray-600 mt-2 line-clamp-2">{m.description}</p>}
|
||||
<div className="flex items-center gap-3 mt-3 text-xs text-gray-500">
|
||||
<span>{m.duration_minutes} Min.</span>
|
||||
<span>{FREQUENCY_LABELS[m.frequency_type]}</span>
|
||||
<span>Quiz: {m.pass_threshold}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{modules.length === 0 && <p className="text-center text-gray-500 py-8">Keine Module gefunden</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import type { TrainingStats, DeadlineInfo } from '@/lib/sdk/training/types'
|
||||
import { KPICard } from './KPICard'
|
||||
|
||||
interface OverviewTabProps {
|
||||
stats: TrainingStats
|
||||
deadlines: DeadlineInfo[]
|
||||
escalationResult: { total_checked: number; escalated: number } | null
|
||||
onDismissEscalation: () => void
|
||||
}
|
||||
|
||||
export function OverviewTab({ stats, deadlines, escalationResult, onDismissEscalation }: OverviewTabProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{escalationResult && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium text-blue-900">Eskalation abgeschlossen: </span>
|
||||
<span className="text-blue-700">
|
||||
{escalationResult.total_checked} Zuweisungen geprueft, {escalationResult.escalated} eskaliert
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={onDismissEscalation} className="text-blue-400 hover:text-blue-600 text-lg font-bold">×</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<KPICard label="Module" value={stats.total_modules} />
|
||||
<KPICard label="Zuweisungen" value={stats.total_assignments} />
|
||||
<KPICard label="Abschlussrate" value={`${stats.completion_rate.toFixed(1)}%`} color={stats.completion_rate >= 80 ? 'green' : stats.completion_rate >= 50 ? 'yellow' : 'red'} />
|
||||
<KPICard label="Ueberfaellig" value={stats.overdue_count} color={stats.overdue_count > 0 ? 'red' : 'green'} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<KPICard label="Ausstehend" value={stats.pending_count} />
|
||||
<KPICard label="In Bearbeitung" value={stats.in_progress_count} />
|
||||
<KPICard label="Avg. Quiz-Score" value={`${stats.avg_quiz_score.toFixed(1)}%`} />
|
||||
<KPICard label="Deadlines (7d)" value={stats.upcoming_deadlines} color={stats.upcoming_deadlines > 5 ? 'yellow' : 'green'} />
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Status-Verteilung</h3>
|
||||
{stats.total_assignments > 0 && (
|
||||
<div className="flex gap-1 h-6 rounded-full overflow-hidden bg-gray-100">
|
||||
{stats.completed_count > 0 && <div className="bg-green-500" style={{ width: `${(stats.completed_count / stats.total_assignments) * 100}%` }} title={`Abgeschlossen: ${stats.completed_count}`} />}
|
||||
{stats.in_progress_count > 0 && <div className="bg-blue-500" style={{ width: `${(stats.in_progress_count / stats.total_assignments) * 100}%` }} title={`In Bearbeitung: ${stats.in_progress_count}`} />}
|
||||
{stats.pending_count > 0 && <div className="bg-gray-400" style={{ width: `${(stats.pending_count / stats.total_assignments) * 100}%` }} title={`Ausstehend: ${stats.pending_count}`} />}
|
||||
{stats.overdue_count > 0 && <div className="bg-red-500" style={{ width: `${(stats.overdue_count / stats.total_assignments) * 100}%` }} title={`Ueberfaellig: ${stats.overdue_count}`} />}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-4 mt-2 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-green-500 inline-block" /> Abgeschlossen</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-blue-500 inline-block" /> In Bearbeitung</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-gray-400 inline-block" /> Ausstehend</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-red-500 inline-block" /> Ueberfaellig</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deadlines */}
|
||||
{deadlines.length > 0 && (
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Naechste Deadlines</h3>
|
||||
<div className="space-y-2">
|
||||
{deadlines.slice(0, 5).map(d => (
|
||||
<div key={d.assignment_id} className="flex items-center justify-between text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{d.module_title}</span>
|
||||
<span className="text-gray-500 ml-2">({d.user_name})</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
d.days_left <= 0 ? 'bg-red-100 text-red-700' :
|
||||
d.days_left <= 7 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{d.days_left <= 0 ? `${Math.abs(d.days_left)} Tage ueberfaellig` : `${d.days_left} Tage`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,21 +6,23 @@ import {
|
||||
getAuditLog, generateContent, generateQuiz,
|
||||
publishContent, checkEscalation, getContent,
|
||||
generateAllContent, generateAllQuizzes,
|
||||
createModule, updateModule, deleteModule,
|
||||
deleteMatrixEntry, setMatrixEntry,
|
||||
startAssignment, completeAssignment, updateAssignment,
|
||||
deleteMatrixEntry,
|
||||
} from '@/lib/sdk/training/api'
|
||||
import type {
|
||||
TrainingModule, TrainingAssignment,
|
||||
MatrixResponse, TrainingStats, DeadlineInfo, AuditLogEntry, ModuleContent, TrainingMedia,
|
||||
} from '@/lib/sdk/training/types'
|
||||
import {
|
||||
REGULATION_LABELS, REGULATION_COLORS, FREQUENCY_LABELS,
|
||||
STATUS_LABELS, STATUS_COLORS, ROLE_LABELS, ALL_ROLES,
|
||||
} from '@/lib/sdk/training/types'
|
||||
import AudioPlayer from '@/components/training/AudioPlayer'
|
||||
import VideoPlayer from '@/components/training/VideoPlayer'
|
||||
import ScriptPreview from '@/components/training/ScriptPreview'
|
||||
import { OverviewTab } from './_components/OverviewTab'
|
||||
import { ModulesTab } from './_components/ModulesTab'
|
||||
import { MatrixTab } from './_components/MatrixTab'
|
||||
import { AssignmentsTab } from './_components/AssignmentsTab'
|
||||
import { ContentTab } from './_components/ContentTab'
|
||||
import { AuditTab } from './_components/AuditTab'
|
||||
import { ModuleCreateModal } from './_components/ModuleCreateModal'
|
||||
import { ModuleEditDrawer } from './_components/ModuleEditDrawer'
|
||||
import { MatrixAddModal } from './_components/MatrixAddModal'
|
||||
import { AssignmentDetailDrawer } from './_components/AssignmentDetailDrawer'
|
||||
|
||||
type Tab = 'overview' | 'modules' | 'matrix' | 'assignments' | 'content' | 'audit'
|
||||
|
||||
export default function TrainingPage() {
|
||||
@@ -266,394 +268,69 @@ export default function TrainingPage() {
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && stats && (
|
||||
<div className="space-y-6">
|
||||
{escalationResult && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium text-blue-900">Eskalation abgeschlossen: </span>
|
||||
<span className="text-blue-700">
|
||||
{escalationResult.total_checked} Zuweisungen geprueft, {escalationResult.escalated} eskaliert
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={() => setEscalationResult(null)} className="text-blue-400 hover:text-blue-600 text-lg font-bold">×</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<KPICard label="Module" value={stats.total_modules} />
|
||||
<KPICard label="Zuweisungen" value={stats.total_assignments} />
|
||||
<KPICard label="Abschlussrate" value={`${stats.completion_rate.toFixed(1)}%`} color={stats.completion_rate >= 80 ? 'green' : stats.completion_rate >= 50 ? 'yellow' : 'red'} />
|
||||
<KPICard label="Ueberfaellig" value={stats.overdue_count} color={stats.overdue_count > 0 ? 'red' : 'green'} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<KPICard label="Ausstehend" value={stats.pending_count} />
|
||||
<KPICard label="In Bearbeitung" value={stats.in_progress_count} />
|
||||
<KPICard label="Avg. Quiz-Score" value={`${stats.avg_quiz_score.toFixed(1)}%`} />
|
||||
<KPICard label="Deadlines (7d)" value={stats.upcoming_deadlines} color={stats.upcoming_deadlines > 5 ? 'yellow' : 'green'} />
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Status-Verteilung</h3>
|
||||
{stats.total_assignments > 0 && (
|
||||
<div className="flex gap-1 h-6 rounded-full overflow-hidden bg-gray-100">
|
||||
{stats.completed_count > 0 && <div className="bg-green-500" style={{ width: `${(stats.completed_count / stats.total_assignments) * 100}%` }} title={`Abgeschlossen: ${stats.completed_count}`} />}
|
||||
{stats.in_progress_count > 0 && <div className="bg-blue-500" style={{ width: `${(stats.in_progress_count / stats.total_assignments) * 100}%` }} title={`In Bearbeitung: ${stats.in_progress_count}`} />}
|
||||
{stats.pending_count > 0 && <div className="bg-gray-400" style={{ width: `${(stats.pending_count / stats.total_assignments) * 100}%` }} title={`Ausstehend: ${stats.pending_count}`} />}
|
||||
{stats.overdue_count > 0 && <div className="bg-red-500" style={{ width: `${(stats.overdue_count / stats.total_assignments) * 100}%` }} title={`Ueberfaellig: ${stats.overdue_count}`} />}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-4 mt-2 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-green-500 inline-block" /> Abgeschlossen</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-blue-500 inline-block" /> In Bearbeitung</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-gray-400 inline-block" /> Ausstehend</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-3 rounded-full bg-red-500 inline-block" /> Ueberfaellig</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deadlines */}
|
||||
{deadlines.length > 0 && (
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Naechste Deadlines</h3>
|
||||
<div className="space-y-2">
|
||||
{deadlines.slice(0, 5).map(d => (
|
||||
<div key={d.assignment_id} className="flex items-center justify-between text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{d.module_title}</span>
|
||||
<span className="text-gray-500 ml-2">({d.user_name})</span>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||
d.days_left <= 0 ? 'bg-red-100 text-red-700' :
|
||||
d.days_left <= 7 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{d.days_left <= 0 ? `${Math.abs(d.days_left)} Tage ueberfaellig` : `${d.days_left} Tage`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<OverviewTab
|
||||
stats={stats}
|
||||
deadlines={deadlines}
|
||||
escalationResult={escalationResult}
|
||||
onDismissEscalation={() => setEscalationResult(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'modules' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3 items-center">
|
||||
<select value={regulationFilter} onChange={e => setRegulationFilter(e.target.value)} className="px-3 py-1.5 text-sm border rounded-lg bg-white">
|
||||
<option value="">Alle Bereiche</option>
|
||||
{Object.entries(REGULATION_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setShowModuleCreate(true)}
|
||||
className="ml-auto px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
+ Neues Modul
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredModules.map(m => (
|
||||
<div
|
||||
key={m.id}
|
||||
onClick={() => setSelectedModule(m)}
|
||||
className="bg-white border rounded-lg p-4 hover:shadow-md cursor-pointer transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${REGULATION_COLORS[m.regulation_area]?.bg || 'bg-gray-100'} ${REGULATION_COLORS[m.regulation_area]?.text || 'text-gray-700'}`}>
|
||||
{REGULATION_LABELS[m.regulation_area] || m.regulation_area}
|
||||
</span>
|
||||
<h3 className="mt-2 font-medium text-gray-900">{m.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{m.module_code}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{m.nis2_relevant && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-purple-100 text-purple-700 rounded">NIS2</span>
|
||||
)}
|
||||
{!m.is_active && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-gray-100 text-gray-500 rounded">Inaktiv</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{m.description && <p className="text-sm text-gray-600 mt-2 line-clamp-2">{m.description}</p>}
|
||||
<div className="flex items-center gap-3 mt-3 text-xs text-gray-500">
|
||||
<span>{m.duration_minutes} Min.</span>
|
||||
<span>{FREQUENCY_LABELS[m.frequency_type]}</span>
|
||||
<span>Quiz: {m.pass_threshold}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{filteredModules.length === 0 && <p className="text-center text-gray-500 py-8">Keine Module gefunden</p>}
|
||||
</div>
|
||||
<ModulesTab
|
||||
modules={filteredModules}
|
||||
regulationFilter={regulationFilter}
|
||||
onRegulationFilterChange={setRegulationFilter}
|
||||
onCreateClick={() => setShowModuleCreate(true)}
|
||||
onModuleClick={setSelectedModule}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'matrix' && matrix && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">Compliance Training Matrix (CTM): Welche Rollen benoetigen welche Schulungsmodule</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="text-left p-2 border font-medium text-gray-700 w-48">Rolle</th>
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Module</th>
|
||||
<th className="text-center p-2 border font-medium text-gray-700 w-20">Anzahl</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ALL_ROLES.map(role => {
|
||||
const entries = matrix.entries[role] || []
|
||||
return (
|
||||
<tr key={role} className="border-b hover:bg-gray-50">
|
||||
<td className="p-2 border font-medium">
|
||||
<span className="text-gray-900">{role}</span>
|
||||
<span className="text-gray-500 ml-1 text-xs">{ROLE_LABELS[role]}</span>
|
||||
</td>
|
||||
<td className="p-2 border">
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{entries.map(e => (
|
||||
<span
|
||||
key={e.id || e.module_id}
|
||||
className={`inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full ${e.is_mandatory ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}
|
||||
title={`${e.module_title} (${e.is_mandatory ? 'Pflicht' : 'Optional'})`}
|
||||
>
|
||||
{e.is_mandatory ? '🔴' : '🔵'} {e.module_code}
|
||||
<button
|
||||
onClick={() => handleDeleteMatrixEntry(role, e.module_id)}
|
||||
className="ml-1 text-gray-400 hover:text-red-500 font-bold leading-none"
|
||||
title="Zuordnung entfernen"
|
||||
>×</button>
|
||||
</span>
|
||||
))}
|
||||
{entries.length === 0 && <span className="text-gray-400 text-xs">Keine Module</span>}
|
||||
<button
|
||||
onClick={() => setMatrixAddRole(role)}
|
||||
className="px-2 py-1 text-xs text-blue-600 border border-blue-300 rounded-full hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
+ Hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2 border text-center">{entries.length}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<MatrixTab
|
||||
matrix={matrix}
|
||||
onDeleteEntry={handleDeleteMatrixEntry}
|
||||
onAddEntry={setMatrixAddRole}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'assignments' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)} className="px-3 py-1.5 text-sm border rounded-lg bg-white">
|
||||
<option value="">Alle Status</option>
|
||||
{Object.entries(STATUS_LABELS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Modul</th>
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Mitarbeiter</th>
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Rolle</th>
|
||||
<th className="text-center p-2 border font-medium text-gray-700">Fortschritt</th>
|
||||
<th className="text-center p-2 border font-medium text-gray-700">Status</th>
|
||||
<th className="text-center p-2 border font-medium text-gray-700">Quiz</th>
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Deadline</th>
|
||||
<th className="text-center p-2 border font-medium text-gray-700">Eskalation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredAssignments.map(a => (
|
||||
<tr
|
||||
key={a.id}
|
||||
onClick={() => setSelectedAssignment(a)}
|
||||
className="border-b hover:bg-gray-50 cursor-pointer"
|
||||
>
|
||||
<td className="p-2 border">
|
||||
<div className="font-medium">{a.module_title || a.module_code}</div>
|
||||
<div className="text-xs text-gray-500">{a.module_code}</div>
|
||||
</td>
|
||||
<td className="p-2 border">
|
||||
<div>{a.user_name}</div>
|
||||
<div className="text-xs text-gray-500">{a.user_email}</div>
|
||||
</td>
|
||||
<td className="p-2 border text-xs">{a.role_code || '-'}</td>
|
||||
<td className="p-2 border text-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div className="bg-blue-500 h-2 rounded-full" style={{ width: `${a.progress_percent}%` }} />
|
||||
</div>
|
||||
<span className="text-xs w-8">{a.progress_percent}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2 border text-center">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${STATUS_COLORS[a.status]?.bg || ''} ${STATUS_COLORS[a.status]?.text || ''}`}>
|
||||
{STATUS_LABELS[a.status] || a.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2 border text-center text-xs">
|
||||
{a.quiz_score != null ? (
|
||||
<span className={`font-medium ${a.quiz_passed ? 'text-green-600' : 'text-red-600'}`}>{a.quiz_score.toFixed(0)}%</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="p-2 border text-xs">{new Date(a.deadline).toLocaleDateString('de-DE')}</td>
|
||||
<td className="p-2 border text-center">
|
||||
{a.escalation_level > 0 ? <span className="px-2 py-0.5 rounded text-xs bg-red-100 text-red-700">L{a.escalation_level}</span> : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{filteredAssignments.length === 0 && <p className="text-center text-gray-500 py-8">Keine Zuweisungen</p>}
|
||||
</div>
|
||||
<AssignmentsTab
|
||||
assignments={filteredAssignments}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
onAssignmentClick={setSelectedAssignment}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'content' && (
|
||||
<div className="space-y-6">
|
||||
{/* Bulk Generation */}
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Bulk-Generierung</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">Generiere Inhalte und Quiz-Fragen fuer alle Module auf einmal</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleBulkContent}
|
||||
disabled={bulkGenerating}
|
||||
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{bulkGenerating ? 'Generiere...' : 'Alle Inhalte generieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkQuiz}
|
||||
disabled={bulkGenerating}
|
||||
className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{bulkGenerating ? 'Generiere...' : 'Alle Quizfragen generieren'}
|
||||
</button>
|
||||
</div>
|
||||
{bulkResult && (
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg text-sm">
|
||||
<div className="flex gap-6">
|
||||
<span className="text-green-700">Generiert: {bulkResult.generated}</span>
|
||||
<span className="text-gray-500">Uebersprungen: {bulkResult.skipped}</span>
|
||||
{bulkResult.errors?.length > 0 && (
|
||||
<span className="text-red-600">Fehler: {bulkResult.errors.length}</span>
|
||||
)}
|
||||
</div>
|
||||
{bulkResult.errors?.length > 0 && (
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
{bulkResult.errors.map((err, i) => <div key={i}>{err}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">LLM-Content-Generator</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">Generiere Schulungsinhalte und Quiz-Fragen automatisch via KI</p>
|
||||
<div className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-gray-600 block mb-1">Modul auswaehlen</label>
|
||||
<select
|
||||
value={selectedModuleId}
|
||||
onChange={e => { setSelectedModuleId(e.target.value); setGeneratedContent(null); setModuleMedia([]); if (e.target.value) { handleLoadContent(e.target.value); loadModuleMedia(e.target.value); } }}
|
||||
className="w-full px-3 py-2 text-sm border rounded-lg bg-white"
|
||||
>
|
||||
<option value="">Modul waehlen...</option>
|
||||
{modules.map(m => <option key={m.id} value={m.id}>{m.module_code} - {m.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={handleGenerateContent} disabled={!selectedModuleId || generating} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
||||
{generating ? 'Generiere...' : 'Inhalt generieren'}
|
||||
</button>
|
||||
<button onClick={handleGenerateQuiz} disabled={!selectedModuleId || generating} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
{generating ? 'Generiere...' : 'Quiz generieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{generatedContent && (
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700">Generierter Inhalt (v{generatedContent.version})</h3>
|
||||
<p className="text-xs text-gray-500">Generiert von: {generatedContent.generated_by} ({generatedContent.llm_model})</p>
|
||||
</div>
|
||||
{!generatedContent.is_published ? (
|
||||
<button onClick={() => handlePublishContent(generatedContent.id)} className="px-3 py-1.5 text-xs bg-green-600 text-white rounded hover:bg-green-700">Veroeffentlichen</button>
|
||||
) : (
|
||||
<span className="px-3 py-1.5 text-xs bg-green-100 text-green-700 rounded">Veroeffentlicht</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="prose prose-sm max-w-none border rounded p-4 bg-gray-50 max-h-96 overflow-y-auto">
|
||||
<pre className="whitespace-pre-wrap text-sm text-gray-800">{generatedContent.content_body}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audio Player */}
|
||||
{selectedModuleId && generatedContent?.is_published && (
|
||||
<AudioPlayer
|
||||
moduleId={selectedModuleId}
|
||||
audio={moduleMedia.find(m => m.media_type === 'audio') || null}
|
||||
onMediaUpdate={() => loadModuleMedia(selectedModuleId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Video Player */}
|
||||
{selectedModuleId && generatedContent?.is_published && (
|
||||
<VideoPlayer
|
||||
moduleId={selectedModuleId}
|
||||
video={moduleMedia.find(m => m.media_type === 'video') || null}
|
||||
onMediaUpdate={() => loadModuleMedia(selectedModuleId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Script Preview */}
|
||||
{selectedModuleId && generatedContent?.is_published && (
|
||||
<ScriptPreview moduleId={selectedModuleId} />
|
||||
)}
|
||||
</div>
|
||||
<ContentTab
|
||||
modules={modules}
|
||||
selectedModuleId={selectedModuleId}
|
||||
onSelectModule={id => {
|
||||
setSelectedModuleId(id)
|
||||
setGeneratedContent(null)
|
||||
setModuleMedia([])
|
||||
if (id) {
|
||||
handleLoadContent(id)
|
||||
loadModuleMedia(id)
|
||||
}
|
||||
}}
|
||||
generatedContent={generatedContent}
|
||||
generating={generating}
|
||||
bulkGenerating={bulkGenerating}
|
||||
bulkResult={bulkResult}
|
||||
moduleMedia={moduleMedia}
|
||||
onGenerateContent={handleGenerateContent}
|
||||
onGenerateQuiz={handleGenerateQuiz}
|
||||
onBulkContent={handleBulkContent}
|
||||
onBulkQuiz={handleBulkQuiz}
|
||||
onPublishContent={handlePublishContent}
|
||||
onReloadMedia={() => loadModuleMedia(selectedModuleId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'audit' && (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Zeitpunkt</th>
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Aktion</th>
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Entitaet</th>
|
||||
<th className="text-left p-2 border font-medium text-gray-700">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{auditLog.map(entry => (
|
||||
<tr key={entry.id} className="border-b hover:bg-gray-50">
|
||||
<td className="p-2 border text-xs text-gray-600">{new Date(entry.created_at).toLocaleString('de-DE')}</td>
|
||||
<td className="p-2 border"><span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-700">{entry.action}</span></td>
|
||||
<td className="p-2 border text-xs">{entry.entity_type}</td>
|
||||
<td className="p-2 border text-xs text-gray-600">{JSON.stringify(entry.details).substring(0, 100)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{auditLog.length === 0 && <p className="text-center text-gray-500 py-8">Keine Audit-Eintraege</p>}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'audit' && <AuditTab auditLog={auditLog} />}
|
||||
|
||||
{/* Modals & Drawers */}
|
||||
{showModuleCreate && (
|
||||
@@ -687,441 +364,3 @@ export default function TrainingPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MODULE CREATE MODAL
|
||||
// =============================================================================
|
||||
|
||||
function ModuleCreateModal({ onClose, onSaved }: { onClose: () => void; onSaved: () => void }) {
|
||||
const [moduleCode, setModuleCode] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [regulationArea, setRegulationArea] = useState('dsgvo')
|
||||
const [frequencyType, setFrequencyType] = useState('annual')
|
||||
const [durationMinutes, setDurationMinutes] = useState(45)
|
||||
const [passThreshold, setPassThreshold] = useState(70)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!moduleCode || !title) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await createModule({ module_code: moduleCode, title, description, regulation_area: regulationArea, frequency_type: frequencyType, duration_minutes: durationMinutes, pass_threshold: passThreshold })
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Erstellen')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Neues Trainingsmodul</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<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-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Modul-Code *</label>
|
||||
<input type="text" value={moduleCode} onChange={e => setModuleCode(e.target.value.toUpperCase())} placeholder="DSGVO-BASICS" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Regelungsbereich</label>
|
||||
<select value={regulationArea} onChange={e => setRegulationArea(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
{Object.entries(REGULATION_LABELS).map(([k, l]) => <option key={k} value={k}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-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-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</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={2} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Frequenz</label>
|
||||
<select value={frequencyType} onChange={e => setFrequencyType(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
{Object.entries(FREQUENCY_LABELS).map(([k, l]) => <option key={k} value={k}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Dauer (Min.)</label>
|
||||
<input type="number" min={1} value={durationMinutes} onChange={e => setDurationMinutes(Number(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Bestehensgrenze (%)</label>
|
||||
<input type="number" min={0} max={100} value={passThreshold} onChange={e => setPassThreshold(Number(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
|
||||
<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 || !moduleCode || !title} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
|
||||
{saving ? 'Erstelle...' : 'Modul erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MODULE EDIT DRAWER
|
||||
// =============================================================================
|
||||
|
||||
function ModuleEditDrawer({ module, onClose, onSaved }: { module: TrainingModule; onClose: () => void; onSaved: () => void }) {
|
||||
const [title, setTitle] = useState(module.title)
|
||||
const [description, setDescription] = useState(module.description || '')
|
||||
const [durationMinutes, setDurationMinutes] = useState(module.duration_minutes)
|
||||
const [passThreshold, setPassThreshold] = useState(module.pass_threshold)
|
||||
const [isActive, setIsActive] = useState(module.is_active)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await updateModule(module.id, { title, description, duration_minutes: durationMinutes, pass_threshold: passThreshold, is_active: isActive })
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm(`Modul "${module.title}" wirklich loeschen?`)) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteModule(module.id)
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Loeschen')
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-start justify-end">
|
||||
<div className="h-full w-full max-w-lg bg-white shadow-xl flex flex-col">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${REGULATION_COLORS[module.regulation_area]?.bg || 'bg-gray-100'} ${REGULATION_COLORS[module.regulation_area]?.text || 'text-gray-700'}`}>
|
||||
{REGULATION_LABELS[module.regulation_area] || module.regulation_area}
|
||||
</span>
|
||||
{module.nis2_relevant && <span className="text-xs px-1.5 py-0.5 bg-purple-100 text-purple-700 rounded">NIS2</span>}
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mt-1">{module.module_code}</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<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="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-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-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</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 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Dauer (Minuten)</label>
|
||||
<input type="number" min={1} value={durationMinutes} onChange={e => setDurationMinutes(Number(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Bestehensgrenze (%)</label>
|
||||
<input type="number" min={0} max={100} value={passThreshold} onChange={e => setPassThreshold(Number(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-sm font-medium text-gray-700">Modul aktiv</span>
|
||||
<button
|
||||
onClick={() => setIsActive(!isActive)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isActive ? 'bg-blue-600' : 'bg-gray-200'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${isActive ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
<div className="p-6 border-t border-gray-200 space-y-3">
|
||||
<button onClick={handleSave} disabled={saving} className="w-full px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
|
||||
{saving ? 'Speichern...' : 'Aenderungen speichern'}
|
||||
</button>
|
||||
<button onClick={handleDelete} disabled={deleting} className="w-full px-4 py-2 text-sm bg-red-50 text-red-600 border border-red-200 rounded-lg hover:bg-red-100 disabled:opacity-50 transition-colors">
|
||||
{deleting ? 'Loeschen...' : 'Modul loeschen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MATRIX ADD MODAL
|
||||
// =============================================================================
|
||||
|
||||
function MatrixAddModal({ roleCode, modules, onClose, onSaved }: {
|
||||
roleCode: string
|
||||
modules: TrainingModule[]
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const activeModules = modules.filter(m => m.is_active).sort((a, b) => a.module_code.localeCompare(b.module_code))
|
||||
const [moduleId, setModuleId] = useState(activeModules[0]?.id || '')
|
||||
const [isMandatory, setIsMandatory] = useState(true)
|
||||
const [priority, setPriority] = useState(1)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!moduleId) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await setMatrixEntry({ role_code: roleCode, module_id: moduleId, is_mandatory: isMandatory, priority })
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Hinzufuegen')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Modul zu Rolle hinzufuegen</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<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-4">
|
||||
<p className="text-sm text-gray-500">Rolle: <span className="font-medium text-gray-900">{roleCode}</span></p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Modul</label>
|
||||
<select value={moduleId} onChange={e => setModuleId(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
{activeModules.map(m => <option key={m.id} value={m.id}>{m.module_code} — {m.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700">Pflichtmodul</label>
|
||||
<button onClick={() => setIsMandatory(!isMandatory)} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isMandatory ? 'bg-blue-600' : 'bg-gray-200'}`}>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${isMandatory ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Prioritaet</label>
|
||||
<input type="number" min={1} value={priority} onChange={e => setPriority(Number(e.target.value))} className="w-24 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
|
||||
<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 || !moduleId} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
|
||||
{saving ? 'Hinzufuegen...' : 'Hinzufuegen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ASSIGNMENT DETAIL DRAWER
|
||||
// =============================================================================
|
||||
|
||||
function AssignmentDetailDrawer({ assignment, onClose, onSaved }: {
|
||||
assignment: TrainingAssignment
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [deadline, setDeadline] = useState(assignment.deadline ? assignment.deadline.split('T')[0] : '')
|
||||
const [savingDeadline, setSavingDeadline] = useState(false)
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleAction = async (action: () => Promise<unknown>) => {
|
||||
setActionLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await action()
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler')
|
||||
setActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveDeadline = async () => {
|
||||
setSavingDeadline(true)
|
||||
setError(null)
|
||||
try {
|
||||
await updateAssignment(assignment.id, { deadline: new Date(deadline).toISOString() })
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
|
||||
setSavingDeadline(false)
|
||||
}
|
||||
}
|
||||
|
||||
const statusActions: Record<string, { label: string; action: () => Promise<unknown> } | null> = {
|
||||
pending: { label: 'Starten', action: () => startAssignment(assignment.id) },
|
||||
in_progress: { label: 'Als abgeschlossen markieren', action: () => completeAssignment(assignment.id) },
|
||||
overdue: { label: 'Als erledigt markieren', action: () => completeAssignment(assignment.id) },
|
||||
completed: null,
|
||||
expired: null,
|
||||
}
|
||||
const currentAction = statusActions[assignment.status] || null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-start justify-end">
|
||||
<div className="h-full w-full max-w-lg bg-white shadow-xl flex flex-col">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">{assignment.module_title || assignment.module_code}</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{assignment.module_code}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<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="flex-1 overflow-y-auto p-6 space-y-5">
|
||||
{/* Employee */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="font-medium text-gray-900">{assignment.user_name}</div>
|
||||
<div className="text-sm text-gray-500">{assignment.user_email}</div>
|
||||
{assignment.role_code && <div className="text-xs text-gray-400 mt-1">Rolle: {assignment.role_code}</div>}
|
||||
</div>
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between"><span className="text-gray-500">Erstellt:</span><span>{new Date(assignment.created_at).toLocaleString('de-DE')}</span></div>
|
||||
{assignment.started_at && <div className="flex justify-between"><span className="text-gray-500">Gestartet:</span><span>{new Date(assignment.started_at).toLocaleString('de-DE')}</span></div>}
|
||||
{assignment.completed_at && <div className="flex justify-between"><span className="text-gray-500">Abgeschlossen:</span><span className="text-green-600">{new Date(assignment.completed_at).toLocaleString('de-DE')}</span></div>}
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-500">Fortschritt</span>
|
||||
<span className="font-medium">{assignment.progress_percent}%</span>
|
||||
</div>
|
||||
<div className="w-full h-3 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div className="bg-blue-500 h-full rounded-full transition-all" style={{ width: `${assignment.progress_percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quiz Score */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Quiz-Score</span>
|
||||
{assignment.quiz_score != null ? (
|
||||
<span className={`px-2 py-1 rounded text-sm font-medium ${assignment.quiz_passed ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||
{assignment.quiz_score.toFixed(0)}% {assignment.quiz_passed ? '(Bestanden)' : '(Nicht bestanden)'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 rounded text-sm bg-gray-100 text-gray-500">Noch kein Quiz</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Escalation */}
|
||||
{assignment.escalation_level > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Eskalationslevel</span>
|
||||
<span className="px-2 py-1 rounded text-sm font-medium bg-orange-100 text-orange-700">
|
||||
Level {assignment.escalation_level}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Certificate */}
|
||||
{assignment.certificate_id && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Zertifikat</span>
|
||||
<span className="text-sm text-blue-600">Zertifikat vorhanden</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Action */}
|
||||
{currentAction && (
|
||||
<button
|
||||
onClick={() => handleAction(currentAction.action)}
|
||||
disabled={actionLoading}
|
||||
className="w-full px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{actionLoading ? 'Bitte warten...' : currentAction.label}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Deadline Edit */}
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Deadline bearbeiten</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={deadline}
|
||||
onChange={e => setDeadline(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveDeadline}
|
||||
disabled={savingDeadline || !deadline}
|
||||
className="px-4 py-2 text-sm bg-gray-800 text-white rounded-lg hover:bg-gray-900 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{savingDeadline ? 'Speichern...' : 'Deadline speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KPICard({ label, value, color }: { label: string; value: string | number; color?: string }) {
|
||||
const colorMap: Record<string, string> = {
|
||||
green: 'bg-green-50 border-green-200',
|
||||
yellow: 'bg-yellow-50 border-yellow-200',
|
||||
red: 'bg-red-50 border-red-200',
|
||||
}
|
||||
const textMap: Record<string, string> = {
|
||||
green: 'text-green-700',
|
||||
yellow: 'text-yellow-700',
|
||||
red: 'text-red-700',
|
||||
}
|
||||
return (
|
||||
<div className={`border rounded-lg p-4 ${color ? colorMap[color] || 'bg-white border-gray-200' : 'bg-white border-gray-200'}`}>
|
||||
<p className="text-xs text-gray-500">{label}</p>
|
||||
<p className={`text-2xl font-bold mt-1 ${color ? textMap[color] || 'text-gray-900' : 'text-gray-900'}`}>{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user