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:
867
website/app/admin/compliance/audit-checklist/page.tsx
Normal file
867
website/app/admin/compliance/audit-checklist/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
871
website/app/admin/compliance/audit-workspace/page.tsx
Normal file
871
website/app/admin/compliance/audit-workspace/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
387
website/app/admin/compliance/controls/page.tsx
Normal file
387
website/app/admin/compliance/controls/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
522
website/app/admin/compliance/evidence/page.tsx
Normal file
522
website/app/admin/compliance/evidence/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
502
website/app/admin/compliance/export/page.tsx
Normal file
502
website/app/admin/compliance/export/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
745
website/app/admin/compliance/modules/page.tsx
Normal file
745
website/app/admin/compliance/modules/page.tsx
Normal 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 "AI Risikobewertung" 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>
|
||||
);
|
||||
}
|
||||
413
website/app/admin/compliance/my-tasks/page.tsx
Normal file
413
website/app/admin/compliance/my-tasks/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1519
website/app/admin/compliance/page.tsx
Normal file
1519
website/app/admin/compliance/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
622
website/app/admin/compliance/risks/page.tsx
Normal file
622
website/app/admin/compliance/risks/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
384
website/app/admin/compliance/role-select/page.tsx
Normal file
384
website/app/admin/compliance/role-select/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
789
website/app/admin/compliance/scraper/page.tsx
Normal file
789
website/app/admin/compliance/scraper/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user