fix: Restore all files lost during destructive rebase

A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit bfdaf63ba9
2009 changed files with 749983 additions and 1731 deletions

View File

@@ -0,0 +1,867 @@
'use client'
/**
* Audit Checklist Page - Sprint 3 Feature
*
* Ermoeglicht Auditoren:
* - Audit-Sessions zu erstellen und zu verwalten
* - Checklisten-Items durchzuarbeiten
* - Sign-offs mit digitaler Signatur (SHA-256) zu erstellen
* - Fortschritt und Statistiken zu verfolgen
*/
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import AdminLayout from '@/components/admin/AdminLayout'
import { getTerm, Language } from '@/lib/compliance-i18n'
// Types based on backend schemas
interface AuditSession {
id: string
name: string
description: string | null
auditor_name: string
auditor_email: string | null
status: 'draft' | 'in_progress' | 'completed' | 'archived'
regulation_ids: string[] | null
total_items: number
completed_items: number
created_at: string
started_at: string | null
completed_at: string | null
}
interface AuditChecklistItem {
requirement_id: string
regulation_code: string
article: string
title: string
current_result: 'compliant' | 'compliant_notes' | 'non_compliant' | 'not_applicable' | 'pending'
notes: string | null
is_signed: boolean
signed_at: string | null
signed_by: string | null
evidence_count: number
controls_mapped: number
}
interface AuditStatistics {
total: number
compliant: number
compliant_with_notes: number
non_compliant: number
not_applicable: number
pending: number
completion_percentage: number
}
interface Regulation {
id: string
code: string
name: string
requirement_count: number
}
// Result status configuration
const RESULT_STATUS = {
pending: { label: 'Ausstehend', labelEn: 'Pending', color: 'bg-slate-100 text-slate-700', icon: '○' },
compliant: { label: 'Konform', labelEn: 'Compliant', color: 'bg-green-100 text-green-700', icon: '✓' },
compliant_notes: { label: 'Konform (Anm.)', labelEn: 'Compliant w/ Notes', color: 'bg-yellow-100 text-yellow-700', icon: '⚠' },
non_compliant: { label: 'Nicht konform', labelEn: 'Non-Compliant', color: 'bg-red-100 text-red-700', icon: '✗' },
not_applicable: { label: 'N/A', labelEn: 'N/A', color: 'bg-slate-50 text-slate-500', icon: '' },
}
const SESSION_STATUS = {
draft: { label: 'Entwurf', color: 'bg-slate-100 text-slate-700' },
in_progress: { label: 'In Bearbeitung', color: 'bg-blue-100 text-blue-700' },
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700' },
archived: { label: 'Archiviert', color: 'bg-slate-50 text-slate-500' },
}
export default function AuditChecklistPage() {
const [sessions, setSessions] = useState<AuditSession[]>([])
const [selectedSession, setSelectedSession] = useState<AuditSession | null>(null)
const [checklistItems, setChecklistItems] = useState<AuditChecklistItem[]>([])
const [statistics, setStatistics] = useState<AuditStatistics | null>(null)
const [regulations, setRegulations] = useState<Regulation[]>([])
const [loading, setLoading] = useState(true)
const [loadingChecklist, setLoadingChecklist] = useState(false)
const [error, setError] = useState<string | null>(null)
// Filters
const [filterStatus, setFilterStatus] = useState<string>('all')
const [filterRegulation, setFilterRegulation] = useState<string>('all')
const [searchQuery, setSearchQuery] = useState('')
// Modal states
const [showCreateModal, setShowCreateModal] = useState(false)
const [showSignOffModal, setShowSignOffModal] = useState(false)
const [selectedItem, setSelectedItem] = useState<AuditChecklistItem | null>(null)
// Language
const [lang] = useState<Language>('de')
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
// Load sessions
const loadSessions = useCallback(async () => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/audit/sessions`)
if (res.ok) {
const data = await res.json()
setSessions(data.sessions || [])
} else {
console.error('Failed to load sessions:', res.status)
}
} catch (err) {
console.error('Failed to load sessions:', err)
setError('Verbindung zum Backend fehlgeschlagen')
} finally {
setLoading(false)
}
}, [BACKEND_URL])
// Load regulations for filter
const loadRegulations = useCallback(async () => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/regulations`)
if (res.ok) {
const data = await res.json()
setRegulations(data.regulations || [])
}
} catch (err) {
console.error('Failed to load regulations:', err)
}
}, [BACKEND_URL])
// Load checklist for selected session
const loadChecklist = useCallback(async (sessionId: string) => {
setLoadingChecklist(true)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/audit/checklist/${sessionId}`)
if (res.ok) {
const data = await res.json()
setChecklistItems(data.items || [])
setStatistics(data.statistics || null)
// Update session with latest data
if (data.session) {
setSelectedSession(data.session)
}
} else {
console.error('Failed to load checklist:', res.status)
}
} catch (err) {
console.error('Failed to load checklist:', err)
} finally {
setLoadingChecklist(false)
}
}, [BACKEND_URL])
useEffect(() => {
loadSessions()
loadRegulations()
}, [loadSessions, loadRegulations])
useEffect(() => {
if (selectedSession) {
loadChecklist(selectedSession.id)
}
}, [selectedSession, loadChecklist])
// Filter checklist items
const filteredItems = checklistItems.filter(item => {
if (filterStatus !== 'all' && item.current_result !== filterStatus) return false
if (filterRegulation !== 'all' && item.regulation_code !== filterRegulation) return false
if (searchQuery) {
const query = searchQuery.toLowerCase()
return (
item.title.toLowerCase().includes(query) ||
item.article.toLowerCase().includes(query) ||
item.regulation_code.toLowerCase().includes(query)
)
}
return true
})
// Handle sign-off
const handleSignOff = async (
item: AuditChecklistItem,
result: AuditChecklistItem['current_result'],
notes: string,
sign: boolean
) => {
if (!selectedSession) return
try {
const res = await fetch(
`${BACKEND_URL}/api/v1/compliance/audit/checklist/${selectedSession.id}/items/${item.requirement_id}/sign-off`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ result, notes, sign }),
}
)
if (res.ok) {
// Reload checklist to get updated data
await loadChecklist(selectedSession.id)
setShowSignOffModal(false)
setSelectedItem(null)
} else {
const err = await res.json()
alert(`Fehler: ${err.detail || 'Sign-off fehlgeschlagen'}`)
}
} catch (err) {
console.error('Sign-off failed:', err)
alert('Netzwerkfehler bei Sign-off')
}
}
// Session list view when no session is selected
if (!selectedSession && !loading) {
return (
<AdminLayout title="Audit Checkliste" description="Strukturierte Pruefungen durchfuehren">
<div className="mb-6 flex items-center justify-between">
<Link
href="/admin/compliance"
className="text-sm text-slate-600 hover:text-slate-900 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="M15 19l-7-7 7-7" />
</svg>
Zurueck zu Compliance
</Link>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Neue Audit-Session
</button>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
)}
{/* Session Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sessions.length === 0 ? (
<div className="col-span-full bg-white rounded-lg border border-slate-200 p-8 text-center">
<svg className="w-12 h-12 mx-auto text-slate-300 mb-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>
<h3 className="text-lg font-medium text-slate-700 mb-2">Keine Audit-Sessions</h3>
<p className="text-slate-500 mb-4">Erstellen Sie eine neue Audit-Session um zu beginnen.</p>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Erste Session erstellen
</button>
</div>
) : (
sessions.map(session => (
<SessionCard
key={session.id}
session={session}
onClick={() => setSelectedSession(session)}
/>
))
)}
</div>
{/* Create Session Modal */}
{showCreateModal && (
<CreateSessionModal
regulations={regulations}
onClose={() => setShowCreateModal(false)}
onCreate={async (data) => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/audit/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (res.ok) {
await loadSessions()
setShowCreateModal(false)
} else {
const err = await res.json()
alert(`Fehler: ${err.detail || 'Session konnte nicht erstellt werden'}`)
}
} catch (err) {
console.error('Create session failed:', err)
alert('Netzwerkfehler')
}
}}
/>
)}
</AdminLayout>
)
}
// Loading state
if (loading) {
return (
<AdminLayout title="Audit Checkliste" description="Laden...">
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
</AdminLayout>
)
}
// Checklist view for selected session
return (
<AdminLayout title={selectedSession?.name || 'Audit Checkliste'} description="Pruefung durchfuehren">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<button
onClick={() => setSelectedSession(null)}
className="text-sm text-slate-600 hover:text-slate-900 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="M15 19l-7-7 7-7" />
</svg>
Zurueck zur Uebersicht
</button>
<div className="flex items-center gap-3">
<span className={`px-3 py-1 text-sm font-medium rounded-full ${SESSION_STATUS[selectedSession?.status || 'draft'].color}`}>
{SESSION_STATUS[selectedSession?.status || 'draft'].label}
</span>
<button
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 flex items-center gap-2"
onClick={() => {/* Export PDF */}}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Export PDF
</button>
</div>
</div>
{/* Progress Bar */}
{statistics && (
<AuditProgressBar statistics={statistics} lang={lang} />
)}
{/* Filters */}
<div className="bg-white rounded-lg border border-slate-200 p-4 mb-6">
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<label className="block text-xs font-medium text-slate-500 mb-1">Suche</label>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Artikel, Titel..."
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-500 mb-1">Verordnung</label>
<select
value={filterRegulation}
onChange={(e) => setFilterRegulation(e.target.value)}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="all">Alle</option>
{regulations.map(reg => (
<option key={reg.code} value={reg.code}>{reg.code}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Status</label>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="all">Alle</option>
{Object.entries(RESULT_STATUS).map(([key, { label }]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
</div>
</div>
{/* Checklist Items */}
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
{loadingChecklist ? (
<div className="p-8 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mx-auto"></div>
<p className="mt-2 text-slate-500">Checkliste wird geladen...</p>
</div>
) : filteredItems.length === 0 ? (
<div className="p-8 text-center">
<p className="text-slate-500">Keine Eintraege gefunden</p>
</div>
) : (
<div className="divide-y divide-slate-100">
{filteredItems.map((item, index) => (
<ChecklistItemRow
key={item.requirement_id}
item={item}
index={index + 1}
onSignOff={() => {
setSelectedItem(item)
setShowSignOffModal(true)
}}
lang={lang}
/>
))}
</div>
)}
</div>
{/* Sign-off Modal */}
{showSignOffModal && selectedItem && (
<SignOffModal
item={selectedItem}
auditorName={selectedSession?.auditor_name || ''}
onClose={() => {
setShowSignOffModal(false)
setSelectedItem(null)
}}
onSubmit={(result, notes, sign) => handleSignOff(selectedItem, result, notes, sign)}
/>
)}
</AdminLayout>
)
}
// Session Card Component
function SessionCard({ session, onClick }: { session: AuditSession; onClick: () => void }) {
const completionPercent = session.total_items > 0
? Math.round((session.completed_items / session.total_items) * 100)
: 0
return (
<button
onClick={onClick}
className="bg-white rounded-lg border border-slate-200 p-4 text-left hover:shadow-md hover:border-primary-300 transition-all"
>
<div className="flex items-start justify-between mb-3">
<h3 className="font-semibold text-slate-900">{session.name}</h3>
<span className={`px-2 py-0.5 text-xs font-medium rounded ${SESSION_STATUS[session.status].color}`}>
{SESSION_STATUS[session.status].label}
</span>
</div>
{session.description && (
<p className="text-sm text-slate-500 mb-3 line-clamp-2">{session.description}</p>
)}
<div className="mb-3">
<div className="flex justify-between text-xs text-slate-500 mb-1">
<span>{session.completed_items} / {session.total_items} Punkte</span>
<span>{completionPercent}%</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 transition-all"
style={{ width: `${completionPercent}%` }}
/>
</div>
</div>
<div className="flex items-center justify-between text-xs text-slate-500">
<span>Auditor: {session.auditor_name}</span>
<span>{new Date(session.created_at).toLocaleDateString('de-DE')}</span>
</div>
</button>
)
}
// Progress Bar Component
function AuditProgressBar({ statistics, lang }: { statistics: AuditStatistics; lang: Language }) {
const segments = [
{ key: 'compliant', count: statistics.compliant, color: 'bg-green-500', label: 'Konform' },
{ key: 'compliant_with_notes', count: statistics.compliant_with_notes, color: 'bg-yellow-500', label: 'Mit Anm.' },
{ key: 'non_compliant', count: statistics.non_compliant, color: 'bg-red-500', label: 'Nicht konform' },
{ key: 'not_applicable', count: statistics.not_applicable, color: 'bg-slate-300', label: 'N/A' },
{ key: 'pending', count: statistics.pending, color: 'bg-slate-100', label: 'Offen' },
]
return (
<div className="bg-white rounded-lg border border-slate-200 p-4 mb-6">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-slate-900">Fortschritt</h3>
<span className="text-2xl font-bold text-primary-600">
{Math.round(statistics.completion_percentage)}%
</span>
</div>
{/* Stacked Progress Bar */}
<div className="h-4 bg-slate-100 rounded-full overflow-hidden flex">
{segments.map(seg => (
seg.count > 0 && (
<div
key={seg.key}
className={`${seg.color} transition-all`}
style={{ width: `${(seg.count / statistics.total) * 100}%` }}
title={`${seg.label}: ${seg.count}`}
/>
)
))}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-4 mt-3">
{segments.map(seg => (
<div key={seg.key} className="flex items-center gap-1.5 text-sm">
<span className={`w-3 h-3 rounded ${seg.color}`} />
<span className="text-slate-600">{seg.label}:</span>
<span className="font-medium text-slate-900">{seg.count}</span>
</div>
))}
</div>
</div>
)
}
// Checklist Item Row Component
function ChecklistItemRow({
item,
index,
onSignOff,
lang,
}: {
item: AuditChecklistItem
index: number
onSignOff: () => void
lang: Language
}) {
const status = RESULT_STATUS[item.current_result]
return (
<div className="p-4 hover:bg-slate-50 transition-colors">
<div className="flex items-start gap-4">
{/* Status Icon */}
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-lg ${status.color}`}>
{status.icon}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-slate-500">{index}.</span>
<span className="font-mono text-sm text-primary-600">{item.regulation_code}</span>
<span className="font-mono text-sm text-slate-700">{item.article}</span>
{item.is_signed && (
<span className="px-1.5 py-0.5 text-xs bg-green-100 text-green-700 rounded flex items-center gap-1">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
Signiert
</span>
)}
</div>
<h4 className="text-sm font-medium text-slate-900 mb-1">{item.title}</h4>
{item.notes && (
<p className="text-xs text-slate-500 italic mb-2">"{item.notes}"</p>
)}
<div className="flex items-center gap-4 text-xs text-slate-500">
<span>{item.controls_mapped} Controls</span>
<span>{item.evidence_count} Nachweise</span>
{item.signed_at && (
<span>Signiert: {new Date(item.signed_at).toLocaleDateString('de-DE')}</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<span className={`px-2 py-1 text-xs font-medium rounded ${status.color}`}>
{lang === 'de' ? status.label : status.labelEn}
</span>
<button
onClick={onSignOff}
className="p-2 text-slate-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-colors"
title="Sign-off"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
</div>
</div>
</div>
)
}
// Create Session Modal
function CreateSessionModal({
regulations,
onClose,
onCreate,
}: {
regulations: Regulation[]
onClose: () => void
onCreate: (data: { name: string; description?: string; auditor_name: string; auditor_email?: string; regulation_codes?: string[] }) => void
}) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [auditorName, setAuditorName] = useState('')
const [auditorEmail, setAuditorEmail] = useState('')
const [selectedRegs, setSelectedRegs] = useState<string[]>([])
const [submitting, setSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name || !auditorName) return
setSubmitting(true)
await onCreate({
name,
description: description || undefined,
auditor_name: auditorName,
auditor_email: auditorEmail || undefined,
regulation_codes: selectedRegs.length > 0 ? selectedRegs : undefined,
})
setSubmitting(false)
}
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-lg w-full mx-4 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900">Neue Audit-Session</h2>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600">
<svg className="w-6 h-6" 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>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Name der Pruefung *
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="z.B. Q1 2026 Compliance Audit"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Beschreibung
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optionale Beschreibung..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg"
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Auditor Name *
</label>
<input
type="text"
value={auditorName}
onChange={(e) => setAuditorName(e.target.value)}
placeholder="Dr. Max Mustermann"
className="w-full px-3 py-2 border border-slate-300 rounded-lg"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
E-Mail
</label>
<input
type="email"
value={auditorEmail}
onChange={(e) => setAuditorEmail(e.target.value)}
placeholder="auditor@example.com"
className="w-full px-3 py-2 border border-slate-300 rounded-lg"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Verordnungen (optional - leer = alle)
</label>
<div className="flex flex-wrap gap-2 p-2 border border-slate-300 rounded-lg max-h-32 overflow-y-auto">
{regulations.map(reg => (
<label key={reg.code} className="flex items-center gap-1.5">
<input
type="checkbox"
checked={selectedRegs.includes(reg.code)}
onChange={(e) => {
if (e.target.checked) {
setSelectedRegs([...selectedRegs, reg.code])
} else {
setSelectedRegs(selectedRegs.filter(r => r !== reg.code))
}
}}
className="rounded"
/>
<span className="text-sm text-slate-700">{reg.code}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
>
Abbrechen
</button>
<button
type="submit"
disabled={!name || !auditorName || submitting}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{submitting ? 'Erstelle...' : 'Session erstellen'}
</button>
</div>
</form>
</div>
</div>
)
}
// Sign-off Modal
function SignOffModal({
item,
auditorName,
onClose,
onSubmit,
}: {
item: AuditChecklistItem
auditorName: string
onClose: () => void
onSubmit: (result: AuditChecklistItem['current_result'], notes: string, sign: boolean) => void
}) {
const [result, setResult] = useState<AuditChecklistItem['current_result']>(item.current_result)
const [notes, setNotes] = useState(item.notes || '')
const [sign, setSign] = useState(false)
const [submitting, setSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
await onSubmit(result, notes, sign)
setSubmitting(false)
}
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-lg w-full mx-4 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200">
<h2 className="text-lg font-semibold text-slate-900">Auditor Sign-off</h2>
<p className="text-sm text-slate-500 mt-1">
{item.regulation_code} {item.article} - {item.title}
</p>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Pruefungsergebnis
</label>
<div className="space-y-2">
{Object.entries(RESULT_STATUS).map(([key, { label, color }]) => (
<label key={key} className="flex items-center gap-3 p-2 rounded-lg hover:bg-slate-50 cursor-pointer">
<input
type="radio"
name="result"
value={key}
checked={result === key}
onChange={() => setResult(key as typeof result)}
className="w-4 h-4"
/>
<span className={`px-2 py-0.5 text-sm font-medium rounded ${color}`}>
{label}
</span>
</label>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Anmerkungen
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Optionale Anmerkungen zur Pruefung..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg"
rows={3}
/>
</div>
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={sign}
onChange={(e) => setSign(e.target.checked)}
className="mt-1 rounded"
/>
<div>
<span className="font-medium text-amber-800">Digital signieren</span>
<p className="text-sm text-amber-700 mt-0.5">
Erstellt eine SHA-256 Signatur des Ergebnisses. Diese Aktion kann nicht rueckgaengig gemacht werden.
</p>
</div>
</label>
</div>
<div className="text-sm text-slate-500 pt-2 border-t border-slate-100">
<p>Auditor: <span className="font-medium text-slate-700">{auditorName}</span></p>
<p>Datum: <span className="font-medium text-slate-700">{new Date().toLocaleString('de-DE')}</span></p>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
>
Abbrechen
</button>
<button
type="submit"
disabled={submitting}
className={`px-4 py-2 rounded-lg disabled:opacity-50 ${
sign
? 'bg-amber-600 text-white hover:bg-amber-700'
: 'bg-primary-600 text-white hover:bg-primary-700'
}`}
>
{submitting ? 'Speichere...' : sign ? 'Signieren & Speichern' : 'Speichern'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,871 @@
'use client'
/**
* Audit Workspace - Collaborative Audit Management
*
* Features:
* - View all requirements with original text from regulations
* - Document implementation details for each requirement
* - Link to source documents (PDFs, EUR-Lex)
* - Track audit status (pending, in_review, approved, rejected)
* - Add code references and evidence
* - Auditor notes and sign-off
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
import AdminLayout from '@/components/admin/AdminLayout'
// Types
interface Regulation {
id: string
code: string
name: string
full_name: string
regulation_type: string
source_url: string | null
local_pdf_path: string | null
requirement_count: number
}
interface Requirement {
id: string
regulation_id: string
regulation_code?: string
article: string
paragraph: string | null
title: string
description: string | null
requirement_text: string | null
breakpilot_interpretation: string | null
implementation_status: string
implementation_details: string | null
code_references: Array<{ file: string; line?: number; description: string }> | null
evidence_description: string | null
audit_status: string
auditor_notes: string | null
is_applicable: boolean
applicability_reason: string | null
priority: number
source_page: number | null
source_section: string | null
}
interface RequirementUpdate {
implementation_status?: string
implementation_details?: string
code_references?: Array<{ file: string; line?: number; description: string }>
evidence_description?: string
audit_status?: string
auditor_notes?: string
is_applicable?: boolean
applicability_reason?: string
}
const IMPLEMENTATION_STATUS = {
not_started: { label: 'Nicht gestartet', color: 'bg-slate-400' },
in_progress: { label: 'In Arbeit', color: 'bg-yellow-500' },
implemented: { label: 'Implementiert', color: 'bg-blue-500' },
verified: { label: 'Verifiziert', color: 'bg-green-500' },
}
const AUDIT_STATUS = {
pending: { label: 'Ausstehend', color: 'bg-slate-400' },
in_review: { label: 'In Pruefung', color: 'bg-yellow-500' },
approved: { label: 'Genehmigt', color: 'bg-green-500' },
rejected: { label: 'Abgelehnt', color: 'bg-red-500' },
}
const PRIORITY_LABELS: Record<number, { label: string; color: string }> = {
1: { label: 'Kritisch', color: 'text-red-600' },
2: { label: 'Hoch', color: 'text-orange-600' },
3: { label: 'Mittel', color: 'text-yellow-600' },
}
export default function AuditWorkspacePage() {
const [regulations, setRegulations] = useState<Regulation[]>([])
const [requirements, setRequirements] = useState<Requirement[]>([])
const [selectedRegulation, setSelectedRegulation] = useState<string | null>(null)
const [selectedRequirement, setSelectedRequirement] = useState<Requirement | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [filterAuditStatus, setFilterAuditStatus] = useState<string>('all')
const [filterImplStatus, setFilterImplStatus] = useState<string>('all')
const [searchQuery, setSearchQuery] = useState('')
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
useEffect(() => {
loadRegulations()
}, [])
useEffect(() => {
if (selectedRegulation) {
loadRequirements(selectedRegulation)
}
}, [selectedRegulation])
const loadRegulations = async () => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/regulations`)
if (res.ok) {
const data = await res.json()
setRegulations(data.regulations || [])
// Select first regulation by default
if (data.regulations?.length > 0) {
setSelectedRegulation(data.regulations[0].code)
}
}
} catch (err) {
console.error('Failed to load regulations:', err)
} finally {
setLoading(false)
}
}
const loadRequirements = async (regCode: string) => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/regulations/${regCode}/requirements`)
if (res.ok) {
const data = await res.json()
setRequirements(data.requirements || [])
}
} catch (err) {
console.error('Failed to load requirements:', err)
}
}
const updateRequirement = async (reqId: string, updates: RequirementUpdate) => {
setSaving(true)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/requirements/${reqId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
if (res.ok) {
const updated = await res.json()
setRequirements(prev => prev.map(r => r.id === reqId ? { ...r, ...updates } : r))
if (selectedRequirement?.id === reqId) {
setSelectedRequirement({ ...selectedRequirement, ...updates })
}
}
} catch (err) {
console.error('Failed to update requirement:', err)
} finally {
setSaving(false)
}
}
const filteredRequirements = requirements.filter(req => {
if (filterAuditStatus !== 'all' && req.audit_status !== filterAuditStatus) return false
if (filterImplStatus !== 'all' && req.implementation_status !== filterImplStatus) return false
if (searchQuery) {
const query = searchQuery.toLowerCase()
return (
req.title.toLowerCase().includes(query) ||
req.article.toLowerCase().includes(query) ||
req.requirement_text?.toLowerCase().includes(query)
)
}
return true
})
const currentRegulation = regulations.find(r => r.code === selectedRegulation)
// Statistics
const stats = {
total: requirements.length,
verified: requirements.filter(r => r.implementation_status === 'verified').length,
approved: requirements.filter(r => r.audit_status === 'approved').length,
pending: requirements.filter(r => r.audit_status === 'pending').length,
}
return (
<AdminLayout title="Audit Workspace" description="Gemeinsam mit Pruefern arbeiten">
{/* Header with back link */}
<div className="mb-6 flex items-center justify-between">
<Link
href="/admin/compliance"
className="text-sm text-slate-600 hover:text-slate-900 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="M15 19l-7-7 7-7" />
</svg>
Zurueck zu Compliance
</Link>
<div className="flex items-center gap-4">
<div className="text-sm text-slate-500">
{stats.approved}/{stats.total} genehmigt | {stats.verified}/{stats.total} verifiziert
</div>
</div>
</div>
<div className="grid grid-cols-12 gap-6">
{/* Left Sidebar - Regulation & Requirement List */}
<div className="col-span-4 space-y-4">
{/* Regulation Selector */}
<div className="bg-white rounded-lg border border-slate-200 p-4">
<label className="block text-sm font-medium text-slate-700 mb-2">
Verordnung / Standard
</label>
<select
value={selectedRegulation || ''}
onChange={(e) => setSelectedRegulation(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
{regulations.map(reg => (
<option key={reg.code} value={reg.code}>
{reg.code} - {reg.name} ({reg.requirement_count})
</option>
))}
</select>
{currentRegulation?.source_url && (
<a
href={currentRegulation.source_url}
target="_blank"
rel="noopener noreferrer"
className="mt-2 text-sm text-primary-600 hover:text-primary-800 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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Originaldokument oeffnen
</a>
)}
{currentRegulation?.local_pdf_path && (
<a
href={`/docs/${currentRegulation.local_pdf_path}`}
target="_blank"
className="mt-1 text-sm text-slate-600 hover:text-slate-800 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="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Lokale PDF
</a>
)}
</div>
{/* Filters */}
<div className="bg-white rounded-lg border border-slate-200 p-4 space-y-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Suche</label>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Artikel, Titel..."
className="w-full px-3 py-1.5 text-sm border border-slate-300 rounded-lg"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Audit-Status</label>
<select
value={filterAuditStatus}
onChange={(e) => setFilterAuditStatus(e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded-lg"
>
<option value="all">Alle</option>
{Object.entries(AUDIT_STATUS).map(([key, { label }]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Impl.-Status</label>
<select
value={filterImplStatus}
onChange={(e) => setFilterImplStatus(e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded-lg"
>
<option value="all">Alle</option>
{Object.entries(IMPLEMENTATION_STATUS).map(([key, { label }]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
</div>
</div>
{/* Requirements List */}
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<div className="p-3 border-b border-slate-200 bg-slate-50">
<span className="text-sm font-medium text-slate-700">
Anforderungen ({filteredRequirements.length})
</span>
</div>
<div className="max-h-[500px] overflow-y-auto">
{filteredRequirements.map(req => (
<button
key={req.id}
onClick={() => setSelectedRequirement(req)}
className={`w-full text-left p-3 border-b border-slate-100 hover:bg-slate-50 transition-colors ${
selectedRequirement?.id === req.id ? 'bg-primary-50 border-l-4 border-l-primary-500' : ''
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-slate-600">
{req.article}{req.paragraph ? ` ${req.paragraph}` : ''}
</span>
<span className={`text-xs ${PRIORITY_LABELS[req.priority]?.color || 'text-slate-500'}`}>
{PRIORITY_LABELS[req.priority]?.label || ''}
</span>
</div>
<p className="text-sm text-slate-900 truncate mt-0.5">{req.title}</p>
</div>
<div className="flex flex-col items-end gap-1">
<span className={`w-2 h-2 rounded-full ${AUDIT_STATUS[req.audit_status as keyof typeof AUDIT_STATUS]?.color || 'bg-slate-300'}`} />
<span className={`w-2 h-2 rounded-full ${IMPLEMENTATION_STATUS[req.implementation_status as keyof typeof IMPLEMENTATION_STATUS]?.color || 'bg-slate-300'}`} />
</div>
</div>
</button>
))}
</div>
</div>
</div>
{/* Right Panel - Requirement Detail */}
<div className="col-span-8">
{selectedRequirement ? (
<RequirementDetailPanel
requirement={selectedRequirement}
regulation={currentRegulation}
onUpdate={(updates) => updateRequirement(selectedRequirement.id, updates)}
saving={saving}
/>
) : (
<div className="bg-white rounded-lg border border-slate-200 p-8 text-center">
<svg className="w-12 h-12 mx-auto text-slate-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-slate-500">Waehlen Sie eine Anforderung aus der Liste</p>
</div>
)}
</div>
</div>
</AdminLayout>
)
}
// AI Interpretation Types
interface AIInterpretation {
summary: string
applicability: string
technical_measures: string[]
affected_modules: string[]
risk_level: string
implementation_hints: string[]
confidence_score: number
error?: string
}
// Requirement Detail Panel Component
function RequirementDetailPanel({
requirement,
regulation,
onUpdate,
saving,
}: {
requirement: Requirement
regulation: Regulation | undefined
onUpdate: (updates: RequirementUpdate) => void
saving: boolean
}) {
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
const [editMode, setEditMode] = useState(false)
const [aiLoading, setAiLoading] = useState(false)
const [aiInterpretation, setAiInterpretation] = useState<AIInterpretation | null>(null)
const [showAiPanel, setShowAiPanel] = useState(false)
const [localData, setLocalData] = useState({
implementation_status: requirement.implementation_status,
implementation_details: requirement.implementation_details || '',
evidence_description: requirement.evidence_description || '',
audit_status: requirement.audit_status,
auditor_notes: requirement.auditor_notes || '',
is_applicable: requirement.is_applicable,
applicability_reason: requirement.applicability_reason || '',
})
const [newCodeRef, setNewCodeRef] = useState({ file: '', line: '', description: '' })
useEffect(() => {
setLocalData({
implementation_status: requirement.implementation_status,
implementation_details: requirement.implementation_details || '',
evidence_description: requirement.evidence_description || '',
audit_status: requirement.audit_status,
auditor_notes: requirement.auditor_notes || '',
is_applicable: requirement.is_applicable,
applicability_reason: requirement.applicability_reason || '',
})
setEditMode(false)
setAiInterpretation(null)
setShowAiPanel(false)
}, [requirement.id])
const generateAiInterpretation = async () => {
setAiLoading(true)
setShowAiPanel(true)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/ai/interpret`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requirement_id: requirement.id }),
})
if (res.ok) {
const data = await res.json()
setAiInterpretation(data)
} else {
const err = await res.json()
setAiInterpretation({
summary: '', applicability: '', technical_measures: [],
affected_modules: [], risk_level: 'unknown', implementation_hints: [],
confidence_score: 0, error: err.detail || 'Fehler bei AI-Analyse'
})
}
} catch (err) {
setAiInterpretation({
summary: '', applicability: '', technical_measures: [],
affected_modules: [], risk_level: 'unknown', implementation_hints: [],
confidence_score: 0, error: 'Netzwerkfehler bei AI-Analyse'
})
} finally {
setAiLoading(false)
}
}
const handleSave = () => {
onUpdate(localData)
setEditMode(false)
}
const addCodeReference = () => {
if (!newCodeRef.file) return
const refs = requirement.code_references || []
onUpdate({
code_references: [...refs, {
file: newCodeRef.file,
line: newCodeRef.line ? parseInt(newCodeRef.line) : undefined,
description: newCodeRef.description,
}],
})
setNewCodeRef({ file: '', line: '', description: '' })
}
return (
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-slate-200 bg-slate-50">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3">
<span className="font-mono text-lg font-semibold text-slate-900">
{requirement.article}{requirement.paragraph ? ` ${requirement.paragraph}` : ''}
</span>
<span className={`px-2 py-0.5 text-xs font-medium rounded ${
AUDIT_STATUS[requirement.audit_status as keyof typeof AUDIT_STATUS]?.color || 'bg-slate-200'
} text-white`}>
{AUDIT_STATUS[requirement.audit_status as keyof typeof AUDIT_STATUS]?.label || requirement.audit_status}
</span>
<span className={`px-2 py-0.5 text-xs font-medium rounded ${
IMPLEMENTATION_STATUS[requirement.implementation_status as keyof typeof IMPLEMENTATION_STATUS]?.color || 'bg-slate-200'
} text-white`}>
{IMPLEMENTATION_STATUS[requirement.implementation_status as keyof typeof IMPLEMENTATION_STATUS]?.label || requirement.implementation_status}
</span>
</div>
<h2 className="text-lg font-medium text-slate-900 mt-1">{requirement.title}</h2>
</div>
<div className="flex gap-2">
{editMode ? (
<>
<button
onClick={() => setEditMode(false)}
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-800"
>
Abbrechen
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</>
) : (
<button
onClick={() => setEditMode(true)}
className="px-3 py-1.5 text-sm bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200"
>
Bearbeiten
</button>
)}
</div>
</div>
</div>
<div className="p-4 space-y-6">
{/* Original Requirement Text */}
<section>
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2 flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Originaler Anforderungstext
</h3>
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
<p className="text-sm text-slate-700 whitespace-pre-wrap">
{requirement.requirement_text || 'Kein Originaltext hinterlegt'}
</p>
{requirement.source_page && (
<p className="text-xs text-slate-500 mt-2">
Quelle: {regulation?.code} Seite {requirement.source_page}
{requirement.source_section ? `, ${requirement.source_section}` : ''}
</p>
)}
</div>
</section>
{/* Applicability */}
<section>
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2">
Anwendbarkeit auf Breakpilot
</h3>
{editMode ? (
<div className="space-y-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={localData.is_applicable}
onChange={(e) => setLocalData({ ...localData, is_applicable: e.target.checked })}
className="rounded"
/>
<span className="text-sm text-slate-700">Anwendbar</span>
</label>
<textarea
value={localData.applicability_reason}
onChange={(e) => setLocalData({ ...localData, applicability_reason: e.target.value })}
placeholder="Begruendung fuer Anwendbarkeit..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={2}
/>
</div>
) : (
<div className="flex items-start gap-3">
<span className={`px-2 py-1 text-xs font-medium rounded ${
requirement.is_applicable ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-600'
}`}>
{requirement.is_applicable ? 'Anwendbar' : 'Nicht anwendbar'}
</span>
{requirement.applicability_reason && (
<p className="text-sm text-slate-600">{requirement.applicability_reason}</p>
)}
</div>
)}
</section>
{/* Breakpilot Interpretation & AI Analysis */}
<section>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
Interpretation
</h3>
<button
onClick={generateAiInterpretation}
disabled={aiLoading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-gradient-to-r from-purple-600 to-pink-600 rounded-lg hover:from-purple-700 hover:to-pink-700 disabled:opacity-50 transition-all"
>
{aiLoading ? (
<>
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
AI analysiert...
</>
) : (
<>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
AI Analyse
</>
)}
</button>
</div>
{/* Existing interpretation */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-3">
<p className="text-sm text-blue-800">
{requirement.breakpilot_interpretation || 'Keine Interpretation hinterlegt'}
</p>
</div>
{/* AI Interpretation Panel */}
{showAiPanel && (
<div className="bg-gradient-to-br from-purple-50 to-pink-50 border border-purple-200 rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-purple-800 flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
AI-generierte Analyse
</h4>
{aiInterpretation?.confidence_score && (
<span className="text-xs text-purple-600">
Konfidenz: {Math.round(aiInterpretation.confidence_score * 100)}%
</span>
)}
</div>
{aiLoading && (
<div className="text-center py-4">
<div className="animate-pulse text-purple-600">Claude analysiert die Anforderung...</div>
</div>
)}
{aiInterpretation?.error && (
<div className="bg-red-100 text-red-700 p-3 rounded text-sm">
{aiInterpretation.error}
</div>
)}
{aiInterpretation && !aiInterpretation.error && !aiLoading && (
<div className="space-y-3 text-sm">
{/* Summary */}
{aiInterpretation.summary && (
<div>
<div className="font-medium text-purple-700 mb-1">Zusammenfassung</div>
<p className="text-slate-700">{aiInterpretation.summary}</p>
</div>
)}
{/* Applicability */}
{aiInterpretation.applicability && (
<div>
<div className="font-medium text-purple-700 mb-1">Anwendbarkeit auf Breakpilot</div>
<p className="text-slate-700">{aiInterpretation.applicability}</p>
</div>
)}
{/* Risk Level */}
{aiInterpretation.risk_level && (
<div className="flex items-center gap-2">
<span className="font-medium text-purple-700">Risiko:</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
aiInterpretation.risk_level === 'critical' ? 'bg-red-100 text-red-700' :
aiInterpretation.risk_level === 'high' ? 'bg-orange-100 text-orange-700' :
aiInterpretation.risk_level === 'medium' ? 'bg-yellow-100 text-yellow-700' :
'bg-green-100 text-green-700'
}`}>
{aiInterpretation.risk_level}
</span>
</div>
)}
{/* Technical Measures */}
{aiInterpretation.technical_measures?.length > 0 && (
<div>
<div className="font-medium text-purple-700 mb-1">Technische Massnahmen</div>
<ul className="list-disc list-inside text-slate-700 space-y-1">
{aiInterpretation.technical_measures.map((m, i) => (
<li key={i}>{m}</li>
))}
</ul>
</div>
)}
{/* Affected Modules */}
{aiInterpretation.affected_modules?.length > 0 && (
<div>
<div className="font-medium text-purple-700 mb-1">Betroffene Module</div>
<div className="flex flex-wrap gap-1">
{aiInterpretation.affected_modules.map((m, i) => (
<span key={i} className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
{m}
</span>
))}
</div>
</div>
)}
{/* Implementation Hints */}
{aiInterpretation.implementation_hints?.length > 0 && (
<div>
<div className="font-medium text-purple-700 mb-1">Implementierungshinweise</div>
<ul className="list-disc list-inside text-slate-700 space-y-1">
{aiInterpretation.implementation_hints.map((h, i) => (
<li key={i}>{h}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
)}
</section>
{/* Implementation Details */}
<section>
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2 flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
Umsetzung (fuer Auditor)
</h3>
{editMode ? (
<div className="space-y-3">
<div>
<label className="block text-xs text-slate-500 mb-1">Implementierungsstatus</label>
<select
value={localData.implementation_status}
onChange={(e) => setLocalData({ ...localData, implementation_status: e.target.value })}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
{Object.entries(IMPLEMENTATION_STATUS).map(([key, { label }]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
<textarea
value={localData.implementation_details}
onChange={(e) => setLocalData({ ...localData, implementation_details: e.target.value })}
placeholder="Beschreiben Sie, wie diese Anforderung in Breakpilot umgesetzt wurde..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={4}
/>
</div>
) : (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-sm text-green-800 whitespace-pre-wrap">
{requirement.implementation_details || 'Noch keine Umsetzungsdetails dokumentiert'}
</p>
</div>
)}
</section>
{/* Code References */}
<section>
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2">
Code-Referenzen
</h3>
<div className="space-y-2">
{(requirement.code_references || []).map((ref, idx) => (
<div key={idx} className="flex items-center gap-2 bg-slate-50 p-2 rounded-lg text-sm">
<code className="text-primary-600">{ref.file}{ref.line ? `:${ref.line}` : ''}</code>
<span className="text-slate-500">-</span>
<span className="text-slate-700">{ref.description}</span>
</div>
))}
{editMode && (
<div className="flex gap-2">
<input
type="text"
value={newCodeRef.file}
onChange={(e) => setNewCodeRef({ ...newCodeRef, file: e.target.value })}
placeholder="Datei (z.B. backend/auth.py)"
className="flex-1 px-2 py-1.5 text-sm border border-slate-300 rounded"
/>
<input
type="text"
value={newCodeRef.line}
onChange={(e) => setNewCodeRef({ ...newCodeRef, line: e.target.value })}
placeholder="Zeile"
className="w-20 px-2 py-1.5 text-sm border border-slate-300 rounded"
/>
<input
type="text"
value={newCodeRef.description}
onChange={(e) => setNewCodeRef({ ...newCodeRef, description: e.target.value })}
placeholder="Beschreibung"
className="flex-1 px-2 py-1.5 text-sm border border-slate-300 rounded"
/>
<button
onClick={addCodeReference}
className="px-3 py-1.5 bg-primary-600 text-white rounded text-sm"
>
+
</button>
</div>
)}
</div>
</section>
{/* Evidence */}
<section>
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2 flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
Nachweis / Evidence
</h3>
{editMode ? (
<textarea
value={localData.evidence_description}
onChange={(e) => setLocalData({ ...localData, evidence_description: e.target.value })}
placeholder="Welche Nachweise belegen die Erfuellung dieser Anforderung?"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={3}
/>
) : (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<p className="text-sm text-amber-800">
{requirement.evidence_description || 'Keine Nachweise beschrieben'}
</p>
</div>
)}
</section>
{/* Auditor Section */}
<section className="border-t border-slate-200 pt-4">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2 flex items-center gap-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
Auditor-Bereich
</h3>
{editMode ? (
<div className="space-y-3">
<div>
<label className="block text-xs text-slate-500 mb-1">Audit-Status</label>
<select
value={localData.audit_status}
onChange={(e) => setLocalData({ ...localData, audit_status: e.target.value })}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
{Object.entries(AUDIT_STATUS).map(([key, { label }]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
<textarea
value={localData.auditor_notes}
onChange={(e) => setLocalData({ ...localData, auditor_notes: e.target.value })}
placeholder="Notizen des Auditors..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={3}
/>
</div>
) : (
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
<p className="text-sm text-slate-700">
{requirement.auditor_notes || 'Keine Auditor-Notizen'}
</p>
</div>
)}
</section>
</div>
</div>
)
}

View File

@@ -0,0 +1,387 @@
'use client'
/**
* Control Catalogue Page
*
* Features:
* - List all controls with filters
* - Control detail view
* - Status update / Review
* - Evidence linking
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
import AdminLayout from '@/components/admin/AdminLayout'
interface Control {
id: string
control_id: string
domain: string
control_type: string
title: string
description: string
pass_criteria: string
implementation_guidance: string
code_reference: string
is_automated: boolean
automation_tool: string
owner: string
status: string
status_notes: string
last_reviewed_at: string | null
next_review_at: string | null
evidence_count: number
}
const DOMAIN_LABELS: Record<string, string> = {
gov: 'Governance',
priv: 'Datenschutz',
iam: 'Identity & Access',
crypto: 'Kryptografie',
sdlc: 'Secure Dev',
ops: 'Operations',
ai: 'KI-spezifisch',
cra: 'Supply Chain',
aud: 'Audit',
}
const DOMAIN_COLORS: Record<string, string> = {
gov: 'bg-slate-100 text-slate-700',
priv: 'bg-blue-100 text-blue-700',
iam: 'bg-purple-100 text-purple-700',
crypto: 'bg-yellow-100 text-yellow-700',
sdlc: 'bg-green-100 text-green-700',
ops: 'bg-orange-100 text-orange-700',
ai: 'bg-pink-100 text-pink-700',
cra: 'bg-cyan-100 text-cyan-700',
aud: 'bg-indigo-100 text-indigo-700',
}
const STATUS_STYLES: Record<string, { bg: string; text: string; icon: string }> = {
pass: { bg: 'bg-green-100', text: 'text-green-700', icon: 'M5 13l4 4L19 7' },
partial: { bg: 'bg-yellow-100', text: 'text-yellow-700', icon: 'M12 8v4m0 4h.01' },
fail: { bg: 'bg-red-100', text: 'text-red-700', icon: 'M6 18L18 6M6 6l12 12' },
planned: { bg: 'bg-slate-100', text: 'text-slate-700', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
'n/a': { bg: 'bg-slate-100', text: 'text-slate-500', icon: 'M20 12H4' },
}
export default function ControlsPage() {
const [controls, setControls] = useState<Control[]>([])
const [loading, setLoading] = useState(true)
const [selectedControl, setSelectedControl] = useState<Control | null>(null)
const [filterDomain, setFilterDomain] = useState<string>('')
const [filterStatus, setFilterStatus] = useState<string>('')
const [searchTerm, setSearchTerm] = useState('')
const [reviewModalOpen, setReviewModalOpen] = useState(false)
const [reviewStatus, setReviewStatus] = useState('pass')
const [reviewNotes, setReviewNotes] = useState('')
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
useEffect(() => {
loadControls()
}, [filterDomain, filterStatus])
const loadControls = async () => {
setLoading(true)
try {
const params = new URLSearchParams()
if (filterDomain) params.append('domain', filterDomain)
if (filterStatus) params.append('status', filterStatus)
if (searchTerm) params.append('search', searchTerm)
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/controls?${params}`)
if (res.ok) {
const data = await res.json()
setControls(data.controls || [])
}
} catch (error) {
console.error('Failed to load controls:', error)
} finally {
setLoading(false)
}
}
const handleSearch = () => {
loadControls()
}
const openReviewModal = (control: Control) => {
setSelectedControl(control)
setReviewStatus(control.status || 'planned')
setReviewNotes(control.status_notes || '')
setReviewModalOpen(true)
}
const submitReview = async () => {
if (!selectedControl) return
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/controls/${selectedControl.control_id}/review`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status: reviewStatus,
status_notes: reviewNotes,
}),
})
if (res.ok) {
setReviewModalOpen(false)
loadControls()
} else {
alert('Fehler beim Speichern')
}
} catch (error) {
console.error('Review failed:', error)
alert('Fehler beim Speichern')
}
}
const filteredControls = controls.filter((c) => {
if (searchTerm) {
const term = searchTerm.toLowerCase()
return (
c.control_id.toLowerCase().includes(term) ||
c.title.toLowerCase().includes(term) ||
(c.description && c.description.toLowerCase().includes(term))
)
}
return true
})
const getDaysUntilReview = (nextReview: string | null) => {
if (!nextReview) return null
const days = Math.ceil((new Date(nextReview).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
return days
}
return (
<AdminLayout title="Control Catalogue" description="Technische & organisatorische Controls">
{/* Header Actions */}
<div className="flex flex-wrap items-center gap-4 mb-6">
<Link
href="/admin/compliance"
className="text-sm text-slate-500 hover:text-slate-700 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="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Zurueck zum Dashboard
</Link>
<div className="flex-1" />
<span className="text-sm text-slate-500">{filteredControls.length} Controls</span>
</div>
{/* Filters */}
<div className="bg-white rounded-xl shadow-sm border p-4 mb-6">
<div className="flex flex-wrap items-center gap-4">
<div className="flex-1 min-w-[200px]">
<input
type="text"
placeholder="Control suchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<select
value={filterDomain}
onChange={(e) => setFilterDomain(e.target.value)}
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Alle Domains</option>
{Object.entries(DOMAIN_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Alle Status</option>
<option value="pass">Bestanden</option>
<option value="partial">Teilweise</option>
<option value="fail">Nicht bestanden</option>
<option value="planned">Geplant</option>
<option value="n/a">Nicht anwendbar</option>
</select>
<button
onClick={handleSearch}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Filtern
</button>
</div>
</div>
{/* Controls Table */}
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
) : (
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">ID</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Domain</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Titel</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Automatisiert</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Nachweise</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Review</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{filteredControls.map((control) => {
const statusStyle = STATUS_STYLES[control.status] || STATUS_STYLES.planned
const daysUntilReview = getDaysUntilReview(control.next_review_at)
return (
<tr key={control.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-mono font-medium text-primary-600">{control.control_id}</span>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 text-xs rounded-full ${DOMAIN_COLORS[control.domain] || 'bg-slate-100 text-slate-700'}`}>
{DOMAIN_LABELS[control.domain] || control.domain}
</span>
</td>
<td className="px-4 py-3">
<div>
<p className="font-medium text-slate-900">{control.title}</p>
{control.description && (
<p className="text-sm text-slate-500 truncate max-w-md">{control.description}</p>
)}
</div>
</td>
<td className="px-4 py-3 text-center">
<span className={`inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full ${statusStyle.bg} ${statusStyle.text}`}>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={statusStyle.icon} />
</svg>
{control.status}
</span>
</td>
<td className="px-4 py-3 text-center">
{control.is_automated ? (
<span className="inline-flex items-center gap-1 text-green-600">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-xs">{control.automation_tool}</span>
</span>
) : (
<span className="text-slate-400 text-xs">Manuell</span>
)}
</td>
<td className="px-4 py-3 text-center">
<Link
href={`/admin/compliance/evidence?control=${control.control_id}`}
className="text-primary-600 hover:text-primary-700 font-medium"
>
{control.evidence_count || 0}
</Link>
</td>
<td className="px-4 py-3 text-center">
{daysUntilReview !== null ? (
<span className={`text-sm ${daysUntilReview < 0 ? 'text-red-600 font-medium' : daysUntilReview < 14 ? 'text-yellow-600' : 'text-slate-500'}`}>
{daysUntilReview < 0 ? `${Math.abs(daysUntilReview)}d ueberfaellig` : `${daysUntilReview}d`}
</span>
) : (
<span className="text-slate-400 text-sm">-</span>
)}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => openReviewModal(control)}
className="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Review
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
{/* Review Modal */}
{reviewModalOpen && selectedControl && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">
Control Review: {selectedControl.control_id}
</h3>
<div className="mb-4">
<p className="text-sm text-slate-500 mb-2">{selectedControl.title}</p>
<div className="p-3 bg-slate-50 rounded-lg text-sm">
<p className="font-medium text-slate-700 mb-1">Pass-Kriterium:</p>
<p className="text-slate-600">{selectedControl.pass_criteria}</p>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-slate-700 mb-2">Status</label>
<div className="grid grid-cols-5 gap-2">
{Object.entries(STATUS_STYLES).map(([key, style]) => (
<button
key={key}
onClick={() => setReviewStatus(key)}
className={`p-2 rounded-lg border-2 text-sm font-medium transition-colors ${
reviewStatus === key
? `${style.bg} ${style.text} border-current`
: 'border-slate-200 hover:border-slate-300'
}`}
>
{key}
</button>
))}
</div>
</div>
<div className="mb-6">
<label className="block text-sm font-medium text-slate-700 mb-2">Notizen</label>
<textarea
value={reviewNotes}
onChange={(e) => setReviewNotes(e.target.value)}
placeholder="Begruendung, Nachweise, naechste Schritte..."
rows={3}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setReviewModalOpen(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
>
Abbrechen
</button>
<button
onClick={submitReview}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Speichern
</button>
</div>
</div>
</div>
)}
</AdminLayout>
)
}

View File

@@ -0,0 +1,522 @@
'use client'
/**
* Evidence Management Page
*
* Features:
* - List evidence by control
* - File upload
* - URL/Link adding
* - Evidence status tracking
*/
import { useState, useEffect, useRef, Suspense } from 'react'
import { useSearchParams } from 'next/navigation'
import Link from 'next/link'
import AdminLayout from '@/components/admin/AdminLayout'
interface Evidence {
id: string
control_id: string
evidence_type: string
title: string
description: string
artifact_path: string | null
artifact_url: string | null
artifact_hash: string | null
file_size_bytes: number | null
mime_type: string | null
status: string
source: string
ci_job_id: string | null
valid_from: string
valid_until: string | null
collected_at: string
}
interface Control {
id: string
control_id: string
title: string
}
const EVIDENCE_TYPES = [
{ value: 'scan_report', label: 'Scan Report', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
{ value: 'policy_document', label: 'Policy Dokument', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
{ value: 'config_snapshot', label: 'Config Snapshot', icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4' },
{ value: 'test_result', label: 'Test Ergebnis', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
{ value: 'screenshot', label: 'Screenshot', icon: 'M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z' },
{ value: 'external_link', label: 'Externer Link', icon: 'M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14' },
{ value: 'manual_upload', label: 'Manueller Upload', icon: 'M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12' },
]
const STATUS_STYLES: Record<string, string> = {
valid: 'bg-green-100 text-green-700',
expired: 'bg-red-100 text-red-700',
pending: 'bg-yellow-100 text-yellow-700',
failed: 'bg-red-100 text-red-700',
}
function EvidencePageContent({ initialControlId }: { initialControlId: string | null }) {
const [evidence, setEvidence] = useState<Evidence[]>([])
const [controls, setControls] = useState<Control[]>([])
const [loading, setLoading] = useState(true)
const [filterControlId, setFilterControlId] = useState(initialControlId || '')
const [filterType, setFilterType] = useState('')
const [uploadModalOpen, setUploadModalOpen] = useState(false)
const [linkModalOpen, setLinkModalOpen] = useState(false)
const [uploading, setUploading] = useState(false)
const [newEvidence, setNewEvidence] = useState({
control_id: initialControlId || '',
evidence_type: 'manual_upload',
title: '',
description: '',
artifact_url: '',
})
const fileInputRef = useRef<HTMLInputElement>(null)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
useEffect(() => {
loadData()
}, [filterControlId, filterType])
const loadData = async () => {
setLoading(true)
try {
const params = new URLSearchParams()
if (filterControlId) params.append('control_id', filterControlId)
if (filterType) params.append('evidence_type', filterType)
const [evidenceRes, controlsRes] = await Promise.all([
fetch(`${BACKEND_URL}/api/v1/compliance/evidence?${params}`),
fetch(`${BACKEND_URL}/api/v1/compliance/controls`),
])
if (evidenceRes.ok) {
const data = await evidenceRes.json()
setEvidence(data.evidence || [])
}
if (controlsRes.ok) {
const data = await controlsRes.json()
setControls(data.controls || [])
}
} catch (error) {
console.error('Failed to load data:', error)
} finally {
setLoading(false)
}
}
const handleFileUpload = async () => {
if (!selectedFile || !newEvidence.control_id || !newEvidence.title) {
alert('Bitte alle Pflichtfelder ausfuellen')
return
}
setUploading(true)
try {
const formData = new FormData()
formData.append('file', selectedFile)
const params = new URLSearchParams({
control_id: newEvidence.control_id,
evidence_type: newEvidence.evidence_type,
title: newEvidence.title,
})
if (newEvidence.description) {
params.append('description', newEvidence.description)
}
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/evidence/upload?${params}`, {
method: 'POST',
body: formData,
})
if (res.ok) {
setUploadModalOpen(false)
resetForm()
loadData()
} else {
const error = await res.text()
alert(`Upload fehlgeschlagen: ${error}`)
}
} catch (error) {
console.error('Upload failed:', error)
alert('Upload fehlgeschlagen')
} finally {
setUploading(false)
}
}
const handleLinkSubmit = async () => {
if (!newEvidence.control_id || !newEvidence.title || !newEvidence.artifact_url) {
alert('Bitte alle Pflichtfelder ausfuellen')
return
}
setUploading(true)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/evidence`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
control_id: newEvidence.control_id,
evidence_type: 'external_link',
title: newEvidence.title,
description: newEvidence.description,
artifact_url: newEvidence.artifact_url,
source: 'manual',
}),
})
if (res.ok) {
setLinkModalOpen(false)
resetForm()
loadData()
} else {
const error = await res.text()
alert(`Fehler: ${error}`)
}
} catch (error) {
console.error('Failed to add link:', error)
alert('Fehler beim Hinzufuegen')
} finally {
setUploading(false)
}
}
const resetForm = () => {
setNewEvidence({
control_id: filterControlId || '',
evidence_type: 'manual_upload',
title: '',
description: '',
artifact_url: '',
})
setSelectedFile(null)
}
const formatFileSize = (bytes: number | null) => {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
const getControlTitle = (controlUuid: string) => {
const control = controls.find((c) => c.id === controlUuid)
return control?.control_id || controlUuid
}
return (
<AdminLayout title="Evidence Management" description="Nachweise & Artefakte">
{/* Header */}
<div className="flex flex-wrap items-center gap-4 mb-6">
<Link
href="/admin/compliance"
className="text-sm text-slate-500 hover:text-slate-700 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="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Zurueck
</Link>
<div className="flex-1" />
<button
onClick={() => { resetForm(); setLinkModalOpen(true) }}
className="px-4 py-2 border border-primary-600 text-primary-600 rounded-lg hover:bg-primary-50"
>
Link hinzufuegen
</button>
<button
onClick={() => { resetForm(); setUploadModalOpen(true) }}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Datei hochladen
</button>
</div>
{/* Filters */}
<div className="bg-white rounded-xl shadow-sm border p-4 mb-6">
<div className="flex flex-wrap items-center gap-4">
<select
value={filterControlId}
onChange={(e) => setFilterControlId(e.target.value)}
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Alle Controls</option>
{controls.map((c) => (
<option key={c.id} value={c.control_id}>{c.control_id} - {c.title}</option>
))}
</select>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Alle Typen</option>
{EVIDENCE_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
<span className="text-sm text-slate-500">{evidence.length} Nachweise</span>
</div>
</div>
{/* Evidence List */}
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
) : evidence.length === 0 ? (
<div className="bg-white rounded-xl shadow-sm border p-12 text-center">
<svg className="w-16 h-16 text-slate-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-slate-500 mb-4">Keine Nachweise gefunden</p>
<button
onClick={() => setUploadModalOpen(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Ersten Nachweis hinzufuegen
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{evidence.map((ev) => (
<div key={ev.id} className="bg-white rounded-xl shadow-sm border p-4 hover:border-primary-300 transition-colors">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={EVIDENCE_TYPES.find((t) => t.value === ev.evidence_type)?.icon || 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'} />
</svg>
</div>
<span className={`px-2 py-0.5 text-xs rounded-full ${STATUS_STYLES[ev.status] || 'bg-slate-100 text-slate-700'}`}>
{ev.status}
</span>
</div>
<span className="text-xs text-slate-500 font-mono">{getControlTitle(ev.control_id)}</span>
</div>
<h4 className="font-medium text-slate-900 mb-1">{ev.title}</h4>
{ev.description && (
<p className="text-sm text-slate-500 mb-3 line-clamp-2">{ev.description}</p>
)}
<div className="flex items-center justify-between text-xs text-slate-500 pt-3 border-t">
<span>{ev.evidence_type.replace('_', ' ')}</span>
<span>{formatFileSize(ev.file_size_bytes)}</span>
</div>
{ev.artifact_url && (
<a
href={ev.artifact_url}
target="_blank"
rel="noopener noreferrer"
className="mt-3 block text-sm text-primary-600 hover:text-primary-700 truncate"
>
{ev.artifact_url}
</a>
)}
<div className="mt-3 text-xs text-slate-400">
Erfasst: {new Date(ev.collected_at).toLocaleDateString('de-DE')}
</div>
</div>
))}
</div>
)}
{/* Upload Modal */}
{uploadModalOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Datei hochladen</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Control *</label>
<select
value={newEvidence.control_id}
onChange={(e) => setNewEvidence({ ...newEvidence, control_id: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Control auswaehlen...</option>
{controls.map((c) => (
<option key={c.id} value={c.control_id}>{c.control_id} - {c.title}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
<select
value={newEvidence.evidence_type}
onChange={(e) => setNewEvidence({ ...newEvidence, evidence_type: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
{EVIDENCE_TYPES.filter((t) => t.value !== 'external_link').map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
<input
type="text"
value={newEvidence.title}
onChange={(e) => setNewEvidence({ ...newEvidence, title: e.target.value })}
placeholder="z.B. Semgrep Scan Report 2026-01"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={newEvidence.description}
onChange={(e) => setNewEvidence({ ...newEvidence, description: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Datei *</label>
<input
type="file"
ref={fileInputRef}
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
{selectedFile && (
<p className="mt-1 text-sm text-slate-500">
{selectedFile.name} ({formatFileSize(selectedFile.size)})
</p>
)}
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setUploadModalOpen(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
>
Abbrechen
</button>
<button
onClick={handleFileUpload}
disabled={uploading}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{uploading ? 'Hochladen...' : 'Hochladen'}
</button>
</div>
</div>
</div>
)}
{/* Link Modal */}
{linkModalOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Link/Quelle hinzufuegen</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Control *</label>
<select
value={newEvidence.control_id}
onChange={(e) => setNewEvidence({ ...newEvidence, control_id: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">Control auswaehlen...</option>
{controls.map((c) => (
<option key={c.id} value={c.control_id}>{c.control_id} - {c.title}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
<input
type="text"
value={newEvidence.title}
onChange={(e) => setNewEvidence({ ...newEvidence, title: e.target.value })}
placeholder="z.B. GitHub Branch Protection Settings"
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">URL *</label>
<input
type="url"
value={newEvidence.artifact_url}
onChange={(e) => setNewEvidence({ ...newEvidence, artifact_url: e.target.value })}
placeholder="https://github.com/..."
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={newEvidence.description}
onChange={(e) => setNewEvidence({ ...newEvidence, description: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setLinkModalOpen(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
>
Abbrechen
</button>
<button
onClick={handleLinkSubmit}
disabled={uploading}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{uploading ? 'Speichern...' : 'Hinzufuegen'}
</button>
</div>
</div>
</div>
)}
</AdminLayout>
)
}
function EvidencePageWithParams() {
const searchParams = useSearchParams()
const initialControlId = searchParams.get('control')
return <EvidencePageContent initialControlId={initialControlId} />
}
export default function EvidencePage() {
return (
<Suspense fallback={
<AdminLayout title="Evidence Management" description="Nachweise & Artefakte">
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
</AdminLayout>
}>
<EvidencePageWithParams />
</Suspense>
)
}

View File

@@ -0,0 +1,502 @@
'use client'
/**
* Audit Export Wizard
*
* Features:
* - Export type selection
* - Scope filtering (regulations, domains)
* - Export generation
* - Download
* - Export history
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
import AdminLayout from '@/components/admin/AdminLayout'
interface Export {
id: string
export_type: string
export_name: string
status: string
requested_by: string
requested_at: string
completed_at: string | null
file_path: string | null
file_hash: string | null
file_size_bytes: number | null
total_controls: number | null
total_evidence: number | null
compliance_score: number | null
error_message: string | null
}
interface Regulation {
code: string
name: string
}
const EXPORT_TYPES = [
{ value: 'full', label: 'Vollstaendiger Export', description: 'Alle Daten inkl. Regulations, Controls, Evidence, Risks' },
{ value: 'controls_only', label: 'Nur Controls', description: 'Control Catalogue mit Mappings' },
{ value: 'evidence_only', label: 'Nur Nachweise', description: 'Evidence-Dateien und Metadaten' },
]
const DOMAIN_OPTIONS = [
{ value: 'gov', label: 'Governance' },
{ value: 'priv', label: 'Datenschutz' },
{ value: 'iam', label: 'Identity & Access' },
{ value: 'crypto', label: 'Kryptografie' },
{ value: 'sdlc', label: 'Secure Dev' },
{ value: 'ops', label: 'Operations' },
{ value: 'ai', label: 'KI-spezifisch' },
{ value: 'cra', label: 'Supply Chain' },
{ value: 'aud', label: 'Audit' },
]
export default function ExportPage() {
const [exports, setExports] = useState<Export[]>([])
const [regulations, setRegulations] = useState<Regulation[]>([])
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [wizardStep, setWizardStep] = useState(1)
const [exportType, setExportType] = useState('full')
const [selectedRegulations, setSelectedRegulations] = useState<string[]>([])
const [selectedDomains, setSelectedDomains] = useState<string[]>([])
const [currentExport, setCurrentExport] = useState<Export | null>(null)
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
try {
const [exportsRes, regulationsRes] = await Promise.all([
fetch(`${BACKEND_URL}/api/v1/compliance/exports`),
fetch(`${BACKEND_URL}/api/v1/compliance/regulations`),
])
if (exportsRes.ok) {
const data = await exportsRes.json()
setExports(data.exports || [])
}
if (regulationsRes.ok) {
const data = await regulationsRes.json()
setRegulations(data.regulations || [])
}
} catch (error) {
console.error('Failed to load data:', error)
} finally {
setLoading(false)
}
}
const startExport = async () => {
setGenerating(true)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
export_type: exportType,
included_regulations: selectedRegulations.length > 0 ? selectedRegulations : null,
included_domains: selectedDomains.length > 0 ? selectedDomains : null,
}),
})
if (res.ok) {
const exportData = await res.json()
setCurrentExport(exportData)
setWizardStep(4)
loadData()
} else {
const error = await res.text()
alert(`Export fehlgeschlagen: ${error}`)
}
} catch (error) {
console.error('Export failed:', error)
alert('Export fehlgeschlagen')
} finally {
setGenerating(false)
}
}
const downloadExport = (exportId: string) => {
window.open(`${BACKEND_URL}/api/v1/compliance/export/${exportId}/download`, '_blank')
}
const resetWizard = () => {
setWizardStep(1)
setExportType('full')
setSelectedRegulations([])
setSelectedDomains([])
setCurrentExport(null)
}
const toggleRegulation = (code: string) => {
setSelectedRegulations((prev) =>
prev.includes(code) ? prev.filter((r) => r !== code) : [...prev, code]
)
}
const toggleDomain = (domain: string) => {
setSelectedDomains((prev) =>
prev.includes(domain) ? prev.filter((d) => d !== domain) : [...prev, domain]
)
}
const formatFileSize = (bytes: number | null) => {
if (!bytes) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
const renderWizardSteps = () => (
<div className="flex items-center justify-center mb-8">
{[
{ num: 1, label: 'Typ' },
{ num: 2, label: 'Scope' },
{ num: 3, label: 'Bestaetigen' },
{ num: 4, label: 'Download' },
].map((step, idx) => (
<div key={step.num} className="flex items-center">
<div className={`flex items-center justify-center w-10 h-10 rounded-full font-medium ${
wizardStep >= step.num
? 'bg-primary-600 text-white'
: 'bg-slate-200 text-slate-500'
}`}>
{wizardStep > step.num ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
step.num
)}
</div>
<span className={`ml-2 text-sm ${wizardStep >= step.num ? 'text-slate-900' : 'text-slate-500'}`}>
{step.label}
</span>
{idx < 3 && (
<div className={`w-16 h-0.5 mx-4 ${wizardStep > step.num ? 'bg-primary-600' : 'bg-slate-200'}`} />
)}
</div>
))}
</div>
)
const renderStep1 = () => (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Export-Typ waehlen</h3>
<div className="grid gap-4">
{EXPORT_TYPES.map((type) => (
<button
key={type.value}
onClick={() => setExportType(type.value)}
className={`p-4 rounded-lg border-2 text-left transition-colors ${
exportType === type.value
? 'border-primary-600 bg-primary-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
exportType === type.value ? 'border-primary-600' : 'border-slate-300'
}`}>
{exportType === type.value && (
<div className="w-3 h-3 rounded-full bg-primary-600" />
)}
</div>
<div>
<p className="font-medium text-slate-900">{type.label}</p>
<p className="text-sm text-slate-500">{type.description}</p>
</div>
</div>
</button>
))}
</div>
<div className="flex justify-end pt-4">
<button
onClick={() => setWizardStep(2)}
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Weiter
</button>
</div>
</div>
)
const renderStep2 = () => (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-slate-900">Scope definieren (optional)</h3>
{/* Regulations Filter */}
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">Verordnungen filtern</h4>
<p className="text-sm text-slate-500 mb-3">Leer lassen fuer alle Verordnungen</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{regulations.map((reg) => (
<button
key={reg.code}
onClick={() => toggleRegulation(reg.code)}
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
selectedRegulations.includes(reg.code)
? 'border-primary-600 bg-primary-50 text-primary-700'
: 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
{reg.code}
</button>
))}
</div>
</div>
{/* Domains Filter */}
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">Domains filtern</h4>
<p className="text-sm text-slate-500 mb-3">Leer lassen fuer alle Domains</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{DOMAIN_OPTIONS.map((domain) => (
<button
key={domain.value}
onClick={() => toggleDomain(domain.value)}
className={`px-3 py-2 text-sm rounded-lg border transition-colors ${
selectedDomains.includes(domain.value)
? 'border-primary-600 bg-primary-50 text-primary-700'
: 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
{domain.label}
</button>
))}
</div>
</div>
<div className="flex justify-between pt-4">
<button
onClick={() => setWizardStep(1)}
className="px-6 py-2 text-slate-600 hover:text-slate-800"
>
Zurueck
</button>
<button
onClick={() => setWizardStep(3)}
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Weiter
</button>
</div>
</div>
)
const renderStep3 = () => (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-slate-900">Export bestaetigen</h3>
<div className="bg-slate-50 rounded-lg p-6 space-y-4">
<div className="flex justify-between">
<span className="text-slate-600">Export-Typ:</span>
<span className="font-medium text-slate-900">
{EXPORT_TYPES.find((t) => t.value === exportType)?.label}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Verordnungen:</span>
<span className="font-medium text-slate-900">
{selectedRegulations.length > 0 ? selectedRegulations.join(', ') : 'Alle'}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Domains:</span>
<span className="font-medium text-slate-900">
{selectedDomains.length > 0
? selectedDomains.map((d) => DOMAIN_OPTIONS.find((o) => o.value === d)?.label).join(', ')
: 'Alle'}
</span>
</div>
</div>
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
Der Export kann je nach Datenmenge einige Sekunden dauern.
Nach Abschluss koennen Sie die ZIP-Datei herunterladen.
</p>
</div>
<div className="flex justify-between pt-4">
<button
onClick={() => setWizardStep(2)}
className="px-6 py-2 text-slate-600 hover:text-slate-800"
>
Zurueck
</button>
<button
onClick={startExport}
disabled={generating}
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 flex items-center gap-2"
>
{generating && (
<svg className="animate-spin h-4 w-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 12h4z" />
</svg>
)}
{generating ? 'Generiere...' : 'Export starten'}
</button>
</div>
</div>
)
const renderStep4 = () => (
<div className="space-y-6 text-center">
{currentExport?.status === 'completed' ? (
<>
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-900">Export erfolgreich!</h3>
<div className="bg-slate-50 rounded-lg p-6 text-left space-y-3">
<div className="flex justify-between">
<span className="text-slate-600">Compliance Score:</span>
<span className="font-medium text-slate-900">{currentExport.compliance_score?.toFixed(1)}%</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Controls:</span>
<span className="font-medium text-slate-900">{currentExport.total_controls}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Nachweise:</span>
<span className="font-medium text-slate-900">{currentExport.total_evidence}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Dateigroesse:</span>
<span className="font-medium text-slate-900">{formatFileSize(currentExport.file_size_bytes)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">SHA-256:</span>
<span className="font-mono text-xs text-slate-500 truncate max-w-xs">{currentExport.file_hash}</span>
</div>
</div>
<div className="flex justify-center gap-4 pt-4">
<button
onClick={resetWizard}
className="px-6 py-2 text-slate-600 hover:text-slate-800"
>
Neuer Export
</button>
<button
onClick={() => downloadExport(currentExport.id)}
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
ZIP herunterladen
</button>
</div>
</>
) : (
<>
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto">
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-900">Export fehlgeschlagen</h3>
<p className="text-slate-500">{currentExport?.error_message || 'Unbekannter Fehler'}</p>
<button
onClick={resetWizard}
className="px-6 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Erneut versuchen
</button>
</>
)}
</div>
)
return (
<AdminLayout title="Audit Export" description="Export fuer externe Pruefer">
{/* Header */}
<div className="flex flex-wrap items-center gap-4 mb-6">
<Link
href="/admin/compliance"
className="text-sm text-slate-500 hover:text-slate-700 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="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Zurueck
</Link>
</div>
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Wizard */}
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border p-6">
{renderWizardSteps()}
{wizardStep === 1 && renderStep1()}
{wizardStep === 2 && renderStep2()}
{wizardStep === 3 && renderStep3()}
{wizardStep === 4 && renderStep4()}
</div>
{/* Export History */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Letzte Exports</h3>
{exports.length === 0 ? (
<p className="text-slate-500 text-sm">Noch keine Exports vorhanden</p>
) : (
<div className="space-y-3">
{exports.slice(0, 10).map((exp) => (
<div key={exp.id} className="p-3 bg-slate-50 rounded-lg">
<div className="flex items-center justify-between mb-1">
<span className={`px-2 py-0.5 text-xs rounded-full ${
exp.status === 'completed' ? 'bg-green-100 text-green-700' :
exp.status === 'failed' ? 'bg-red-100 text-red-700' :
'bg-yellow-100 text-yellow-700'
}`}>
{exp.status}
</span>
<span className="text-xs text-slate-500">
{new Date(exp.requested_at).toLocaleDateString('de-DE')}
</span>
</div>
<p className="text-sm font-medium text-slate-900">{exp.export_name}</p>
<p className="text-xs text-slate-500">{exp.export_type} - {formatFileSize(exp.file_size_bytes)}</p>
{exp.status === 'completed' && (
<button
onClick={() => downloadExport(exp.id)}
className="mt-2 text-xs text-primary-600 hover:text-primary-700 font-medium"
>
Download
</button>
)}
</div>
))}
</div>
)}
</div>
</div>
)}
</AdminLayout>
)
}

View File

@@ -0,0 +1,745 @@
'use client';
import { useState, useEffect } from 'react';
// Types
interface ServiceModule {
id: string;
name: string;
display_name: string;
description: string | null;
service_type: string;
port: number | null;
technology_stack: string[];
repository_path: string | null;
docker_image: string | null;
data_categories: string[];
processes_pii: boolean;
processes_health_data: boolean;
ai_components: boolean;
criticality: string;
owner_team: string | null;
is_active: boolean;
compliance_score: number | null;
regulation_count: number;
risk_count: number;
created_at: string;
regulations?: Array<{
code: string;
name: string;
relevance_level: string;
notes: string | null;
}>;
}
interface ModulesOverview {
total_modules: number;
modules_by_type: Record<string, number>;
modules_by_criticality: Record<string, number>;
modules_processing_pii: number;
modules_with_ai: number;
average_compliance_score: number | null;
regulations_coverage: Record<string, number>;
}
// Service Type Icons and Colors
const SERVICE_TYPE_CONFIG: Record<string, { icon: string; color: string; bgColor: string }> = {
backend: { icon: '⚙️', color: 'text-blue-700', bgColor: 'bg-blue-100' },
database: { icon: '🗄️', color: 'text-purple-700', bgColor: 'bg-purple-100' },
ai: { icon: '🤖', color: 'text-pink-700', bgColor: 'bg-pink-100' },
communication: { icon: '💬', color: 'text-green-700', bgColor: 'bg-green-100' },
storage: { icon: '📦', color: 'text-orange-700', bgColor: 'bg-orange-100' },
infrastructure: { icon: '🌐', color: 'text-gray-700', bgColor: 'bg-gray-100' },
monitoring: { icon: '📊', color: 'text-cyan-700', bgColor: 'bg-cyan-100' },
security: { icon: '🔒', color: 'text-red-700', bgColor: 'bg-red-100' },
};
const CRITICALITY_CONFIG: Record<string, { color: string; bgColor: string }> = {
critical: { color: 'text-red-700', bgColor: 'bg-red-100' },
high: { color: 'text-orange-700', bgColor: 'bg-orange-100' },
medium: { color: 'text-yellow-700', bgColor: 'bg-yellow-100' },
low: { color: 'text-green-700', bgColor: 'bg-green-100' },
};
const RELEVANCE_CONFIG: Record<string, { color: string; bgColor: string }> = {
critical: { color: 'text-red-700', bgColor: 'bg-red-100' },
high: { color: 'text-orange-700', bgColor: 'bg-orange-100' },
medium: { color: 'text-yellow-700', bgColor: 'bg-yellow-100' },
low: { color: 'text-green-700', bgColor: 'bg-green-100' },
};
export default function ModulesPage() {
const [modules, setModules] = useState<ServiceModule[]>([]);
const [overview, setOverview] = useState<ModulesOverview | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filters
const [typeFilter, setTypeFilter] = useState<string>('all');
const [criticalityFilter, setCriticalityFilter] = useState<string>('all');
const [piiFilter, setPiiFilter] = useState<boolean | null>(null);
const [aiFilter, setAiFilter] = useState<boolean | null>(null);
const [searchTerm, setSearchTerm] = useState('');
// Selected module for detail view
const [selectedModule, setSelectedModule] = useState<ServiceModule | null>(null);
const [loadingDetail, setLoadingDetail] = useState(false);
// AI Risk Assessment
const [riskAssessment, setRiskAssessment] = useState<{
overall_risk: string;
risk_factors: Array<{ factor: string; severity: string; likelihood: string }>;
recommendations: string[];
compliance_gaps: string[];
confidence_score: number;
} | null>(null);
const [loadingRisk, setLoadingRisk] = useState(false);
const [showRiskPanel, setShowRiskPanel] = useState(false);
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000';
const API_BASE = `${BACKEND_URL}/api/v1/compliance`;
useEffect(() => {
fetchModules();
fetchOverview();
}, []);
const fetchModules = async () => {
try {
setLoading(true);
const params = new URLSearchParams();
if (typeFilter !== 'all') params.append('service_type', typeFilter);
if (criticalityFilter !== 'all') params.append('criticality', criticalityFilter);
if (piiFilter !== null) params.append('processes_pii', String(piiFilter));
if (aiFilter !== null) params.append('ai_components', String(aiFilter));
const url = `${API_BASE}/modules${params.toString() ? '?' + params.toString() : ''}`;
const res = await fetch(url);
if (!res.ok) throw new Error('Failed to fetch modules');
const data = await res.json();
setModules(data.modules || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
const fetchOverview = async () => {
try {
const res = await fetch(`${API_BASE}/modules/overview`);
if (!res.ok) throw new Error('Failed to fetch overview');
const data = await res.json();
setOverview(data);
} catch (err) {
console.error('Failed to fetch overview:', err);
}
};
const fetchModuleDetail = async (moduleId: string) => {
try {
setLoadingDetail(true);
const res = await fetch(`${API_BASE}/modules/${moduleId}`);
if (!res.ok) throw new Error('Failed to fetch module details');
const data = await res.json();
setSelectedModule(data);
} catch (err) {
console.error('Failed to fetch module details:', err);
} finally {
setLoadingDetail(false);
}
};
const seedModules = async (force: boolean = false) => {
try {
const res = await fetch(`${API_BASE}/modules/seed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force }),
});
if (!res.ok) throw new Error('Failed to seed modules');
const data = await res.json();
alert(`Seeded ${data.modules_created} modules with ${data.mappings_created} regulation mappings`);
fetchModules();
fetchOverview();
} catch (err) {
alert('Failed to seed modules: ' + (err instanceof Error ? err.message : 'Unknown error'));
}
};
const assessModuleRisk = async (moduleId: string) => {
setLoadingRisk(true);
setShowRiskPanel(true);
setRiskAssessment(null);
try {
const res = await fetch(`${API_BASE}/ai/assess-risk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ module_id: moduleId }),
});
if (res.ok) {
const data = await res.json();
setRiskAssessment(data);
} else {
alert('AI-Risikobewertung fehlgeschlagen');
}
} catch (err) {
alert('Netzwerkfehler bei AI-Risikobewertung');
} finally {
setLoadingRisk(false);
}
};
// Filter modules by search term
const filteredModules = modules.filter(m => {
if (!searchTerm) return true;
const term = searchTerm.toLowerCase();
return (
m.name.toLowerCase().includes(term) ||
m.display_name.toLowerCase().includes(term) ||
(m.description && m.description.toLowerCase().includes(term)) ||
m.technology_stack.some(t => t.toLowerCase().includes(term))
);
});
// Group by type for visualization
const modulesByType = filteredModules.reduce((acc, m) => {
const type = m.service_type || 'unknown';
if (!acc[type]) acc[type] = [];
acc[type].push(m);
return acc;
}, {} as Record<string, ServiceModule[]>);
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Service Module Registry</h1>
<p className="text-gray-600 mt-1">
Alle {overview?.total_modules || 0} Breakpilot-Services mit Regulation-Mappings
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => seedModules(false)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
>
Seed Modules
</button>
<button
onClick={() => seedModules(true)}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
>
Force Re-Seed
</button>
</div>
</div>
{/* Overview Stats */}
{overview && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div className="bg-white rounded-lg p-4 shadow border">
<div className="text-3xl font-bold text-blue-600">{overview.total_modules}</div>
<div className="text-sm text-gray-600">Services</div>
</div>
<div className="bg-white rounded-lg p-4 shadow border">
<div className="text-3xl font-bold text-red-600">{overview.modules_by_criticality?.critical || 0}</div>
<div className="text-sm text-gray-600">Critical</div>
</div>
<div className="bg-white rounded-lg p-4 shadow border">
<div className="text-3xl font-bold text-purple-600">{overview.modules_processing_pii}</div>
<div className="text-sm text-gray-600">PII-Processing</div>
</div>
<div className="bg-white rounded-lg p-4 shadow border">
<div className="text-3xl font-bold text-pink-600">{overview.modules_with_ai}</div>
<div className="text-sm text-gray-600">AI-Komponenten</div>
</div>
<div className="bg-white rounded-lg p-4 shadow border">
<div className="text-3xl font-bold text-green-600">
{Object.keys(overview.regulations_coverage || {}).length}
</div>
<div className="text-sm text-gray-600">Regulations</div>
</div>
<div className="bg-white rounded-lg p-4 shadow border">
<div className="text-3xl font-bold text-cyan-600">
{overview.average_compliance_score !== null
? `${overview.average_compliance_score}%`
: 'N/A'}
</div>
<div className="text-sm text-gray-600">Avg. Score</div>
</div>
</div>
)}
{/* Filters */}
<div className="bg-white rounded-lg p-4 shadow border">
<div className="flex flex-wrap gap-4 items-center">
<div>
<label className="block text-xs text-gray-500 mb-1">Service Type</label>
<select
value={typeFilter}
onChange={(e) => { setTypeFilter(e.target.value); }}
className="border rounded px-3 py-2 text-sm"
>
<option value="all">Alle Typen</option>
<option value="backend">Backend</option>
<option value="database">Database</option>
<option value="ai">AI/ML</option>
<option value="communication">Communication</option>
<option value="storage">Storage</option>
<option value="infrastructure">Infrastructure</option>
<option value="monitoring">Monitoring</option>
<option value="security">Security</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Criticality</label>
<select
value={criticalityFilter}
onChange={(e) => { setCriticalityFilter(e.target.value); }}
className="border rounded px-3 py-2 text-sm"
>
<option value="all">Alle</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">PII</label>
<select
value={piiFilter === null ? 'all' : String(piiFilter)}
onChange={(e) => {
const val = e.target.value;
setPiiFilter(val === 'all' ? null : val === 'true');
}}
className="border rounded px-3 py-2 text-sm"
>
<option value="all">Alle</option>
<option value="true">Verarbeitet PII</option>
<option value="false">Keine PII</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">AI</label>
<select
value={aiFilter === null ? 'all' : String(aiFilter)}
onChange={(e) => {
const val = e.target.value;
setAiFilter(val === 'all' ? null : val === 'true');
}}
className="border rounded px-3 py-2 text-sm"
>
<option value="all">Alle</option>
<option value="true">Mit AI</option>
<option value="false">Ohne AI</option>
</select>
</div>
<div className="flex-1">
<label className="block text-xs text-gray-500 mb-1">Suche</label>
<input
type="text"
placeholder="Service, Beschreibung, Technologie..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="border rounded px-3 py-2 text-sm w-full"
/>
</div>
<div className="pt-5">
<button
onClick={fetchModules}
className="px-4 py-2 bg-gray-100 rounded hover:bg-gray-200 transition text-sm"
>
Filter anwenden
</button>
</div>
</div>
</div>
{/* Error */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 p-4 rounded-lg">
{error}
</div>
)}
{/* Loading */}
{loading && (
<div className="text-center py-12 text-gray-500">
Lade Module...
</div>
)}
{/* Main Content - Two Column Layout */}
{!loading && (
<div className="flex gap-6">
{/* Module List */}
<div className="flex-1 space-y-4">
{Object.entries(modulesByType).map(([type, typeModules]) => (
<div key={type} className="bg-white rounded-lg shadow border">
<div className={`px-4 py-2 border-b ${SERVICE_TYPE_CONFIG[type]?.bgColor || 'bg-gray-100'}`}>
<span className="text-lg mr-2">{SERVICE_TYPE_CONFIG[type]?.icon || '📁'}</span>
<span className={`font-semibold ${SERVICE_TYPE_CONFIG[type]?.color || 'text-gray-700'}`}>
{type.charAt(0).toUpperCase() + type.slice(1)}
</span>
<span className="text-gray-500 ml-2">({typeModules.length})</span>
</div>
<div className="divide-y">
{typeModules.map((module) => (
<div
key={module.id}
onClick={() => fetchModuleDetail(module.name)}
className={`p-4 cursor-pointer hover:bg-gray-50 transition ${
selectedModule?.id === module.id ? 'bg-blue-50' : ''
}`}
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{module.display_name}</span>
{module.port && (
<span className="text-xs text-gray-400">:{module.port}</span>
)}
</div>
<div className="text-sm text-gray-500 mt-1">{module.name}</div>
{module.description && (
<div className="text-sm text-gray-600 mt-1 line-clamp-2">
{module.description}
</div>
)}
<div className="flex flex-wrap gap-1 mt-2">
{module.technology_stack.slice(0, 4).map((tech, i) => (
<span key={i} className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded">
{tech}
</span>
))}
{module.technology_stack.length > 4 && (
<span className="px-2 py-0.5 text-gray-400 text-xs">
+{module.technology_stack.length - 4}
</span>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1">
<span className={`px-2 py-0.5 text-xs rounded ${
CRITICALITY_CONFIG[module.criticality]?.bgColor || 'bg-gray-100'
} ${CRITICALITY_CONFIG[module.criticality]?.color || 'text-gray-700'}`}>
{module.criticality}
</span>
<div className="flex gap-1 mt-1">
{module.processes_pii && (
<span className="px-1.5 py-0.5 bg-purple-100 text-purple-700 text-xs rounded" title="Verarbeitet PII">
PII
</span>
)}
{module.ai_components && (
<span className="px-1.5 py-0.5 bg-pink-100 text-pink-700 text-xs rounded" title="AI-Komponenten">
AI
</span>
)}
</div>
<div className="text-xs text-gray-400 mt-1">
{module.regulation_count} Regulations
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
{filteredModules.length === 0 && !loading && (
<div className="text-center py-12 text-gray-500 bg-white rounded-lg shadow border">
Keine Module gefunden.
<button
onClick={() => seedModules(false)}
className="text-blue-600 hover:underline ml-1"
>
Jetzt seeden?
</button>
</div>
)}
</div>
{/* Detail Panel */}
{selectedModule && (
<div className="w-96 bg-white rounded-lg shadow border sticky top-6 h-fit">
<div className={`px-4 py-3 border-b ${SERVICE_TYPE_CONFIG[selectedModule.service_type]?.bgColor || 'bg-gray-100'}`}>
<div className="flex items-center justify-between">
<span className="text-lg">{SERVICE_TYPE_CONFIG[selectedModule.service_type]?.icon || '📁'}</span>
<button
onClick={() => setSelectedModule(null)}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
<h3 className="font-bold text-lg mt-2">{selectedModule.display_name}</h3>
<div className="text-sm text-gray-600">{selectedModule.name}</div>
</div>
{loadingDetail ? (
<div className="p-4 text-center text-gray-500">Lade Details...</div>
) : (
<div className="p-4 space-y-4">
{/* Description */}
{selectedModule.description && (
<div>
<div className="text-xs text-gray-500 uppercase mb-1">Beschreibung</div>
<div className="text-sm text-gray-700">{selectedModule.description}</div>
</div>
)}
{/* Technical Details */}
<div className="grid grid-cols-2 gap-2 text-sm">
{selectedModule.port && (
<div>
<span className="text-gray-500">Port:</span>
<span className="ml-1 font-mono">{selectedModule.port}</span>
</div>
)}
<div>
<span className="text-gray-500">Criticality:</span>
<span className={`ml-1 px-1.5 py-0.5 rounded text-xs ${
CRITICALITY_CONFIG[selectedModule.criticality]?.bgColor || ''
} ${CRITICALITY_CONFIG[selectedModule.criticality]?.color || ''}`}>
{selectedModule.criticality}
</span>
</div>
</div>
{/* Technology Stack */}
<div>
<div className="text-xs text-gray-500 uppercase mb-1">Tech Stack</div>
<div className="flex flex-wrap gap-1">
{selectedModule.technology_stack.map((tech, i) => (
<span key={i} className="px-2 py-0.5 bg-gray-100 text-gray-700 text-xs rounded">
{tech}
</span>
))}
</div>
</div>
{/* Data Categories */}
{selectedModule.data_categories.length > 0 && (
<div>
<div className="text-xs text-gray-500 uppercase mb-1">Daten-Kategorien</div>
<div className="flex flex-wrap gap-1">
{selectedModule.data_categories.map((cat, i) => (
<span key={i} className="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs rounded">
{cat}
</span>
))}
</div>
</div>
)}
{/* Flags */}
<div className="flex flex-wrap gap-2">
{selectedModule.processes_pii && (
<span className="px-2 py-1 bg-purple-100 text-purple-700 text-xs rounded">
Verarbeitet PII
</span>
)}
{selectedModule.ai_components && (
<span className="px-2 py-1 bg-pink-100 text-pink-700 text-xs rounded">
AI-Komponenten
</span>
)}
{selectedModule.processes_health_data && (
<span className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded">
Gesundheitsdaten
</span>
)}
</div>
{/* Regulations */}
{selectedModule.regulations && selectedModule.regulations.length > 0 && (
<div>
<div className="text-xs text-gray-500 uppercase mb-2">
Applicable Regulations ({selectedModule.regulations.length})
</div>
<div className="space-y-2">
{selectedModule.regulations.map((reg, i) => (
<div key={i} className="p-2 bg-gray-50 rounded text-sm">
<div className="flex justify-between items-start">
<span className="font-medium">{reg.code}</span>
<span className={`px-1.5 py-0.5 rounded text-xs ${
RELEVANCE_CONFIG[reg.relevance_level]?.bgColor || 'bg-gray-100'
} ${RELEVANCE_CONFIG[reg.relevance_level]?.color || 'text-gray-700'}`}>
{reg.relevance_level}
</span>
</div>
<div className="text-gray-500 text-xs">{reg.name}</div>
{reg.notes && (
<div className="text-gray-600 text-xs mt-1 italic">{reg.notes}</div>
)}
</div>
))}
</div>
</div>
)}
{/* Owner */}
{selectedModule.owner_team && (
<div>
<div className="text-xs text-gray-500 uppercase mb-1">Owner</div>
<div className="text-sm text-gray-700">{selectedModule.owner_team}</div>
</div>
)}
{/* Repository */}
{selectedModule.repository_path && (
<div>
<div className="text-xs text-gray-500 uppercase mb-1">Repository</div>
<code className="text-xs bg-gray-100 px-2 py-1 rounded block">
{selectedModule.repository_path}
</code>
</div>
)}
{/* AI Risk Assessment Button */}
<div className="pt-2 border-t">
<button
onClick={() => assessModuleRisk(selectedModule.id)}
disabled={loadingRisk}
className="w-full px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg hover:from-purple-700 hover:to-pink-700 transition disabled:opacity-50 flex items-center justify-center gap-2"
>
{loadingRisk ? (
<>
<span className="animate-spin"></span>
AI analysiert...
</>
) : (
<>
<span>🤖</span>
AI Risikobewertung
</>
)}
</button>
</div>
{/* AI Risk Assessment Panel */}
{showRiskPanel && (
<div className="mt-4 p-4 bg-gradient-to-br from-purple-50 to-pink-50 rounded-lg border border-purple-200">
<div className="flex justify-between items-center mb-3">
<h4 className="font-semibold text-purple-900 flex items-center gap-2">
<span>🤖</span> AI Risikobewertung
</h4>
<button
onClick={() => setShowRiskPanel(false)}
className="text-purple-400 hover:text-purple-600"
>
</button>
</div>
{loadingRisk ? (
<div className="text-center py-4 text-purple-600">
<div className="animate-pulse">Analysiere Compliance-Risiken...</div>
</div>
) : riskAssessment ? (
<div className="space-y-3">
{/* Overall Risk */}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Gesamtrisiko:</span>
<span className={`px-2 py-1 rounded text-sm font-medium ${
riskAssessment.overall_risk === 'critical' ? 'bg-red-100 text-red-700' :
riskAssessment.overall_risk === 'high' ? 'bg-orange-100 text-orange-700' :
riskAssessment.overall_risk === 'medium' ? 'bg-yellow-100 text-yellow-700' :
'bg-green-100 text-green-700'
}`}>
{riskAssessment.overall_risk.toUpperCase()}
</span>
<span className="text-xs text-gray-400">
({Math.round(riskAssessment.confidence_score * 100)}% Konfidenz)
</span>
</div>
{/* Risk Factors */}
{riskAssessment.risk_factors.length > 0 && (
<div>
<div className="text-xs text-gray-500 uppercase mb-1">Risikofaktoren</div>
<div className="space-y-1">
{riskAssessment.risk_factors.map((factor, i) => (
<div key={i} className="flex items-center justify-between text-sm bg-white/50 rounded px-2 py-1">
<span className="text-gray-700">{factor.factor}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${
factor.severity === 'critical' || factor.severity === 'high'
? 'bg-red-100 text-red-600'
: 'bg-yellow-100 text-yellow-600'
}`}>
{factor.severity}
</span>
</div>
))}
</div>
</div>
)}
{/* Compliance Gaps */}
{riskAssessment.compliance_gaps.length > 0 && (
<div>
<div className="text-xs text-gray-500 uppercase mb-1">Compliance-Lücken</div>
<ul className="text-sm text-gray-700 space-y-1">
{riskAssessment.compliance_gaps.map((gap, i) => (
<li key={i} className="flex items-start gap-1">
<span className="text-red-500"></span>
<span>{gap}</span>
</li>
))}
</ul>
</div>
)}
{/* Recommendations */}
{riskAssessment.recommendations.length > 0 && (
<div>
<div className="text-xs text-gray-500 uppercase mb-1">Empfehlungen</div>
<ul className="text-sm text-gray-700 space-y-1">
{riskAssessment.recommendations.map((rec, i) => (
<li key={i} className="flex items-start gap-1">
<span className="text-green-500"></span>
<span>{rec}</span>
</li>
))}
</ul>
</div>
)}
</div>
) : (
<div className="text-center py-4 text-gray-500 text-sm">
Klicken Sie auf &quot;AI Risikobewertung&quot; um eine Analyse zu starten.
</div>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Regulations Coverage Overview */}
{overview && overview.regulations_coverage && Object.keys(overview.regulations_coverage).length > 0 && (
<div className="bg-white rounded-lg shadow border p-4">
<h3 className="font-semibold text-gray-900 mb-4">Regulation Coverage</h3>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Object.entries(overview.regulations_coverage)
.sort(([, a], [, b]) => b - a)
.map(([code, count]) => (
<div key={code} className="bg-gray-50 rounded p-3 text-center">
<div className="text-2xl font-bold text-blue-600">{count}</div>
<div className="text-xs text-gray-600 truncate" title={code}>{code}</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,413 @@
'use client'
/**
* Meine Aufgaben - Personal Task Dashboard
*
* Zeigt dem angemeldeten Benutzer seine Compliance-Aufgaben:
* - Offene Control-Reviews
* - Faellige Evidence-Uploads
* - Ausstehende Sign-offs
* - Risiko-Behandlungen
*/
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import AdminLayout from '@/components/admin/AdminLayout'
import { Language } from '@/lib/compliance-i18n'
interface Task {
id: string
type: 'control_review' | 'evidence_upload' | 'signoff' | 'risk_treatment'
title: string
description: string
priority: 'critical' | 'high' | 'medium' | 'low'
due_date: string | null
days_remaining: number | null
status: 'overdue' | 'due_soon' | 'pending' | 'in_progress'
related_entity: {
type: string
id: string
name: string
}
}
interface TaskStats {
total: number
overdue: number
due_soon: number
in_progress: number
completed_this_week: number
}
// Mock-Daten fuer die Demonstration
const MOCK_TASKS: Task[] = [
{
id: '1',
type: 'control_review',
title: 'PRIV-001 Review faellig',
description: 'Quartalsreview des Verarbeitungsverzeichnisses',
priority: 'high',
due_date: '2026-01-20',
days_remaining: 2,
status: 'due_soon',
related_entity: { type: 'control', id: 'PRIV-001', name: 'Verarbeitungsverzeichnis' }
},
{
id: '2',
type: 'evidence_upload',
title: 'SAST-Report hochladen',
description: 'Aktueller Semgrep-Scan fuer SDLC-001',
priority: 'medium',
due_date: '2026-01-25',
days_remaining: 7,
status: 'pending',
related_entity: { type: 'control', id: 'SDLC-001', name: 'SAST Scanning' }
},
{
id: '3',
type: 'signoff',
title: 'Audit Sign-off: DSGVO Art. 32',
description: 'Sign-off fuer technische Massnahmen im Q1 Audit',
priority: 'critical',
due_date: '2026-01-19',
days_remaining: 1,
status: 'due_soon',
related_entity: { type: 'requirement', id: 'gdpr-art32', name: 'DSGVO Art. 32' }
},
{
id: '4',
type: 'risk_treatment',
title: 'RISK-003 Behandlung',
description: 'Risiko-Behandlungsplan fuer Key-Rotation definieren',
priority: 'high',
due_date: '2026-01-22',
days_remaining: 4,
status: 'in_progress',
related_entity: { type: 'risk', id: 'RISK-003', name: 'Unzureichende Key-Rotation' }
},
{
id: '5',
type: 'control_review',
title: 'IAM-002 MFA-Check',
description: 'Ueberpruefung der MFA-Abdeckung fuer Admin-Accounts',
priority: 'medium',
due_date: '2026-01-28',
days_remaining: 10,
status: 'pending',
related_entity: { type: 'control', id: 'IAM-002', name: 'MFA fuer Admin-Accounts' }
},
{
id: '6',
type: 'evidence_upload',
title: 'Backup-Test Protokoll',
description: 'Nachweis fuer erfolgreichen Backup-Restore-Test',
priority: 'low',
due_date: '2026-02-01',
days_remaining: 14,
status: 'pending',
related_entity: { type: 'control', id: 'OPS-002', name: 'Backup & Recovery' }
},
]
const MOCK_STATS: TaskStats = {
total: 6,
overdue: 0,
due_soon: 2,
in_progress: 1,
completed_this_week: 3
}
const TYPE_LABELS = {
de: {
control_review: 'Control-Review',
evidence_upload: 'Nachweis-Upload',
signoff: 'Sign-off',
risk_treatment: 'Risiko-Behandlung'
},
en: {
control_review: 'Control Review',
evidence_upload: 'Evidence Upload',
signoff: 'Sign-off',
risk_treatment: 'Risk Treatment'
}
}
const PRIORITY_COLORS = {
critical: 'bg-red-500',
high: 'bg-orange-500',
medium: 'bg-yellow-500',
low: 'bg-slate-400'
}
const STATUS_COLORS = {
overdue: 'text-red-500 bg-red-500/10',
due_soon: 'text-orange-500 bg-orange-500/10',
pending: 'text-slate-400 bg-slate-400/10',
in_progress: 'text-blue-500 bg-blue-500/10'
}
export default function MyTasksPage() {
const router = useRouter()
const [language, setLanguage] = useState<Language>('de')
const [tasks, setTasks] = useState<Task[]>(MOCK_TASKS)
const [stats, setStats] = useState<TaskStats>(MOCK_STATS)
const [filter, setFilter] = useState<string>('all')
const [sortBy, setSortBy] = useState<'due_date' | 'priority'>('due_date')
const [loading, setLoading] = useState(false)
useEffect(() => {
const storedLang = localStorage.getItem('compliance_language') as Language
if (storedLang) {
setLanguage(storedLang)
}
// In Zukunft: Lade Tasks vom Backend
// loadTasks()
}, [])
const filteredTasks = tasks
.filter(task => filter === 'all' || task.type === filter)
.sort((a, b) => {
if (sortBy === 'due_date') {
const aDate = a.due_date ? new Date(a.due_date).getTime() : Infinity
const bDate = b.due_date ? new Date(b.due_date).getTime() : Infinity
return aDate - bDate
} else {
const priorityOrder = { critical: 0, high: 1, medium: 2, low: 3 }
return priorityOrder[a.priority] - priorityOrder[b.priority]
}
})
const getTypeIcon = (type: Task['type']) => {
switch (type) {
case 'control_review':
return (
<svg className="w-5 h-5" 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>
)
case 'evidence_upload':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
)
case 'signoff':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
)
case 'risk_treatment':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
)
}
}
return (
<AdminLayout>
<div className="p-6 bg-slate-900 min-h-screen">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-white">
{language === 'de' ? 'Meine Aufgaben' : 'My Tasks'}
</h1>
<p className="text-slate-400 mt-1">
{language === 'de'
? 'Uebersicht Ihrer Compliance-Aufgaben'
: 'Overview of your compliance tasks'}
</p>
</div>
<button
onClick={() => router.push('/admin/compliance/role-select')}
className="px-4 py-2 bg-slate-700 text-slate-300 rounded-lg hover:bg-slate-600 transition-colors"
>
{language === 'de' ? 'Zurueck zur Auswahl' : 'Back to Selection'}
</button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 mb-8">
<StatCard
label={language === 'de' ? 'Gesamt' : 'Total'}
value={stats.total}
color="text-white"
bgColor="bg-slate-700"
/>
<StatCard
label={language === 'de' ? 'Ueberfaellig' : 'Overdue'}
value={stats.overdue}
color="text-red-500"
bgColor="bg-red-500/10"
/>
<StatCard
label={language === 'de' ? 'Bald faellig' : 'Due Soon'}
value={stats.due_soon}
color="text-orange-500"
bgColor="bg-orange-500/10"
/>
<StatCard
label={language === 'de' ? 'In Bearbeitung' : 'In Progress'}
value={stats.in_progress}
color="text-blue-500"
bgColor="bg-blue-500/10"
/>
<StatCard
label={language === 'de' ? 'Diese Woche erledigt' : 'Completed This Week'}
value={stats.completed_this_week}
color="text-green-500"
bgColor="bg-green-500/10"
/>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-4 mb-6">
<div className="flex items-center gap-2">
<span className="text-slate-400 text-sm">
{language === 'de' ? 'Filter:' : 'Filter:'}
</span>
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="bg-slate-800 border border-slate-700 text-white rounded-lg px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
>
<option value="all">{language === 'de' ? 'Alle' : 'All'}</option>
<option value="control_review">{TYPE_LABELS[language].control_review}</option>
<option value="evidence_upload">{TYPE_LABELS[language].evidence_upload}</option>
<option value="signoff">{TYPE_LABELS[language].signoff}</option>
<option value="risk_treatment">{TYPE_LABELS[language].risk_treatment}</option>
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-400 text-sm">
{language === 'de' ? 'Sortieren:' : 'Sort by:'}
</span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as 'due_date' | 'priority')}
className="bg-slate-800 border border-slate-700 text-white rounded-lg px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
>
<option value="due_date">{language === 'de' ? 'Faelligkeitsdatum' : 'Due Date'}</option>
<option value="priority">{language === 'de' ? 'Prioritaet' : 'Priority'}</option>
</select>
</div>
</div>
{/* Task List */}
<div className="space-y-4">
{filteredTasks.length === 0 ? (
<div className="bg-slate-800 rounded-xl p-12 text-center">
<svg className="w-16 h-16 mx-auto text-slate-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
<h3 className="text-xl font-semibold text-white mb-2">
{language === 'de' ? 'Keine Aufgaben' : 'No Tasks'}
</h3>
<p className="text-slate-400">
{language === 'de'
? 'Sie haben aktuell keine offenen Compliance-Aufgaben.'
: 'You currently have no open compliance tasks.'}
</p>
</div>
) : (
filteredTasks.map((task) => (
<div
key={task.id}
className="bg-slate-800 rounded-xl p-5 border border-slate-700 hover:border-slate-600 transition-colors"
>
<div className="flex items-start gap-4">
{/* Icon */}
<div className={`p-3 rounded-lg ${STATUS_COLORS[task.status]}`}>
{getTypeIcon(task.type)}
</div>
{/* Content */}
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<h3 className="text-lg font-semibold text-white">{task.title}</h3>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${PRIORITY_COLORS[task.priority]} text-white`}>
{task.priority.toUpperCase()}
</span>
<span className="text-xs text-slate-500 bg-slate-700 px-2 py-0.5 rounded">
{TYPE_LABELS[language][task.type]}
</span>
</div>
<p className="text-slate-400 text-sm mb-2">{task.description}</p>
<div className="flex items-center gap-4 text-sm">
<span className="text-slate-500">
{language === 'de' ? 'Betrifft:' : 'Related:'}{' '}
<span className="text-blue-400">{task.related_entity.name}</span>
</span>
{task.due_date && (
<span className={task.days_remaining !== null && task.days_remaining <= 3 ? 'text-orange-400' : 'text-slate-500'}>
{language === 'de' ? 'Faellig:' : 'Due:'}{' '}
{new Date(task.due_date).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US')}
{task.days_remaining !== null && (
<span className="ml-1">
({task.days_remaining} {language === 'de' ? 'Tage' : 'days'})
</span>
)}
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<button
onClick={() => {
// Navigation basierend auf Task-Typ
if (task.type === 'signoff') {
router.push('/admin/compliance/audit-checklist')
} else if (task.type === 'evidence_upload') {
router.push('/admin/compliance/evidence')
} else if (task.type === 'control_review') {
router.push('/admin/compliance/controls')
} else {
router.push('/admin/compliance/risks')
}
}}
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-500 transition-colors"
>
{language === 'de' ? 'Bearbeiten' : 'Handle'}
</button>
<button
className="px-3 py-2 bg-slate-700 text-slate-300 rounded-lg text-sm hover:bg-slate-600 transition-colors"
title={language === 'de' ? 'Spaeter erledigen' : 'Snooze'}
>
<svg className="w-5 h-5" 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>
</button>
</div>
</div>
</div>
))
)}
</div>
{/* Footer */}
<div className="mt-8 text-center text-slate-500 text-sm">
<p>
{language === 'de'
? 'Aufgaben werden automatisch basierend auf Control-Review-Zyklen, Evidence-Ablauf und Audit-Sessions generiert.'
: 'Tasks are automatically generated based on control review cycles, evidence expiry, and audit sessions.'}
</p>
</div>
</div>
</AdminLayout>
)
}
// Stat Card Component
function StatCard({ label, value, color, bgColor }: { label: string; value: number; color: string; bgColor: string }) {
return (
<div className={`${bgColor} rounded-xl p-4 border border-slate-700`}>
<p className="text-slate-400 text-sm mb-1">{label}</p>
<p className={`text-3xl font-bold ${color}`}>{value}</p>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,622 @@
'use client'
/**
* Risk Matrix Page
*
* Features:
* - Visual 5x5 risk matrix
* - Risk list with CRUD
* - Risk assessment / update
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
import AdminLayout from '@/components/admin/AdminLayout'
interface Risk {
id: string
risk_id: string
title: string
description: string
category: string
likelihood: number
impact: number
inherent_risk: string
mitigating_controls: string[] | null
residual_likelihood: number | null
residual_impact: number | null
residual_risk: string | null
owner: string
status: string
treatment_plan: string
}
const RISK_COLORS: Record<string, string> = {
low: 'bg-green-500',
medium: 'bg-yellow-500',
high: 'bg-orange-500',
critical: 'bg-red-500',
}
const RISK_BG_COLORS: Record<string, string> = {
low: 'bg-green-100 border-green-300',
medium: 'bg-yellow-100 border-yellow-300',
high: 'bg-orange-100 border-orange-300',
critical: 'bg-red-100 border-red-300',
}
const STATUS_OPTIONS = ['open', 'mitigated', 'accepted', 'transferred']
const CATEGORY_OPTIONS = [
{ value: 'data_breach', label: 'Datenschutzverletzung' },
{ value: 'compliance_gap', label: 'Compliance-Luecke' },
{ value: 'vendor_risk', label: 'Lieferantenrisiko' },
{ value: 'operational', label: 'Betriebsrisiko' },
{ value: 'technical', label: 'Technisches Risiko' },
{ value: 'legal', label: 'Rechtliches Risiko' },
]
const calculateRiskLevel = (likelihood: number, impact: number): string => {
const score = likelihood * impact
if (score >= 20) return 'critical'
if (score >= 12) return 'high'
if (score >= 6) return 'medium'
return 'low'
}
export default function RisksPage() {
const [risks, setRisks] = useState<Risk[]>([])
const [loading, setLoading] = useState(true)
const [viewMode, setViewMode] = useState<'matrix' | 'list'>('matrix')
const [selectedRisk, setSelectedRisk] = useState<Risk | null>(null)
const [editModalOpen, setEditModalOpen] = useState(false)
const [createModalOpen, setCreateModalOpen] = useState(false)
const [formData, setFormData] = useState({
risk_id: '',
title: '',
description: '',
category: 'compliance_gap',
likelihood: 3,
impact: 3,
owner: '',
treatment_plan: '',
status: 'open',
mitigating_controls: [] as string[],
residual_likelihood: null as number | null,
residual_impact: null as number | null,
})
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
useEffect(() => {
loadRisks()
}, [])
const loadRisks = async () => {
setLoading(true)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/risks`)
if (res.ok) {
const data = await res.json()
setRisks(data.risks || [])
}
} catch (error) {
console.error('Failed to load risks:', error)
} finally {
setLoading(false)
}
}
const openCreateModal = () => {
setFormData({
risk_id: `RISK-${String(risks.length + 1).padStart(3, '0')}`,
title: '',
description: '',
category: 'compliance_gap',
likelihood: 3,
impact: 3,
owner: '',
treatment_plan: '',
status: 'open',
mitigating_controls: [],
residual_likelihood: null,
residual_impact: null,
})
setCreateModalOpen(true)
}
const openEditModal = (risk: Risk) => {
setSelectedRisk(risk)
setFormData({
risk_id: risk.risk_id,
title: risk.title,
description: risk.description || '',
category: risk.category,
likelihood: risk.likelihood,
impact: risk.impact,
owner: risk.owner || '',
treatment_plan: risk.treatment_plan || '',
status: risk.status,
mitigating_controls: risk.mitigating_controls || [],
residual_likelihood: risk.residual_likelihood,
residual_impact: risk.residual_impact,
})
setEditModalOpen(true)
}
const handleCreate = async () => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/risks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
risk_id: formData.risk_id,
title: formData.title,
description: formData.description,
category: formData.category,
likelihood: formData.likelihood,
impact: formData.impact,
owner: formData.owner,
treatment_plan: formData.treatment_plan,
mitigating_controls: formData.mitigating_controls,
}),
})
if (res.ok) {
setCreateModalOpen(false)
loadRisks()
} else {
const error = await res.text()
alert(`Fehler: ${error}`)
}
} catch (error) {
console.error('Create failed:', error)
alert('Fehler beim Erstellen')
}
}
const handleUpdate = async () => {
if (!selectedRisk) return
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/risks/${selectedRisk.risk_id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: formData.title,
description: formData.description,
category: formData.category,
likelihood: formData.likelihood,
impact: formData.impact,
owner: formData.owner,
treatment_plan: formData.treatment_plan,
status: formData.status,
mitigating_controls: formData.mitigating_controls,
residual_likelihood: formData.residual_likelihood,
residual_impact: formData.residual_impact,
}),
})
if (res.ok) {
setEditModalOpen(false)
loadRisks()
} else {
const error = await res.text()
alert(`Fehler: ${error}`)
}
} catch (error) {
console.error('Update failed:', error)
alert('Fehler beim Aktualisieren')
}
}
// Build matrix data structure
const buildMatrix = () => {
const matrix: Record<number, Record<number, Risk[]>> = {}
for (let l = 1; l <= 5; l++) {
matrix[l] = {}
for (let i = 1; i <= 5; i++) {
matrix[l][i] = []
}
}
risks.forEach((risk) => {
if (matrix[risk.likelihood] && matrix[risk.likelihood][risk.impact]) {
matrix[risk.likelihood][risk.impact].push(risk)
}
})
return matrix
}
const renderMatrix = () => {
const matrix = buildMatrix()
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Risk Matrix (Likelihood x Impact)</h3>
<div className="overflow-x-auto">
<div className="inline-block">
{/* Column headers (Impact) */}
<div className="flex ml-16">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="w-24 text-center text-sm font-medium text-slate-500 pb-2">
Impact {i}
</div>
))}
</div>
{/* Matrix rows */}
{[5, 4, 3, 2, 1].map((likelihood) => (
<div key={likelihood} className="flex items-center">
<div className="w-16 text-sm font-medium text-slate-500 text-right pr-2">
L{likelihood}
</div>
{[1, 2, 3, 4, 5].map((impact) => {
const level = calculateRiskLevel(likelihood, impact)
const cellRisks = matrix[likelihood][impact]
return (
<div
key={impact}
className={`w-24 h-20 border m-0.5 rounded flex flex-col items-center justify-center ${RISK_BG_COLORS[level]}`}
>
{cellRisks.length > 0 && (
<div className="flex flex-wrap gap-1 justify-center">
{cellRisks.map((r) => (
<button
key={r.id}
onClick={() => openEditModal(r)}
className={`px-2 py-0.5 text-xs font-medium rounded text-white ${RISK_COLORS[r.inherent_risk] || 'bg-slate-500'} hover:opacity-80`}
title={r.title}
>
{r.risk_id}
</button>
))}
</div>
)}
</div>
)
})}
</div>
))}
</div>
</div>
{/* Legend */}
<div className="flex gap-4 mt-6 pt-4 border-t">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-green-500 rounded" />
<span className="text-sm text-slate-600">Low (1-5)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-yellow-500 rounded" />
<span className="text-sm text-slate-600">Medium (6-11)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-orange-500 rounded" />
<span className="text-sm text-slate-600">High (12-19)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-red-500 rounded" />
<span className="text-sm text-slate-600">Critical (20-25)</span>
</div>
</div>
</div>
)
}
const renderList = () => (
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<table className="w-full">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">ID</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Titel</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Kategorie</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">L x I</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Risiko</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Status</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{risks.map((risk) => (
<tr key={risk.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-mono font-medium text-primary-600">{risk.risk_id}</span>
</td>
<td className="px-4 py-3">
<div>
<p className="font-medium text-slate-900">{risk.title}</p>
{risk.description && (
<p className="text-sm text-slate-500 truncate max-w-md">{risk.description}</p>
)}
</div>
</td>
<td className="px-4 py-3 text-center">
<span className="text-sm text-slate-600">
{CATEGORY_OPTIONS.find((c) => c.value === risk.category)?.label || risk.category}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className="font-mono">{risk.likelihood} x {risk.impact}</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs font-medium rounded-full text-white ${RISK_COLORS[risk.inherent_risk] || 'bg-slate-500'}`}>
{risk.inherent_risk}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-1 text-xs rounded-full ${
risk.status === 'mitigated' ? 'bg-green-100 text-green-700' :
risk.status === 'accepted' ? 'bg-blue-100 text-blue-700' :
'bg-slate-100 text-slate-700'
}`}>
{risk.status}
</span>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => openEditModal(risk)}
className="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Bearbeiten
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
const renderForm = (isCreate: boolean) => (
<div className="space-y-4">
{isCreate && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Risk ID</label>
<input
type="text"
value={formData.risk_id}
onChange={(e) => setFormData({ ...formData, risk_id: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Titel *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Kategorie</label>
<select
value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
{CATEGORY_OPTIONS.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Status</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Likelihood (1-5)</label>
<input
type="range"
min="1"
max="5"
value={formData.likelihood}
onChange={(e) => setFormData({ ...formData, likelihood: parseInt(e.target.value) })}
className="w-full"
/>
<div className="flex justify-between text-xs text-slate-500">
<span>1</span>
<span className="font-medium">{formData.likelihood}</span>
<span>5</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Impact (1-5)</label>
<input
type="range"
min="1"
max="5"
value={formData.impact}
onChange={(e) => setFormData({ ...formData, impact: parseInt(e.target.value) })}
className="w-full"
/>
<div className="flex justify-between text-xs text-slate-500">
<span>1</span>
<span className="font-medium">{formData.impact}</span>
<span>5</span>
</div>
</div>
</div>
<div className="p-3 bg-slate-50 rounded-lg">
<p className="text-sm text-slate-600">
Berechnetes Risiko:{' '}
<span className={`font-medium px-2 py-0.5 rounded text-white ${RISK_COLORS[calculateRiskLevel(formData.likelihood, formData.impact)]}`}>
{calculateRiskLevel(formData.likelihood, formData.impact).toUpperCase()} ({formData.likelihood * formData.impact})
</span>
</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Verantwortlich</label>
<input
type="text"
value={formData.owner}
onChange={(e) => setFormData({ ...formData, owner: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Behandlungsplan</label>
<textarea
value={formData.treatment_plan}
onChange={(e) => setFormData({ ...formData, treatment_plan: e.target.value })}
rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
</div>
)
return (
<AdminLayout title="Risk Matrix" description="Risikobewertung & Management">
{/* Header */}
<div className="flex flex-wrap items-center gap-4 mb-6">
<Link
href="/admin/compliance"
className="text-sm text-slate-500 hover:text-slate-700 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="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Zurueck
</Link>
<div className="flex-1" />
{/* View Toggle */}
<div className="flex bg-slate-100 rounded-lg p-1">
<button
onClick={() => setViewMode('matrix')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
viewMode === 'matrix' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
}`}
>
Matrix
</button>
<button
onClick={() => setViewMode('list')}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
viewMode === 'list' ? 'bg-white shadow text-slate-900' : 'text-slate-600'
}`}
>
Liste
</button>
</div>
<button
onClick={openCreateModal}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Risiko hinzufuegen
</button>
</div>
{/* Content */}
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600" />
</div>
) : risks.length === 0 ? (
<div className="bg-white rounded-xl shadow-sm border p-12 text-center">
<svg className="w-16 h-16 text-slate-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<p className="text-slate-500 mb-4">Keine Risiken erfasst</p>
<button
onClick={openCreateModal}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Erstes Risiko hinzufuegen
</button>
</div>
) : viewMode === 'matrix' ? (
renderMatrix()
) : (
renderList()
)}
{/* Create Modal */}
{createModalOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Neues Risiko</h3>
{renderForm(true)}
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setCreateModalOpen(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
>
Abbrechen
</button>
<button
onClick={handleCreate}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Erstellen
</button>
</div>
</div>
</div>
)}
{/* Edit Modal */}
{editModalOpen && selectedRisk && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold text-slate-900 mb-4">
Risiko bearbeiten: {selectedRisk.risk_id}
</h3>
{renderForm(false)}
<div className="flex justify-end gap-3 mt-6">
<button
onClick={() => setEditModalOpen(false)}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
>
Abbrechen
</button>
<button
onClick={handleUpdate}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Speichern
</button>
</div>
</div>
</div>
)}
</AdminLayout>
)
}

View File

@@ -0,0 +1,384 @@
'use client'
/**
* Compliance Portal - Rollen-Auswahl Landing Page
*
* Ermoeglicht Stakeholdern die Auswahl ihrer bevorzugten Ansicht:
* - Executive: Ueberblick, Trends, Ampel-Status
* - Auditor: Checkliste, Sign-off, Reports
* - Compliance Officer: Workflows, Anforderungsmanagement
* - Entwickler: Tech-Docs, Code-Referenzen
*/
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import AdminLayout from '@/components/admin/AdminLayout'
import { Language } from '@/lib/compliance-i18n'
interface RoleCard {
id: string
title: {
de: string
en: string
}
subtitle: {
de: string
en: string
}
description: {
de: string
en: string
}
features: {
de: string[]
en: string[]
}
icon: JSX.Element
href: string
color: string
borderColor: string
}
const roles: RoleCard[] = [
{
id: 'executive',
title: { de: 'Executive', en: 'Executive' },
subtitle: { de: 'Management-Ueberblick', en: 'Management Overview' },
description: {
de: 'Strategische Compliance-Ansicht mit Ampel-Status, Trends und Top-Risiken fuer schnelle Entscheidungen.',
en: 'Strategic compliance view with traffic light status, trends and top risks for quick decisions.'
},
features: {
de: ['Compliance-Ampel', 'Trend-Analyse', 'Top 5 Risiken', 'Deadline-Kalender'],
en: ['Compliance Traffic Light', 'Trend Analysis', 'Top 5 Risks', 'Deadline Calendar']
},
icon: (
<svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
href: '/admin/compliance?tab=executive',
color: 'from-blue-500 to-blue-600',
borderColor: 'border-blue-500'
},
{
id: 'auditor',
title: { de: 'Auditor', en: 'Auditor' },
subtitle: { de: 'Pruefungs-Checkliste', en: 'Audit Checklist' },
description: {
de: 'Strukturierte Pruefungsumgebung mit Checklisten, Sign-off-Workflows und PDF-Reports fuer Auditoren.',
en: 'Structured audit environment with checklists, sign-off workflows and PDF reports for auditors.'
},
features: {
de: ['Audit-Checkliste', 'Sign-off Workflow', 'PDF Reports', 'Digitale Signaturen'],
en: ['Audit Checklist', 'Sign-off Workflow', 'PDF Reports', 'Digital Signatures']
},
icon: (
<svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
href: '/admin/compliance/audit-checklist',
color: 'from-green-500 to-green-600',
borderColor: 'border-green-500'
},
{
id: 'compliance-officer',
title: { de: 'Compliance Officer', en: 'Compliance Officer' },
subtitle: { de: 'Vollstaendige Verwaltung', en: 'Full Management' },
description: {
de: 'Umfassende Compliance-Verwaltung mit allen Anforderungen, Controls, Evidence und Risk Management.',
en: 'Comprehensive compliance management with all requirements, controls, evidence and risk management.'
},
features: {
de: ['Anforderungs-Management', 'Control-Verwaltung', 'Evidence-Upload', 'Risk Matrix'],
en: ['Requirement Management', 'Control Management', 'Evidence Upload', 'Risk Matrix']
},
icon: (
<svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
href: '/admin/compliance/audit-workspace',
color: 'from-purple-500 to-purple-600',
borderColor: 'border-purple-500'
},
{
id: 'developer',
title: { de: 'Entwickler', en: 'Developer' },
subtitle: { de: 'Technische Details', en: 'Technical Details' },
description: {
de: 'Technische Dokumentation mit Code-Referenzen, API-Spezifikationen und Architektur-Diagrammen.',
en: 'Technical documentation with code references, API specifications and architecture diagrams.'
},
features: {
de: ['API-Dokumentation', 'Code-Referenzen', 'Datenmodell', 'Architektur'],
en: ['API Documentation', 'Code References', 'Data Model', 'Architecture']
},
icon: (
<svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
),
href: '/admin/compliance?tab=technisch',
color: 'from-orange-500 to-orange-600',
borderColor: 'border-orange-500'
}
]
export default function RoleSelectPage() {
const router = useRouter()
const [language, setLanguage] = useState<Language>('de')
const [hoveredRole, setHoveredRole] = useState<string | null>(null)
const [savedRole, setSavedRole] = useState<string | null>(null)
// Lade gespeicherte Rolle aus localStorage
useEffect(() => {
const stored = localStorage.getItem('compliance_preferred_role')
if (stored) {
setSavedRole(stored)
}
const storedLang = localStorage.getItem('compliance_language') as Language
if (storedLang) {
setLanguage(storedLang)
}
}, [])
const handleRoleSelect = (role: RoleCard, savePreference: boolean = false) => {
if (savePreference) {
localStorage.setItem('compliance_preferred_role', role.id)
setSavedRole(role.id)
}
router.push(role.href)
}
const clearPreference = () => {
localStorage.removeItem('compliance_preferred_role')
setSavedRole(null)
}
return (
<AdminLayout>
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-8">
{/* Header */}
<div className="max-w-6xl mx-auto mb-12 text-center">
<h1 className="text-4xl font-bold text-white mb-4">
{language === 'de' ? 'Compliance Portal' : 'Compliance Portal'}
</h1>
<p className="text-xl text-slate-300 mb-6">
{language === 'de'
? 'Waehlen Sie Ihre bevorzugte Ansicht'
: 'Choose your preferred view'}
</p>
{/* Sprach-Toggle */}
<div className="flex justify-center gap-2 mb-4">
<button
onClick={() => {
setLanguage('de')
localStorage.setItem('compliance_language', 'de')
}}
className={`px-4 py-2 rounded-lg transition-colors ${
language === 'de'
? 'bg-blue-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
Deutsch
</button>
<button
onClick={() => {
setLanguage('en')
localStorage.setItem('compliance_language', 'en')
}}
className={`px-4 py-2 rounded-lg transition-colors ${
language === 'en'
? 'bg-blue-600 text-white'
: 'bg-slate-700 text-slate-300 hover:bg-slate-600'
}`}
>
English
</button>
</div>
{/* Gespeicherte Praeferenz */}
{savedRole && (
<div className="inline-flex items-center gap-2 bg-slate-700/50 px-4 py-2 rounded-lg text-slate-300 text-sm">
<span>
{language === 'de' ? 'Bevorzugte Rolle:' : 'Preferred role:'}{' '}
<strong className="text-white">
{roles.find(r => r.id === savedRole)?.title[language]}
</strong>
</span>
<button
onClick={clearPreference}
className="text-slate-400 hover:text-white transition-colors"
title={language === 'de' ? 'Praeferenz loeschen' : 'Clear preference'}
>
<svg className="w-4 h-4" 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>
{/* Role Cards Grid */}
<div className="max-w-6xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-6">
{roles.map((role) => (
<div
key={role.id}
className={`relative group cursor-pointer transition-all duration-300 ${
hoveredRole === role.id ? 'scale-105 z-10' : 'scale-100'
}`}
onMouseEnter={() => setHoveredRole(role.id)}
onMouseLeave={() => setHoveredRole(null)}
onClick={() => handleRoleSelect(role)}
>
{/* Glow Effect */}
<div className={`absolute inset-0 bg-gradient-to-r ${role.color} opacity-0 group-hover:opacity-20 rounded-2xl blur-xl transition-opacity duration-300`} />
{/* Card */}
<div className={`relative bg-slate-800/80 backdrop-blur-sm border-2 ${
savedRole === role.id ? role.borderColor : 'border-slate-700'
} rounded-2xl p-6 h-full transition-all duration-300 group-hover:border-slate-500`}>
{/* Saved Badge */}
{savedRole === role.id && (
<div className={`absolute -top-2 -right-2 bg-gradient-to-r ${role.color} text-white text-xs px-2 py-1 rounded-full`}>
{language === 'de' ? 'Bevorzugt' : 'Preferred'}
</div>
)}
{/* Icon & Title */}
<div className="flex items-start gap-4 mb-4">
<div className={`p-3 rounded-xl bg-gradient-to-br ${role.color} text-white`}>
{role.icon}
</div>
<div>
<h2 className="text-2xl font-bold text-white">{role.title[language]}</h2>
<p className="text-slate-400">{role.subtitle[language]}</p>
</div>
</div>
{/* Description */}
<p className="text-slate-300 mb-4">
{role.description[language]}
</p>
{/* Features */}
<div className="space-y-2">
{role.features[language].map((feature, idx) => (
<div key={idx} className="flex items-center gap-2 text-slate-400">
<svg className="w-4 h-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>{feature}</span>
</div>
))}
</div>
{/* Action Buttons */}
<div className="mt-6 flex gap-3">
<button
onClick={(e) => {
e.stopPropagation()
handleRoleSelect(role)
}}
className={`flex-1 py-2 px-4 bg-gradient-to-r ${role.color} text-white rounded-lg font-medium transition-all duration-200 hover:shadow-lg hover:shadow-${role.color.split('-')[1]}-500/25`}
>
{language === 'de' ? 'Oeffnen' : 'Open'}
</button>
<button
onClick={(e) => {
e.stopPropagation()
handleRoleSelect(role, true)
}}
className="py-2 px-4 bg-slate-700 text-slate-300 rounded-lg font-medium transition-all duration-200 hover:bg-slate-600 hover:text-white"
title={language === 'de' ? 'Als Standard speichern' : 'Save as default'}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</button>
</div>
</div>
</div>
))}
</div>
{/* Quick Links */}
<div className="max-w-6xl mx-auto mt-12">
<div className="bg-slate-800/50 backdrop-blur-sm rounded-xl p-6 border border-slate-700">
<h3 className="text-lg font-semibold text-white mb-4">
{language === 'de' ? 'Schnellzugriff' : 'Quick Access'}
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<QuickLink
href="/admin/compliance/controls"
label={language === 'de' ? 'Control Catalogue' : 'Control Catalogue'}
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
}
/>
<QuickLink
href="/admin/compliance/evidence"
label={language === 'de' ? 'Nachweise' : 'Evidence'}
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
}
/>
<QuickLink
href="/admin/compliance/risks"
label={language === 'de' ? 'Risk Matrix' : 'Risk Matrix'}
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
}
/>
<QuickLink
href="/admin/compliance/scraper"
label={language === 'de' ? 'Regulation Scraper' : 'Regulation Scraper'}
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
}
/>
</div>
</div>
</div>
{/* Footer */}
<div className="max-w-6xl mx-auto mt-8 text-center text-slate-500 text-sm">
<p>
BreakPilot Compliance & Audit Framework v3.0 |
19 {language === 'de' ? 'Verordnungen' : 'Regulations'} |
558 {language === 'de' ? 'Anforderungen' : 'Requirements'} |
44 Controls
</p>
</div>
</div>
</AdminLayout>
)
}
// Quick Link Component
function QuickLink({ href, label, icon }: { href: string; label: string; icon: JSX.Element }) {
const router = useRouter()
return (
<button
onClick={() => router.push(href)}
className="flex items-center gap-3 px-4 py-3 bg-slate-700/50 rounded-lg text-slate-300 hover:bg-slate-700 hover:text-white transition-colors"
>
{icon}
<span className="text-sm font-medium">{label}</span>
</button>
)
}

View File

@@ -0,0 +1,789 @@
'use client'
/**
* Compliance Regulation Scraper Admin Page
*
* Manages the extraction of requirements and audit aspects from:
* - EUR-Lex regulations (GDPR, AI Act, CRA, NIS2, etc.)
* - BSI Technical Guidelines (TR-03161)
* - German laws
*
* Similar pattern to edu-search and zeugnisse-crawler.
*/
import { useState, useEffect, useCallback } from 'react'
import AdminLayout from '@/components/admin/AdminLayout'
import SystemInfoSection, { SYSTEM_INFO_CONFIGS } from '@/components/admin/SystemInfoSection'
// Types
interface Source {
code: string
url: string
source_type: string
regulation_type: string
has_data: boolean
requirement_count: number
}
interface ScraperStatus {
status: 'idle' | 'running' | 'completed' | 'error'
current_source: string | null
last_error: string | null
stats: {
sources_processed: number
requirements_extracted: number
errors: number
last_run: string | null
}
known_sources: string[]
}
interface ScrapeResult {
code: string
status: string
requirements_extracted?: number
reason?: string
error?: string
}
interface PDFDocument {
code: string
name: string
description: string
expected_aspects: string
available: boolean
}
interface PDFExtractionResult {
success: boolean
source_document: string
total_aspects: number
requirements_created: number
statistics: {
by_category: Record<string, number>
by_requirement_level: Record<string, number>
}
}
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
// Source type badges
const sourceTypeBadge: Record<string, { label: string; color: string }> = {
eur_lex: { label: 'EUR-Lex', color: 'bg-blue-100 text-blue-800' },
bsi_pdf: { label: 'BSI PDF', color: 'bg-green-100 text-green-800' },
gesetze_im_internet: { label: 'Gesetze', color: 'bg-yellow-100 text-yellow-800' },
manual: { label: 'Manuell', color: 'bg-gray-100 text-gray-800' },
}
// Regulation type badges
const regulationTypeBadge: Record<string, { label: string; color: string; icon: string }> = {
eu_regulation: { label: 'EU-Verordnung', color: 'bg-indigo-100 text-indigo-800', icon: '🇪🇺' },
eu_directive: { label: 'EU-Richtlinie', color: 'bg-purple-100 text-purple-800', icon: '📜' },
de_law: { label: 'DE-Gesetz', color: 'bg-yellow-100 text-yellow-800', icon: '🇩🇪' },
bsi_standard: { label: 'BSI-Standard', color: 'bg-green-100 text-green-800', icon: '🔒' },
industry_standard: { label: 'Standard', color: 'bg-gray-100 text-gray-800', icon: '📋' },
}
export default function ComplianceScraperPage() {
const [activeTab, setActiveTab] = useState<'sources' | 'pdf' | 'status' | 'logs'>('sources')
const [sources, setSources] = useState<Source[]>([])
const [pdfDocuments, setPdfDocuments] = useState<PDFDocument[]>([])
const [status, setStatus] = useState<ScraperStatus | null>(null)
const [loading, setLoading] = useState(true)
const [scraping, setScraping] = useState(false)
const [extracting, setExtracting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [results, setResults] = useState<ScrapeResult[]>([])
const [pdfResult, setPdfResult] = useState<PDFExtractionResult | null>(null)
// Fetch sources
const fetchSources = useCallback(async () => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/scraper/sources`)
if (res.ok) {
const data = await res.json()
setSources(data.sources || [])
}
} catch (err) {
console.error('Failed to fetch sources:', err)
}
}, [])
// Fetch PDF documents
const fetchPdfDocuments = useCallback(async () => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/scraper/pdf-documents`)
if (res.ok) {
const data = await res.json()
setPdfDocuments(data.documents || [])
}
} catch (err) {
console.error('Failed to fetch PDF documents:', err)
}
}, [])
// Fetch status
const fetchStatus = useCallback(async () => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/scraper/status`)
if (res.ok) {
const data = await res.json()
setStatus(data)
}
} catch (err) {
console.error('Failed to fetch status:', err)
}
}, [])
// Initial load
useEffect(() => {
const loadData = async () => {
setLoading(true)
await Promise.all([fetchSources(), fetchStatus(), fetchPdfDocuments()])
setLoading(false)
}
loadData()
}, [fetchSources, fetchStatus, fetchPdfDocuments])
// Poll status while scraping
useEffect(() => {
if (scraping) {
const interval = setInterval(fetchStatus, 2000)
return () => clearInterval(interval)
}
}, [scraping, fetchStatus])
// Scrape all sources
const handleScrapeAll = async () => {
setScraping(true)
setError(null)
setSuccess(null)
setResults([])
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/scraper/scrape-all`, {
method: 'POST',
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.detail || 'Scraping fehlgeschlagen')
}
const data = await res.json()
setResults([
...data.results.success,
...data.results.failed,
...data.results.skipped,
])
setSuccess(`Scraping abgeschlossen: ${data.results.success.length} erfolgreich, ${data.results.skipped.length} uebersprungen, ${data.results.failed.length} fehlgeschlagen`)
// Refresh sources
await fetchSources()
} catch (err: any) {
setError(err.message)
} finally {
setScraping(false)
}
}
// Scrape single source
const handleScrapeSingle = async (code: string, force: boolean = false) => {
setScraping(true)
setError(null)
setSuccess(null)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/scraper/scrape/${code}?force=${force}`, {
method: 'POST',
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.detail || 'Scraping fehlgeschlagen')
}
const data = await res.json()
if (data.status === 'skipped') {
setSuccess(`${code}: Bereits vorhanden (${data.requirement_count} Anforderungen)`)
} else {
setSuccess(`${code}: ${data.requirements_extracted} Anforderungen extrahiert`)
}
// Refresh sources
await fetchSources()
} catch (err: any) {
setError(err.message)
} finally {
setScraping(false)
}
}
// Extract PDF
const handleExtractPdf = async (code: string, saveToDb: boolean = true, force: boolean = false) => {
setExtracting(true)
setError(null)
setSuccess(null)
setPdfResult(null)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/scraper/extract-pdf`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
document_code: code,
save_to_db: saveToDb,
force: force,
}),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.detail || 'PDF-Extraktion fehlgeschlagen')
}
const data: PDFExtractionResult = await res.json()
setPdfResult(data)
if (data.success) {
setSuccess(`${code}: ${data.total_aspects} Pruefaspekte extrahiert, ${data.requirements_created} Requirements erstellt`)
}
// Refresh sources
await fetchSources()
} catch (err: any) {
setError(err.message)
} finally {
setExtracting(false)
}
}
// Clear messages
useEffect(() => {
if (success) {
const timer = setTimeout(() => setSuccess(null), 5000)
return () => clearTimeout(timer)
}
}, [success])
useEffect(() => {
if (error) {
const timer = setTimeout(() => setError(null), 10000)
return () => clearTimeout(timer)
}
}, [error])
// Stats cards
const StatsCard = ({ title, value, subtitle, icon }: { title: string; value: number | string; subtitle?: string; icon: string }) => (
<div className="bg-white rounded-lg shadow-sm p-5 border border-slate-200">
<div className="flex items-center">
<div className="flex-shrink-0">
<span className="text-2xl">{icon}</span>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-slate-500">{title}</p>
<p className="text-2xl font-semibold text-slate-900">{value}</p>
{subtitle && <p className="text-xs text-slate-400">{subtitle}</p>}
</div>
</div>
</div>
)
return (
<AdminLayout
title="Compliance Scraper"
description="Extrahiert Anforderungen aus EU-Regulierungen, BSI-Standards und Gesetzen"
>
{/* Loading */}
{loading && (
<div className="flex items-center justify-center py-12">
<svg className="w-8 h-8 animate-spin text-primary-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="ml-3 text-slate-600">Lade Quellen...</span>
</div>
)}
{!loading && (
<>
{/* Messages */}
{error && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
{success && (
<div className="mb-4 bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg">
{success}
</div>
)}
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<StatsCard
title="Bekannte Quellen"
value={sources.length}
icon="📚"
/>
<StatsCard
title="Mit Daten"
value={sources.filter(s => s.has_data).length}
subtitle={`${sources.length - sources.filter(s => s.has_data).length} noch zu scrapen`}
icon="✅"
/>
<StatsCard
title="Anforderungen gesamt"
value={sources.reduce((acc, s) => acc + s.requirement_count, 0)}
icon="📋"
/>
<StatsCard
title="Letzter Lauf"
value={status?.stats.last_run ? new Date(status.stats.last_run).toLocaleDateString('de-DE') : 'Nie'}
subtitle={status?.stats.errors ? `${status.stats.errors} Fehler` : undefined}
icon="🕐"
/>
</div>
{/* Scraper Status Bar */}
{(scraping || status?.status === 'running') && (
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-600 border-t-transparent mr-3" />
<div>
<p className="font-medium text-blue-800">Scraper laeuft</p>
{status?.current_source && (
<p className="text-sm text-blue-600">Aktuell: {status.current_source}</p>
)}
</div>
</div>
</div>
)}
{/* Tabs */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 mb-6">
<div className="border-b border-slate-200">
<nav className="flex -mb-px">
{[
{ id: 'sources', name: 'Quellen', icon: '📚' },
{ id: 'pdf', name: 'PDF-Extraktion', icon: '📄' },
{ id: 'status', name: 'Status', icon: '📊' },
{ id: 'logs', name: 'Ergebnisse', icon: '📝' },
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary-600 text-primary-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
}`}
>
<span className="mr-2">{tab.icon}</span>
{tab.name}
</button>
))}
</nav>
</div>
<div className="p-6">
{/* Sources Tab */}
{activeTab === 'sources' && (
<div>
{/* Header */}
<div className="flex justify-between items-center mb-6">
<div>
<h3 className="text-lg font-semibold text-slate-900">Regulierungsquellen</h3>
<p className="text-sm text-slate-500">EU-Lex, BSI-TR und deutsche Gesetze</p>
</div>
<button
onClick={handleScrapeAll}
disabled={scraping}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{scraping ? (
<>
<svg className="w-4 h-4 animate-spin" 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 12h4z" />
</svg>
Laeuft...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Alle Quellen scrapen
</>
)}
</button>
</div>
{/* Sources by Type */}
<div className="space-y-6">
{/* EU Regulations */}
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3 flex items-center gap-2">
<span className="text-lg">🇪🇺</span> EU-Regulierungen (EUR-Lex)
</h4>
<div className="grid gap-3">
{sources.filter(s => s.source_type === 'eur_lex').map(source => (
<SourceCard key={source.code} source={source} onScrape={handleScrapeSingle} scraping={scraping} />
))}
</div>
</div>
{/* BSI Standards */}
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3 flex items-center gap-2">
<span className="text-lg">🔒</span> BSI Technical Guidelines
</h4>
<div className="grid gap-3">
{sources.filter(s => s.source_type === 'bsi_pdf').map(source => (
<SourceCard key={source.code} source={source} onScrape={handleScrapeSingle} scraping={scraping} />
))}
</div>
</div>
</div>
</div>
)}
{/* PDF Extraction Tab */}
{activeTab === 'pdf' && (
<div>
<div className="mb-6">
<h3 className="text-lg font-semibold text-slate-900">PDF-Extraktion (PyMuPDF)</h3>
<p className="text-sm text-slate-500">
Extrahiert ALLE Pruefaspekte aus BSI-TR-03161 PDFs mit Regex-Pattern-Matching
</p>
</div>
{/* PDF Documents */}
<div className="space-y-4">
{pdfDocuments.map(doc => (
<div key={doc.code} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-3xl">📄</span>
<div>
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-900">{doc.code}</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
doc.available ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{doc.available ? 'Verfuegbar' : 'Nicht gefunden'}
</span>
</div>
<div className="text-sm text-slate-600">{doc.name}</div>
<div className="text-xs text-slate-500">{doc.description}</div>
<div className="text-xs text-slate-400 mt-1">
Erwartete Pruefaspekte: {doc.expected_aspects}
</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => handleExtractPdf(doc.code, true, false)}
disabled={extracting || !doc.available}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{extracting ? (
<>
<svg className="w-4 h-4 animate-spin" 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 12h4z" />
</svg>
Extrahiere...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Extrahieren
</>
)}
</button>
<button
onClick={() => handleExtractPdf(doc.code, true, true)}
disabled={extracting || !doc.available}
className="px-3 py-2 bg-orange-100 text-orange-700 rounded-lg hover:bg-orange-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Force: Loescht vorhandene und extrahiert neu"
>
Force
</button>
</div>
</div>
</div>
))}
</div>
{/* Last Extraction Result */}
{pdfResult && (
<div className="mt-6 bg-green-50 rounded-lg p-4 border border-green-200">
<h4 className="font-semibold text-green-800 mb-3">Letztes Extraktions-Ergebnis</h4>
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="text-center p-3 bg-white rounded-lg">
<div className="text-2xl font-bold text-green-700">{pdfResult.total_aspects}</div>
<div className="text-sm text-slate-500">Pruefaspekte gefunden</div>
</div>
<div className="text-center p-3 bg-white rounded-lg">
<div className="text-2xl font-bold text-blue-700">{pdfResult.requirements_created}</div>
<div className="text-sm text-slate-500">Requirements erstellt</div>
</div>
<div className="text-center p-3 bg-white rounded-lg">
<div className="text-2xl font-bold text-slate-700">{Object.keys(pdfResult.statistics.by_category || {}).length}</div>
<div className="text-sm text-slate-500">Kategorien</div>
</div>
</div>
{/* Category Breakdown */}
{pdfResult.statistics.by_category && Object.keys(pdfResult.statistics.by_category).length > 0 && (
<div>
<h5 className="text-sm font-medium text-slate-700 mb-2">Nach Kategorie:</h5>
<div className="flex flex-wrap gap-2">
{Object.entries(pdfResult.statistics.by_category).map(([cat, count]) => (
<span key={cat} className="px-2 py-1 bg-white rounded text-xs text-slate-600">
{cat}: <strong>{count}</strong>
</span>
))}
</div>
</div>
)}
</div>
)}
{/* Info Box */}
<div className="mt-6 bg-blue-50 rounded-lg p-4 border border-blue-200">
<h4 className="font-semibold text-blue-800 mb-2">Wie funktioniert die PDF-Extraktion?</h4>
<ul className="text-sm text-blue-700 space-y-1">
<li> <strong>PyMuPDF (fitz)</strong> liest den PDF-Text</li>
<li> <strong>Regex-Pattern</strong> finden Aspekte wie O.Auth_1, O.Sess_2, T.Network_1</li>
<li> <strong>Kontextanalyse</strong> extrahiert Titel, Kategorie und Anforderungsstufe (MUSS/SOLL/KANN)</li>
<li> <strong>Automatische Speicherung</strong> erstellt Requirements in der Datenbank</li>
</ul>
</div>
</div>
)}
{/* Status Tab */}
{activeTab === 'status' && status && (
<div className="space-y-6">
{/* Current Status */}
<div className="bg-slate-50 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-slate-900">Scraper-Status</h3>
<p className="text-sm text-slate-500">
Letzter Lauf: {status.stats.last_run ? new Date(status.stats.last_run).toLocaleString('de-DE') : 'Noch nie'}
</p>
</div>
<div className={`px-3 py-1.5 rounded-full text-sm font-medium ${
status.status === 'running' ? 'bg-blue-100 text-blue-700' :
status.status === 'error' ? 'bg-red-100 text-red-700' :
status.status === 'completed' ? 'bg-green-100 text-green-700' :
'bg-gray-100 text-gray-700'
}`}>
{status.status === 'running' ? '🔄 Laeuft' :
status.status === 'error' ? '❌ Fehler' :
status.status === 'completed' ? '✅ Abgeschlossen' :
'⏸️ Bereit'}
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="text-center p-4 bg-white rounded-lg">
<div className="text-2xl font-bold text-slate-900">{status.stats.sources_processed}</div>
<div className="text-sm text-slate-500">Quellen verarbeitet</div>
</div>
<div className="text-center p-4 bg-white rounded-lg">
<div className="text-2xl font-bold text-green-600">{status.stats.requirements_extracted}</div>
<div className="text-sm text-slate-500">Anforderungen extrahiert</div>
</div>
<div className="text-center p-4 bg-white rounded-lg">
<div className="text-2xl font-bold text-red-600">{status.stats.errors}</div>
<div className="text-sm text-slate-500">Fehler</div>
</div>
</div>
{status.last_error && (
<div className="mt-4 p-3 bg-red-50 rounded-lg text-sm text-red-700">
<strong>Letzter Fehler:</strong> {status.last_error}
</div>
)}
</div>
{/* Process Description */}
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h4 className="font-semibold text-slate-900 mb-4">Wie funktioniert der Scraper?</h4>
<div className="space-y-3 text-sm text-slate-600">
<div className="flex items-start gap-3">
<div className="w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold">1</div>
<div>
<strong>EUR-Lex Abruf</strong>: Holt HTML-Version der EU-Verordnung, extrahiert Artikel und Absaetze
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold">2</div>
<div>
<strong>BSI-TR Parsing</strong>: Extrahiert Pruefaspekte (O.Auth_1, O.Sess_1, etc.) aus den TR-Dokumenten
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold">3</div>
<div>
<strong>Datenbank-Speicherung</strong>: Jede Anforderung wird als Requirement in der Compliance-DB gespeichert
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 bg-green-100 rounded-full flex items-center justify-center text-green-600 font-bold"></div>
<div>
<strong>Audit-Workspace</strong>: Anforderungen koennen mit Implementierungsdetails angereichert werden
</div>
</div>
</div>
</div>
</div>
)}
{/* Results Tab */}
{activeTab === 'logs' && (
<div>
<h3 className="text-lg font-semibold text-slate-900 mb-4">Letzte Ergebnisse</h3>
{results.length === 0 ? (
<div className="text-center py-12 text-slate-500">
Keine Ergebnisse vorhanden. Starte einen Scrape-Vorgang.
</div>
) : (
<div className="space-y-2">
{results.map((result, idx) => (
<div
key={idx}
className={`p-3 rounded-lg flex items-center justify-between ${
result.error ? 'bg-red-50' :
result.reason ? 'bg-yellow-50' :
'bg-green-50'
}`}
>
<div className="flex items-center gap-3">
<span className="text-lg">
{result.error ? '❌' : result.reason ? '⏭️' : '✅'}
</span>
<span className="font-medium">{result.code}</span>
<span className="text-sm text-slate-500">
{result.error || result.reason || `${result.requirements_extracted} Anforderungen`}
</span>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</>
)}
{/* System Info Section */}
<div className="mt-8 border-t border-slate-200 pt-8">
<SystemInfoSection config={SYSTEM_INFO_CONFIGS.complianceScraper || {
title: 'Compliance Scraper',
description: 'Regulation & Requirements Extraction Service',
version: '1.0.0',
features: [
'EUR-Lex HTML Parsing',
'BSI-TR PDF Extraction',
'Automatic Requirement Mapping',
'Incremental Updates',
],
technicalDetails: {
'Backend': 'Python/FastAPI',
'HTTP Client': 'httpx async',
'HTML Parser': 'BeautifulSoup4',
'PDF Parser': 'PyMuPDF (optional)',
'Database': 'PostgreSQL',
},
}} />
</div>
</AdminLayout>
)
}
// Source Card Component
function SourceCard({
source,
onScrape,
scraping
}: {
source: Source
onScrape: (code: string, force: boolean) => void
scraping: boolean
}) {
const regType = regulationTypeBadge[source.regulation_type] || regulationTypeBadge.industry_standard
const srcType = sourceTypeBadge[source.source_type] || sourceTypeBadge.manual
return (
<div className="bg-white border border-slate-200 rounded-lg p-4 hover:shadow-sm transition-shadow">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">{regType.icon}</span>
<div>
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-900">{source.code}</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${regType.color}`}>
{regType.label}
</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${srcType.color}`}>
{srcType.label}
</span>
</div>
<div className="text-sm text-slate-500 truncate max-w-md" title={source.url}>
{source.url.length > 60 ? source.url.substring(0, 60) + '...' : source.url}
</div>
</div>
</div>
<div className="flex items-center gap-3">
{source.has_data ? (
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm font-medium">
{source.requirement_count} Anforderungen
</span>
) : (
<span className="px-3 py-1 bg-gray-100 text-gray-500 rounded-full text-sm">
Keine Daten
</span>
)}
<div className="flex gap-1">
<button
onClick={() => onScrape(source.code, false)}
disabled={scraping}
className="px-3 py-1.5 text-sm bg-slate-100 text-slate-700 rounded hover:bg-slate-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Scrapen (ueberspringt vorhandene)"
>
Scrapen
</button>
{source.has_data && (
<button
onClick={() => onScrape(source.code, true)}
disabled={scraping}
className="px-3 py-1.5 text-sm bg-orange-100 text-orange-700 rounded hover:bg-orange-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Force: Loescht vorhandene Daten und scraped neu"
>
Force
</button>
)}
</div>
</div>
</div>
</div>
)
}