[split-required] Split 700-870 LOC files across all services
backend-lehrer (11 files): - llm_gateway/routes/schools.py (867 → 5), recording_api.py (848 → 6) - messenger_api.py (840 → 5), print_generator.py (824 → 5) - unit_analytics_api.py (751 → 5), classroom/routes/context.py (726 → 4) - llm_gateway/routes/edu_search_seeds.py (710 → 4) klausur-service (12 files): - ocr_labeling_api.py (845 → 4), metrics_db.py (833 → 4) - legal_corpus_api.py (790 → 4), page_crop.py (758 → 3) - mail/ai_service.py (747 → 4), github_crawler.py (767 → 3) - trocr_service.py (730 → 4), full_compliance_pipeline.py (723 → 4) - dsfa_rag_api.py (715 → 4), ocr_pipeline_auto.py (705 → 4) website (6 pages): - audit-checklist (867 → 8), content (806 → 6) - screen-flow (790 → 4), scraper (789 → 5) - zeugnisse (776 → 5), modules (745 → 4) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import { AuditStatistics } from './types'
|
||||
import { Language } from '@/lib/compliance-i18n'
|
||||
|
||||
export default 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { AuditChecklistItem, RESULT_STATUS } from './types'
|
||||
import { Language } from '@/lib/compliance-i18n'
|
||||
|
||||
export default 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Regulation } from './types'
|
||||
|
||||
interface CreateSessionData {
|
||||
name: string
|
||||
description?: string
|
||||
auditor_name: string
|
||||
auditor_email?: string
|
||||
regulation_codes?: string[]
|
||||
}
|
||||
|
||||
export default function CreateSessionModal({
|
||||
regulations,
|
||||
onClose,
|
||||
onCreate,
|
||||
}: {
|
||||
regulations: Regulation[]
|
||||
onClose: () => void
|
||||
onCreate: (data: CreateSessionData) => 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import { AuditSession, SESSION_STATUS } from './types'
|
||||
|
||||
export default 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { AuditChecklistItem, RESULT_STATUS } from './types'
|
||||
|
||||
export default 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Language } from '@/lib/compliance-i18n'
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export interface AuditStatistics {
|
||||
total: number
|
||||
compliant: number
|
||||
compliant_with_notes: number
|
||||
non_compliant: number
|
||||
not_applicable: number
|
||||
pending: number
|
||||
completion_percentage: number
|
||||
}
|
||||
|
||||
export interface Regulation {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
requirement_count: number
|
||||
}
|
||||
|
||||
export 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: '−' },
|
||||
}
|
||||
|
||||
export 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' },
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
AuditSession,
|
||||
AuditChecklistItem,
|
||||
AuditStatistics,
|
||||
Regulation,
|
||||
} from './types'
|
||||
import { Language } from '@/lib/compliance-i18n'
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
export function useAuditChecklist() {
|
||||
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')
|
||||
|
||||
// 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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
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) {
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
// Create session
|
||||
const handleCreateSession = async (data: {
|
||||
name: string
|
||||
description?: string
|
||||
auditor_name: string
|
||||
auditor_email?: string
|
||||
regulation_codes?: string[]
|
||||
}) => {
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessions,
|
||||
selectedSession,
|
||||
setSelectedSession,
|
||||
checklistItems,
|
||||
filteredItems,
|
||||
statistics,
|
||||
regulations,
|
||||
loading,
|
||||
loadingChecklist,
|
||||
error,
|
||||
filterStatus,
|
||||
setFilterStatus,
|
||||
filterRegulation,
|
||||
setFilterRegulation,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
showCreateModal,
|
||||
setShowCreateModal,
|
||||
showSignOffModal,
|
||||
setShowSignOffModal,
|
||||
selectedItem,
|
||||
setSelectedItem,
|
||||
lang,
|
||||
handleSignOff,
|
||||
handleCreateSession,
|
||||
}
|
||||
}
|
||||
@@ -10,212 +10,29 @@
|
||||
* - 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' },
|
||||
}
|
||||
import { RESULT_STATUS, SESSION_STATUS } from './_components/types'
|
||||
import { useAuditChecklist } from './_components/useAuditChecklist'
|
||||
import SessionCard from './_components/SessionCard'
|
||||
import AuditProgressBar from './_components/AuditProgressBar'
|
||||
import ChecklistItemRow from './_components/ChecklistItemRow'
|
||||
import CreateSessionModal from './_components/CreateSessionModal'
|
||||
import SignOffModal from './_components/SignOffModal'
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
const {
|
||||
sessions, selectedSession, setSelectedSession,
|
||||
filteredItems, statistics, regulations,
|
||||
loading, loadingChecklist, error,
|
||||
filterStatus, setFilterStatus,
|
||||
filterRegulation, setFilterRegulation,
|
||||
searchQuery, setSearchQuery,
|
||||
showCreateModal, setShowCreateModal,
|
||||
showSignOffModal, setShowSignOffModal,
|
||||
selectedItem, setSelectedItem,
|
||||
lang, handleSignOff, handleCreateSession,
|
||||
} = useAuditChecklist()
|
||||
|
||||
// Session list view when no session is selected
|
||||
if (!selectedSession && !loading) {
|
||||
@@ -231,7 +48,6 @@ export default function AuditChecklistPage() {
|
||||
</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"
|
||||
@@ -244,12 +60,9 @@ export default function AuditChecklistPage() {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
<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">
|
||||
@@ -267,46 +80,22 @@ export default function AuditChecklistPage() {
|
||||
</div>
|
||||
) : (
|
||||
sessions.map(session => (
|
||||
<SessionCard
|
||||
key={session.id}
|
||||
session={session}
|
||||
onClick={() => setSelectedSession(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')
|
||||
}
|
||||
}}
|
||||
onCreate={handleCreateSession}
|
||||
/>
|
||||
)}
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<AdminLayout title="Audit Checkliste" description="Laden...">
|
||||
@@ -320,7 +109,6 @@ export default function AuditChecklistPage() {
|
||||
// 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)}
|
||||
@@ -331,7 +119,6 @@ export default function AuditChecklistPage() {
|
||||
</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}
|
||||
@@ -348,10 +135,7 @@ export default function AuditChecklistPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{statistics && (
|
||||
<AuditProgressBar statistics={statistics} lang={lang} />
|
||||
)}
|
||||
{statistics && <AuditProgressBar statistics={statistics} lang={lang} />}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4 mb-6">
|
||||
@@ -424,7 +208,6 @@ export default function AuditChecklistPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sign-off Modal */}
|
||||
{showSignOffModal && selectedItem && (
|
||||
<SignOffModal
|
||||
item={selectedItem}
|
||||
@@ -439,429 +222,3 @@ export default function AuditChecklistPage() {
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
ServiceModule, RiskAssessment,
|
||||
SERVICE_TYPE_CONFIG, CRITICALITY_CONFIG, RELEVANCE_CONFIG,
|
||||
} from './types'
|
||||
|
||||
interface ModuleDetailPanelProps {
|
||||
module: ServiceModule;
|
||||
loadingDetail: boolean;
|
||||
loadingRisk: boolean;
|
||||
showRiskPanel: boolean;
|
||||
riskAssessment: RiskAssessment | null;
|
||||
onClose: () => void;
|
||||
onAssessRisk: (moduleId: string) => void;
|
||||
onCloseRisk: () => void;
|
||||
}
|
||||
|
||||
export default function ModuleDetailPanel({
|
||||
module, loadingDetail, loadingRisk, showRiskPanel, riskAssessment,
|
||||
onClose, onAssessRisk, onCloseRisk,
|
||||
}: ModuleDetailPanelProps) {
|
||||
return (
|
||||
<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[module.service_type]?.bgColor || 'bg-gray-100'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-lg">{SERVICE_TYPE_CONFIG[module.service_type]?.icon || '📁'}</span>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">✕</button>
|
||||
</div>
|
||||
<h3 className="font-bold text-lg mt-2">{module.display_name}</h3>
|
||||
<div className="text-sm text-gray-600">{module.name}</div>
|
||||
</div>
|
||||
|
||||
{loadingDetail ? (
|
||||
<div className="p-4 text-center text-gray-500">Lade Details...</div>
|
||||
) : (
|
||||
<div className="p-4 space-y-4">
|
||||
{module.description && (<div><div className="text-xs text-gray-500 uppercase mb-1">Beschreibung</div><div className="text-sm text-gray-700">{module.description}</div></div>)}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
{module.port && (<div><span className="text-gray-500">Port:</span><span className="ml-1 font-mono">{module.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[module.criticality]?.bgColor || ''} ${CRITICALITY_CONFIG[module.criticality]?.color || ''}`}>{module.criticality}</span></div>
|
||||
</div>
|
||||
<div><div className="text-xs text-gray-500 uppercase mb-1">Tech Stack</div><div className="flex flex-wrap gap-1">{module.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>
|
||||
{module.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">{module.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>)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{module.processes_pii && (<span className="px-2 py-1 bg-purple-100 text-purple-700 text-xs rounded">Verarbeitet PII</span>)}
|
||||
{module.ai_components && (<span className="px-2 py-1 bg-pink-100 text-pink-700 text-xs rounded">AI-Komponenten</span>)}
|
||||
{module.processes_health_data && (<span className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded">Gesundheitsdaten</span>)}
|
||||
</div>
|
||||
{module.regulations && module.regulations.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase mb-2">Applicable Regulations ({module.regulations.length})</div>
|
||||
<div className="space-y-2">
|
||||
{module.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>
|
||||
)}
|
||||
{module.owner_team && (<div><div className="text-xs text-gray-500 uppercase mb-1">Owner</div><div className="text-sm text-gray-700">{module.owner_team}</div></div>)}
|
||||
{module.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">{module.repository_path}</code></div>)}
|
||||
<div className="pt-2 border-t">
|
||||
<button onClick={() => onAssessRisk(module.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>
|
||||
{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={onCloseRisk} 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">
|
||||
<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>
|
||||
{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>)}
|
||||
{riskAssessment.compliance_gaps.length > 0 && (<div><div className="text-xs text-gray-500 uppercase mb-1">Compliance-Luecken</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>)}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
71
website/app/admin/compliance/modules/_components/types.ts
Normal file
71
website/app/admin/compliance/modules/_components/types.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export 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;
|
||||
}>;
|
||||
}
|
||||
|
||||
export 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>;
|
||||
}
|
||||
|
||||
export interface RiskAssessment {
|
||||
overall_risk: string;
|
||||
risk_factors: Array<{ factor: string; severity: string; likelihood: string }>;
|
||||
recommendations: string[];
|
||||
compliance_gaps: string[];
|
||||
confidence_score: number;
|
||||
}
|
||||
|
||||
export 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' },
|
||||
};
|
||||
|
||||
export 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' },
|
||||
};
|
||||
|
||||
export 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' },
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ServiceModule, ModulesOverview, RiskAssessment } from './types'
|
||||
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
|
||||
const API_BASE = `${BACKEND_URL}/api/v1/compliance`
|
||||
|
||||
export function useModulesPage() {
|
||||
const [modules, setModules] = useState<ServiceModule[]>([])
|
||||
const [overview, setOverview] = useState<ModulesOverview | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
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('')
|
||||
|
||||
const [selectedModule, setSelectedModule] = useState<ServiceModule | null>(null)
|
||||
const [loadingDetail, setLoadingDetail] = useState(false)
|
||||
|
||||
const [riskAssessment, setRiskAssessment] = useState<RiskAssessment | null>(null)
|
||||
const [loadingRisk, setLoadingRisk] = useState(false)
|
||||
const [showRiskPanel, setShowRiskPanel] = useState(false)
|
||||
|
||||
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) }
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
|
||||
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 {
|
||||
modules, overview, loading, error,
|
||||
typeFilter, setTypeFilter, criticalityFilter, setCriticalityFilter,
|
||||
piiFilter, setPiiFilter, aiFilter, setAiFilter, searchTerm, setSearchTerm,
|
||||
selectedModule, setSelectedModule, loadingDetail,
|
||||
riskAssessment, loadingRisk, showRiskPanel, setShowRiskPanel,
|
||||
filteredModules, modulesByType,
|
||||
fetchModules, fetchModuleDetail, seedModules, assessModuleRisk,
|
||||
}
|
||||
}
|
||||
@@ -1,214 +1,11 @@
|
||||
'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' },
|
||||
};
|
||||
import { SERVICE_TYPE_CONFIG, CRITICALITY_CONFIG } from './_components/types';
|
||||
import { useModulesPage } from './_components/useModulesPage';
|
||||
import ModuleDetailPanel from './_components/ModuleDetailPanel';
|
||||
|
||||
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[]>);
|
||||
const mp = useModulesPage();
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
@@ -216,233 +13,72 @@ export default function ModulesPage() {
|
||||
<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>
|
||||
<p className="text-gray-600 mt-1">Alle {mp.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>
|
||||
<button onClick={() => mp.seedModules(false)} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">Seed Modules</button>
|
||||
<button onClick={() => mp.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 && (
|
||||
{mp.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 className="bg-white rounded-lg p-4 shadow border"><div className="text-3xl font-bold text-blue-600">{mp.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">{mp.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">{mp.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">{mp.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(mp.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">{mp.overview.average_compliance_score !== null ? `${mp.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><label className="block text-xs text-gray-500 mb-1">Service Type</label><select value={mp.typeFilter} onChange={(e) => mp.setTypeFilter(e.target.value)} className="border rounded px-3 py-2 text-sm"><option value="all">Alle Typen</option>{['backend','database','ai','communication','storage','infrastructure','monitoring','security'].map(t => (<option key={t} value={t}>{t.charAt(0).toUpperCase()+t.slice(1)}</option>))}</select></div>
|
||||
<div><label className="block text-xs text-gray-500 mb-1">Criticality</label><select value={mp.criticalityFilter} onChange={(e) => mp.setCriticalityFilter(e.target.value)} className="border rounded px-3 py-2 text-sm"><option value="all">Alle</option>{['critical','high','medium','low'].map(c => (<option key={c} value={c}>{c.charAt(0).toUpperCase()+c.slice(1)}</option>))}</select></div>
|
||||
<div><label className="block text-xs text-gray-500 mb-1">PII</label><select value={mp.piiFilter === null ? 'all' : String(mp.piiFilter)} onChange={(e) => { const val = e.target.value; mp.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={mp.aiFilter === null ? 'all' : String(mp.aiFilter)} onChange={(e) => { const val = e.target.value; mp.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={mp.searchTerm} onChange={(e) => mp.setSearchTerm(e.target.value)} className="border rounded px-3 py-2 text-sm w-full" /></div>
|
||||
<div className="pt-5"><button onClick={mp.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>
|
||||
)}
|
||||
{mp.error && (<div className="bg-red-50 border border-red-200 text-red-700 p-4 rounded-lg">{mp.error}</div>)}
|
||||
{mp.loading && (<div className="text-center py-12 text-gray-500">Lade Module...</div>)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Lade Module...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content - Two Column Layout */}
|
||||
{!loading && (
|
||||
{/* Main Content */}
|
||||
{!mp.loading && (
|
||||
<div className="flex gap-6">
|
||||
{/* Module List */}
|
||||
<div className="flex-1 space-y-4">
|
||||
{Object.entries(modulesByType).map(([type, typeModules]) => (
|
||||
{Object.entries(mp.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={`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' : ''
|
||||
}`}
|
||||
>
|
||||
{typeModules.map((mod) => (
|
||||
<div key={mod.id} onClick={() => mp.fetchModuleDetail(mod.name)} className={`p-4 cursor-pointer hover:bg-gray-50 transition ${mp.selectedModule?.id === mod.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 items-center gap-2"><span className="font-medium text-gray-900">{mod.display_name}</span>{mod.port && (<span className="text-xs text-gray-400">:{mod.port}</span>)}</div>
|
||||
<div className="text-sm text-gray-500 mt-1">{mod.name}</div>
|
||||
{mod.description && (<div className="text-sm text-gray-600 mt-1 line-clamp-2">{mod.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>
|
||||
)}
|
||||
{mod.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>))}
|
||||
{mod.technology_stack.length > 4 && (<span className="px-2 py-0.5 text-gray-400 text-xs">+{mod.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>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${CRITICALITY_CONFIG[mod.criticality]?.bgColor || 'bg-gray-100'} ${CRITICALITY_CONFIG[mod.criticality]?.color || 'text-gray-700'}`}>{mod.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
|
||||
{mod.processes_pii && (<span className="px-1.5 py-0.5 bg-purple-100 text-purple-700 text-xs rounded" title="Verarbeitet PII">PII</span>)}
|
||||
{mod.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">{mod.regulation_count} Regulations</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -450,293 +86,34 @@ export default function ModulesPage() {
|
||||
</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>
|
||||
{mp.filteredModules.length === 0 && !mp.loading && (
|
||||
<div className="text-center py-12 text-gray-500 bg-white rounded-lg shadow border">Keine Module gefunden.<button onClick={() => mp.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>
|
||||
{mp.selectedModule && (
|
||||
<ModuleDetailPanel
|
||||
module={mp.selectedModule}
|
||||
loadingDetail={mp.loadingDetail}
|
||||
loadingRisk={mp.loadingRisk}
|
||||
showRiskPanel={mp.showRiskPanel}
|
||||
riskAssessment={mp.riskAssessment}
|
||||
onClose={() => mp.setSelectedModule(null)}
|
||||
onAssessRisk={mp.assessModuleRisk}
|
||||
onCloseRisk={() => mp.setShowRiskPanel(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Regulations Coverage Overview */}
|
||||
{overview && overview.regulations_coverage && Object.keys(overview.regulations_coverage).length > 0 && (
|
||||
{/* Regulations Coverage */}
|
||||
{mp.overview && mp.overview.regulations_coverage && Object.keys(mp.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>
|
||||
))}
|
||||
{Object.entries(mp.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>
|
||||
)}
|
||||
|
||||
162
website/app/admin/compliance/scraper/_components/ScraperTabs.tsx
Normal file
162
website/app/admin/compliance/scraper/_components/ScraperTabs.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Source, ScraperStatus, ScrapeResult,
|
||||
PDFDocument, PDFExtractionResult,
|
||||
} from './types'
|
||||
import SourceCard from './SourceCard'
|
||||
|
||||
interface ScraperTabsProps {
|
||||
activeTab: string
|
||||
sources: Source[]
|
||||
pdfDocuments: PDFDocument[]
|
||||
status: ScraperStatus | null
|
||||
scraping: boolean
|
||||
extracting: boolean
|
||||
results: ScrapeResult[]
|
||||
pdfResult: PDFExtractionResult | null
|
||||
handleScrapeAll: () => void
|
||||
handleScrapeSingle: (code: string, force: boolean) => void
|
||||
handleExtractPdf: (code: string, saveToDb: boolean, force: boolean) => void
|
||||
}
|
||||
|
||||
export default function ScraperTabs(props: ScraperTabsProps) {
|
||||
const { activeTab, sources, pdfDocuments, status, scraping, extracting, results, pdfResult } = props
|
||||
|
||||
if (activeTab === 'sources') {
|
||||
return (
|
||||
<div>
|
||||
<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={props.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>
|
||||
<div className="space-y-6">
|
||||
<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={props.handleScrapeSingle} scraping={scraping} />))}</div>
|
||||
</div>
|
||||
<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={props.handleScrapeSingle} scraping={scraping} />))}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (activeTab === 'pdf') {
|
||||
return (
|
||||
<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>
|
||||
<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={() => props.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={() => props.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>
|
||||
{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>
|
||||
{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>
|
||||
)}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
if (activeTab === 'status' && status) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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>
|
||||
<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">
|
||||
{[{ n: '1', t: 'EUR-Lex Abruf', d: 'Holt HTML-Version der EU-Verordnung, extrahiert Artikel und Absaetze' }, { n: '2', t: 'BSI-TR Parsing', d: 'Extrahiert Pruefaspekte (O.Auth_1, O.Sess_1, etc.) aus den TR-Dokumenten' }, { n: '3', t: 'Datenbank-Speicherung', d: 'Jede Anforderung wird als Requirement in der Compliance-DB gespeichert' }].map(s => (
|
||||
<div key={s.n} 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">{s.n}</div><div><strong>{s.t}</strong>: {s.d}</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>
|
||||
)
|
||||
}
|
||||
|
||||
// logs tab
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { Source, regulationTypeBadge, sourceTypeBadge } from './types'
|
||||
|
||||
export default 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>
|
||||
)
|
||||
}
|
||||
65
website/app/admin/compliance/scraper/_components/types.ts
Normal file
65
website/app/admin/compliance/scraper/_components/types.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export interface Source {
|
||||
code: string
|
||||
url: string
|
||||
source_type: string
|
||||
regulation_type: string
|
||||
has_data: boolean
|
||||
requirement_count: number
|
||||
}
|
||||
|
||||
export 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[]
|
||||
}
|
||||
|
||||
export interface ScrapeResult {
|
||||
code: string
|
||||
status: string
|
||||
requirements_extracted?: number
|
||||
reason?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface PDFDocument {
|
||||
code: string
|
||||
name: string
|
||||
description: string
|
||||
expected_aspects: string
|
||||
available: boolean
|
||||
}
|
||||
|
||||
export 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>
|
||||
}
|
||||
}
|
||||
|
||||
export const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
export 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' },
|
||||
}
|
||||
|
||||
export 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: '📋' },
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Source, ScraperStatus, ScrapeResult,
|
||||
PDFDocument, PDFExtractionResult, BACKEND_URL,
|
||||
} from './types'
|
||||
|
||||
export function useComplianceScraper() {
|
||||
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)
|
||||
|
||||
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) }
|
||||
}, [])
|
||||
|
||||
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) }
|
||||
}, [])
|
||||
|
||||
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) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
await Promise.all([fetchSources(), fetchStatus(), fetchPdfDocuments()])
|
||||
setLoading(false)
|
||||
}
|
||||
loadData()
|
||||
}, [fetchSources, fetchStatus, fetchPdfDocuments])
|
||||
|
||||
useEffect(() => {
|
||||
if (scraping) { const interval = setInterval(fetchStatus, 2000); return () => clearInterval(interval) }
|
||||
}, [scraping, fetchStatus])
|
||||
|
||||
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`)
|
||||
await fetchSources()
|
||||
} catch (err: any) { setError(err.message) }
|
||||
finally { setScraping(false) }
|
||||
}
|
||||
|
||||
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`) }
|
||||
await fetchSources()
|
||||
} catch (err: any) { setError(err.message) }
|
||||
finally { setScraping(false) }
|
||||
}
|
||||
|
||||
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 }),
|
||||
})
|
||||
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`) }
|
||||
await fetchSources()
|
||||
} catch (err: any) { setError(err.message) }
|
||||
finally { setExtracting(false) }
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
return {
|
||||
activeTab, setActiveTab, sources, pdfDocuments, status,
|
||||
loading, scraping, extracting, error, success, results, pdfResult,
|
||||
handleScrapeAll, handleScrapeSingle, handleExtractPdf,
|
||||
}
|
||||
}
|
||||
@@ -7,283 +7,20 @@
|
||||
* - 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: '📋' },
|
||||
}
|
||||
import { useComplianceScraper } from './_components/useComplianceScraper'
|
||||
import ScraperTabs from './_components/ScraperTabs'
|
||||
|
||||
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)
|
||||
const scraper = useComplianceScraper()
|
||||
|
||||
// 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="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>
|
||||
@@ -294,12 +31,8 @@ export default function ComplianceScraperPage() {
|
||||
)
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title="Compliance Scraper"
|
||||
description="Extrahiert Anforderungen aus EU-Regulierungen, BSI-Standards und Gesetzen"
|
||||
>
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<AdminLayout title="Compliance Scraper" description="Extrahiert Anforderungen aus EU-Regulierungen, BSI-Standards und Gesetzen">
|
||||
{scraper.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" />
|
||||
@@ -309,481 +42,77 @@ export default function ComplianceScraperPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
{!scraper.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>
|
||||
{scraper.error && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">{scraper.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>
|
||||
{scraper.success && (
|
||||
<div className="mb-4 bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg">{scraper.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="🕐"
|
||||
/>
|
||||
<StatsCard title="Bekannte Quellen" value={scraper.sources.length} icon="📚" />
|
||||
<StatsCard title="Mit Daten" value={scraper.sources.filter(s => s.has_data).length} subtitle={`${scraper.sources.length - scraper.sources.filter(s => s.has_data).length} noch zu scrapen`} icon="✅" />
|
||||
<StatsCard title="Anforderungen gesamt" value={scraper.sources.reduce((acc, s) => acc + s.requirement_count, 0)} icon="📋" />
|
||||
<StatsCard title="Letzter Lauf" value={scraper.status?.stats.last_run ? new Date(scraper.status.stats.last_run).toLocaleDateString('de-DE') : 'Nie'} subtitle={scraper.status?.stats.errors ? `${scraper.status.stats.errors} Fehler` : undefined} icon="🕐" />
|
||||
</div>
|
||||
|
||||
{/* Scraper Status Bar */}
|
||||
{(scraping || status?.status === 'running') && (
|
||||
{(scraper.scraping || scraper.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>
|
||||
)}
|
||||
{scraper.status?.current_source && (<p className="text-sm text-blue-600">Aktuell: {scraper.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: '📝' },
|
||||
{ id: 'sources' as const, name: 'Quellen', icon: '📚' },
|
||||
{ id: 'pdf' as const, name: 'PDF-Extraktion', icon: '📄' },
|
||||
{ id: 'status' as const, name: 'Status', icon: '📊' },
|
||||
{ id: 'logs' as const, 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 key={tab.id} onClick={() => scraper.setActiveTab(tab.id)} className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${scraper.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>
|
||||
)}
|
||||
<ScraperTabs
|
||||
activeTab={scraper.activeTab}
|
||||
sources={scraper.sources}
|
||||
pdfDocuments={scraper.pdfDocuments}
|
||||
status={scraper.status}
|
||||
scraping={scraper.scraping}
|
||||
extracting={scraper.extracting}
|
||||
results={scraper.results}
|
||||
pdfResult={scraper.pdfResult}
|
||||
handleScrapeAll={scraper.handleScrapeAll}
|
||||
handleScrapeSingle={scraper.handleScrapeSingle}
|
||||
handleExtractPdf={scraper.handleExtractPdf}
|
||||
/>
|
||||
</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',
|
||||
},
|
||||
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