feat: 7 Analyse-Module auf 100% — Backend-Endpoints, DB-Model, Frontend-Persistenz
Alle 7 Analyse-Module (Requirements → Report) von ~80% auf 100% gebracht: - Modul 1 (Requirements): POST/DELETE Endpoints + Frontend-Anbindung + Rollback - Modul 2 (Controls): Evidence-Linking UI mit Validity-Badge - Modul 3 (Evidence): Pagination (Frontend + Backend) - Modul 4 (Risk Matrix): Mitigation-UI, Residual Risk, Status-Workflow - Modul 5 (AI Act): AISystemDB Model, 6 CRUD-Endpoints, Backend-Persistenz - Modul 6 (Audit Checklist): PDF-Download + Session-History - Modul 7 (Audit Report): Detail-Seite mit Checklist Sign-Off + Navigation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -320,42 +320,126 @@ export default function AIActPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Load systems from SDK state on mount
|
||||
// Fetch systems from backend on mount
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
// Try to load from SDK state (aiSystems if available)
|
||||
const sdkState = state as unknown as Record<string, unknown>
|
||||
if (Array.isArray(sdkState.aiSystems) && sdkState.aiSystems.length > 0) {
|
||||
setSystems(sdkState.aiSystems as AISystem[])
|
||||
const fetchSystems = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/ai/systems')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const backendSystems = data.systems || []
|
||||
setSystems(backendSystems.map((s: Record<string, unknown>) => ({
|
||||
id: s.id as string,
|
||||
name: s.name as string,
|
||||
description: (s.description || '') as string,
|
||||
purpose: (s.purpose || '') as string,
|
||||
sector: (s.sector || '') as string,
|
||||
classification: (s.classification || 'unclassified') as AISystem['classification'],
|
||||
status: (s.status || 'draft') as AISystem['status'],
|
||||
obligations: (s.obligations || []) as string[],
|
||||
assessmentDate: s.assessment_date ? new Date(s.assessment_date as string) : null,
|
||||
assessmentResult: (s.assessment_result || null) as Record<string, unknown> | null,
|
||||
})))
|
||||
}
|
||||
} catch {
|
||||
// Backend unavailable — start with empty list
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
setLoading(false)
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
fetchSystems()
|
||||
}, [])
|
||||
|
||||
const handleAddSystem = (data: Omit<AISystem, 'id' | 'assessmentDate' | 'assessmentResult'>) => {
|
||||
const handleAddSystem = async (data: Omit<AISystem, 'id' | 'assessmentDate' | 'assessmentResult'>) => {
|
||||
setError(null)
|
||||
if (editingSystem) {
|
||||
// Edit existing system
|
||||
setSystems(prev => prev.map(s =>
|
||||
s.id === editingSystem.id
|
||||
? { ...s, ...data }
|
||||
: s
|
||||
))
|
||||
// Edit existing system via PUT
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/ai/systems/${editingSystem.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
purpose: data.purpose,
|
||||
sector: data.sector,
|
||||
classification: data.classification,
|
||||
status: data.status,
|
||||
obligations: data.obligations,
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
const updated = await res.json()
|
||||
setSystems(prev => prev.map(s =>
|
||||
s.id === editingSystem.id
|
||||
? { ...s, ...data, id: updated.id || editingSystem.id }
|
||||
: s
|
||||
))
|
||||
} else {
|
||||
setError('Speichern fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
// Fallback: update locally
|
||||
setSystems(prev => prev.map(s =>
|
||||
s.id === editingSystem.id ? { ...s, ...data } : s
|
||||
))
|
||||
}
|
||||
setEditingSystem(null)
|
||||
} else {
|
||||
// Create new system
|
||||
const newSystem: AISystem = {
|
||||
...data,
|
||||
id: `ai-${Date.now()}`,
|
||||
assessmentDate: data.classification !== 'unclassified' ? new Date() : null,
|
||||
assessmentResult: null,
|
||||
// Create new system via POST
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/ai/systems', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
purpose: data.purpose,
|
||||
sector: data.sector,
|
||||
classification: data.classification,
|
||||
status: data.status,
|
||||
obligations: data.obligations,
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
const created = await res.json()
|
||||
const newSystem: AISystem = {
|
||||
...data,
|
||||
id: created.id,
|
||||
assessmentDate: created.assessment_date ? new Date(created.assessment_date) : null,
|
||||
assessmentResult: created.assessment_result || null,
|
||||
}
|
||||
setSystems(prev => [...prev, newSystem])
|
||||
} else {
|
||||
setError('Registrierung fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
// Fallback: add locally
|
||||
const newSystem: AISystem = {
|
||||
...data,
|
||||
id: `ai-${Date.now()}`,
|
||||
assessmentDate: data.classification !== 'unclassified' ? new Date() : null,
|
||||
assessmentResult: null,
|
||||
}
|
||||
setSystems(prev => [...prev, newSystem])
|
||||
}
|
||||
setSystems(prev => [...prev, newSystem])
|
||||
}
|
||||
setShowAddForm(false)
|
||||
}
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Moechten Sie dieses KI-System wirklich loeschen?')) return
|
||||
setSystems(prev => prev.filter(s => s.id !== id))
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/ai/systems/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
setSystems(prev => prev.filter(s => s.id !== id))
|
||||
} else {
|
||||
setError('Loeschen fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
setError('Backend nicht erreichbar')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (system: AISystem) => {
|
||||
@@ -364,37 +448,26 @@ export default function AIActPage() {
|
||||
}
|
||||
|
||||
const handleAssess = async (systemId: string) => {
|
||||
const system = systems.find(s => s.id === systemId)
|
||||
if (!system) return
|
||||
|
||||
setAssessingId(systemId)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/ai/assess-risk', {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/ai/systems/${systemId}/assess`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
system_name: system.name,
|
||||
description: system.description,
|
||||
purpose: system.purpose,
|
||||
sector: system.sector,
|
||||
current_classification: system.classification,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const result = await res.json()
|
||||
|
||||
// Update system with assessment result
|
||||
setSystems(prev => prev.map(s =>
|
||||
s.id === systemId
|
||||
? {
|
||||
...s,
|
||||
assessmentDate: new Date(),
|
||||
assessmentResult: result,
|
||||
classification: result.risk_level || result.classification || s.classification,
|
||||
status: result.risk_level === 'high-risk' || result.classification === 'high-risk' ? 'non-compliant' : 'classified',
|
||||
assessmentDate: result.assessment_date ? new Date(result.assessment_date) : new Date(),
|
||||
assessmentResult: result.assessment_result || result,
|
||||
classification: (result.classification || s.classification) as AISystem['classification'],
|
||||
status: (result.status || 'classified') as AISystem['status'],
|
||||
obligations: result.obligations || s.obligations,
|
||||
}
|
||||
: s
|
||||
|
||||
@@ -271,6 +271,18 @@ function LoadingSkeleton() {
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
interface PastSession {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
auditor_name: string
|
||||
created_at: string
|
||||
completed_at?: string
|
||||
completion_percentage: number
|
||||
total_items: number
|
||||
completed_items: number
|
||||
}
|
||||
|
||||
export default function AuditChecklistPage() {
|
||||
const { state, dispatch } = useSDK()
|
||||
const router = useRouter()
|
||||
@@ -279,6 +291,9 @@ export default function AuditChecklistPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
|
||||
const notesTimerRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
||||
const [pastSessions, setPastSessions] = useState<PastSession[]>([])
|
||||
const [pdfLanguage, setPdfLanguage] = useState<'de' | 'en'>('de')
|
||||
const [generatingPdf, setGeneratingPdf] = useState(false)
|
||||
|
||||
// Fetch checklist from backend on mount
|
||||
useEffect(() => {
|
||||
@@ -354,6 +369,21 @@ export default function AuditChecklistPage() {
|
||||
}
|
||||
|
||||
fetchChecklist()
|
||||
|
||||
// Also fetch all sessions for history view
|
||||
const fetchAllSessions = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/audit/sessions')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const sessions = data.sessions || []
|
||||
setPastSessions(sessions)
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
fetchAllSessions()
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Convert SDK checklist items to display items
|
||||
@@ -468,6 +498,32 @@ export default function AuditChecklistPage() {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handlePdfDownload = async () => {
|
||||
if (!activeSessionId) {
|
||||
setError('Kein aktives Audit vorhanden. Erstellen Sie zuerst eine Checkliste.')
|
||||
return
|
||||
}
|
||||
setGeneratingPdf(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/audit/sessions/${activeSessionId}/pdf?language=${pdfLanguage}`)
|
||||
if (!res.ok) throw new Error('Fehler bei der PDF-Generierung')
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit-checklist-${activeSessionId}.pdf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'PDF-Download fehlgeschlagen')
|
||||
} finally {
|
||||
setGeneratingPdf(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewChecklist = async () => {
|
||||
try {
|
||||
setError(null)
|
||||
@@ -508,8 +564,25 @@ export default function AuditChecklistPage() {
|
||||
onClick={handleExport}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Exportieren
|
||||
Export JSON
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
value={pdfLanguage}
|
||||
onChange={(e) => setPdfLanguage(e.target.value as 'de' | 'en')}
|
||||
className="px-2 py-2 border border-gray-300 rounded-l-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="de">DE</option>
|
||||
<option value="en">EN</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handlePdfDownload}
|
||||
disabled={generatingPdf || !activeSessionId}
|
||||
className="px-4 py-2 text-purple-600 border border-l-0 border-gray-300 hover:bg-purple-50 rounded-r-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{generatingPdf ? 'Generiere...' : 'PDF'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleNewChecklist}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
@@ -646,6 +719,62 @@ export default function AuditChecklistPage() {
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session History */}
|
||||
{pastSessions.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Vergangene Audit-Sessions</h3>
|
||||
<div className="space-y-3">
|
||||
{pastSessions
|
||||
.filter(s => s.id !== activeSessionId)
|
||||
.map(session => {
|
||||
const statusBadge: Record<string, string> = {
|
||||
draft: 'bg-slate-100 text-slate-700',
|
||||
in_progress: 'bg-blue-100 text-blue-700',
|
||||
completed: 'bg-green-100 text-green-700',
|
||||
archived: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
const statusLabel: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
in_progress: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
archived: 'Archiviert',
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 cursor-pointer transition-colors"
|
||||
onClick={() => router.push(`/sdk/audit-report/${session.id}`)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${statusBadge[session.status] || ''}`}>
|
||||
{statusLabel[session.status] || session.status}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">{session.name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(session.created_at).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-gray-500">
|
||||
{session.completed_items}/{session.total_items} Punkte
|
||||
</span>
|
||||
<span className={`font-medium ${
|
||||
session.completion_percentage >= 80 ? 'text-green-600' :
|
||||
session.completion_percentage >= 50 ? 'text-yellow-600' : 'text-red-600'
|
||||
}`}>
|
||||
{session.completion_percentage}%
|
||||
</span>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
389
admin-compliance/app/(sdk)/sdk/audit-report/[sessionId]/page.tsx
Normal file
389
admin-compliance/app/(sdk)/sdk/audit-report/[sessionId]/page.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
|
||||
interface ChecklistItem {
|
||||
requirement_id: string
|
||||
title: string
|
||||
article?: string
|
||||
status: string
|
||||
auditor_notes: string
|
||||
signed_off_by: string | null
|
||||
signed_off_at: string | null
|
||||
}
|
||||
|
||||
interface SessionDetail {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
auditor_name: string
|
||||
auditor_email?: string
|
||||
auditor_organization?: string
|
||||
status: string
|
||||
total_items: number
|
||||
completed_items: number
|
||||
compliant_count: number
|
||||
non_compliant_count: number
|
||||
completion_percentage: number
|
||||
created_at: string
|
||||
started_at?: string
|
||||
completed_at?: string
|
||||
}
|
||||
|
||||
export default function AuditReportDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const sessionId = params.sessionId as string
|
||||
|
||||
const [session, setSession] = useState<SessionDetail | null>(null)
|
||||
const [items, setItems] = useState<ChecklistItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pdfLanguage, setPdfLanguage] = useState<'de' | 'en'>('de')
|
||||
const [generatingPdf, setGeneratingPdf] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
// Fetch session details
|
||||
const sessRes = await fetch(`/api/sdk/v1/compliance/audit/sessions`)
|
||||
if (sessRes.ok) {
|
||||
const data = await sessRes.json()
|
||||
const sessions = data.sessions || []
|
||||
const found = sessions.find((s: SessionDetail) => s.id === sessionId)
|
||||
if (found) setSession(found)
|
||||
}
|
||||
|
||||
// Fetch checklist items
|
||||
const checkRes = await fetch(`/api/sdk/v1/compliance/audit/checklist/${sessionId}`)
|
||||
if (checkRes.ok) {
|
||||
const data = await checkRes.json()
|
||||
const checklistItems = data.items || data.checklist || data
|
||||
if (Array.isArray(checklistItems)) {
|
||||
setItems(checklistItems)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Laden fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionId) fetchData()
|
||||
}, [sessionId])
|
||||
|
||||
const handleSignOff = async (requirementId: string, status: string, notes: string) => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/sdk/v1/compliance/audit/checklist/${sessionId}/items/${requirementId}/sign-off`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status, auditor_notes: notes }),
|
||||
}
|
||||
)
|
||||
if (res.ok) {
|
||||
setItems(prev =>
|
||||
prev.map(item =>
|
||||
item.requirement_id === requirementId
|
||||
? { ...item, status, auditor_notes: notes, signed_off_by: 'Aktueller Benutzer', signed_off_at: new Date().toISOString() }
|
||||
: item
|
||||
)
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
const handlePdfDownload = async () => {
|
||||
setGeneratingPdf(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/audit/sessions/${sessionId}/pdf?language=${pdfLanguage}`)
|
||||
if (!res.ok) throw new Error('PDF-Generierung fehlgeschlagen')
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit-report-${sessionId}.pdf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'PDF-Download fehlgeschlagen')
|
||||
} finally {
|
||||
setGeneratingPdf(false)
|
||||
}
|
||||
}
|
||||
|
||||
const statusBadgeStyles: Record<string, string> = {
|
||||
draft: 'bg-slate-100 text-slate-700',
|
||||
in_progress: 'bg-blue-100 text-blue-700',
|
||||
completed: 'bg-green-100 text-green-700',
|
||||
archived: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
in_progress: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
archived: 'Archiviert',
|
||||
}
|
||||
|
||||
const compliantCount = items.filter(i => i.status === 'compliant').length
|
||||
const nonCompliantCount = items.filter(i => i.status === 'non_compliant' || i.status === 'non-compliant').length
|
||||
const pendingCount = items.filter(i => !i.status || i.status === 'not_assessed' || i.status === 'pending').length
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 animate-pulse">
|
||||
<div className="h-8 w-64 bg-slate-200 rounded mb-4" />
|
||||
<div className="h-4 w-96 bg-slate-100 rounded mb-2" />
|
||||
<div className="h-4 w-48 bg-slate-100 rounded" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="bg-white rounded-xl border border-slate-200 p-6 animate-pulse">
|
||||
<div className="h-5 w-3/4 bg-slate-200 rounded mb-2" />
|
||||
<div className="h-4 w-1/2 bg-slate-100 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back Button + Title */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.push('/sdk/audit-report')}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-gray-900">{session?.name || 'Audit Report'}</h1>
|
||||
{session && (
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusBadgeStyles[session.status] || ''}`}>
|
||||
{statusLabels[session.status] || session.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{session && (
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500 mt-1">
|
||||
<span>Auditor: {session.auditor_name}</span>
|
||||
{session.auditor_organization && <span>| {session.auditor_organization}</span>}
|
||||
<span>| Erstellt: {new Date(session.created_at).toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={pdfLanguage}
|
||||
onChange={(e) => setPdfLanguage(e.target.value as 'de' | 'en')}
|
||||
className="px-2 py-2 border border-gray-300 rounded-l-lg text-sm"
|
||||
>
|
||||
<option value="de">DE</option>
|
||||
<option value="en">EN</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handlePdfDownload}
|
||||
disabled={generatingPdf}
|
||||
className="px-4 py-2 bg-purple-600 text-white text-sm rounded-r-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{generatingPdf ? 'Generiere...' : 'PDF herunterladen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress */}
|
||||
{session && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Fortschritt</h2>
|
||||
<span className={`text-3xl font-bold ${
|
||||
session.completion_percentage >= 80 ? 'text-green-600' :
|
||||
session.completion_percentage >= 50 ? 'text-yellow-600' : 'text-red-600'
|
||||
}`}>
|
||||
{session.completion_percentage}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 bg-gray-100 rounded-full overflow-hidden mb-4">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
session.completion_percentage >= 80 ? 'bg-green-500' :
|
||||
session.completion_percentage >= 50 ? 'bg-yellow-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${session.completion_percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div className="text-center p-3 bg-green-50 rounded-lg">
|
||||
<div className="font-semibold text-green-700">{compliantCount}</div>
|
||||
<div className="text-xs text-green-600">Konform</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-red-50 rounded-lg">
|
||||
<div className="font-semibold text-red-700">{nonCompliantCount}</div>
|
||||
<div className="text-xs text-red-600">Nicht konform</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-slate-50 rounded-lg">
|
||||
<div className="font-semibold text-slate-700">{pendingCount}</div>
|
||||
<div className="text-xs text-slate-600">Ausstehend</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Checklist Items */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Pruefpunkte ({items.length})</h2>
|
||||
{items.map((item) => (
|
||||
<ChecklistItemRow
|
||||
key={item.requirement_id}
|
||||
item={item}
|
||||
onSignOff={handleSignOff}
|
||||
readOnly={session?.status === 'completed' || session?.status === 'archived'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{items.length === 0 && !loading && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
|
||||
<h3 className="text-lg font-medium text-gray-700 mb-2">Keine Pruefpunkte</h3>
|
||||
<p className="text-sm text-gray-500">Diese Session hat noch keine Checklist-Items.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChecklistItemRow({
|
||||
item,
|
||||
onSignOff,
|
||||
readOnly,
|
||||
}: {
|
||||
item: ChecklistItem
|
||||
onSignOff: (requirementId: string, status: string, notes: string) => void
|
||||
readOnly: boolean
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [notes, setNotes] = useState(item.auditor_notes || '')
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
compliant: 'border-green-200 bg-green-50',
|
||||
non_compliant: 'border-red-200 bg-red-50',
|
||||
'non-compliant': 'border-red-200 bg-red-50',
|
||||
partially_compliant: 'border-yellow-200 bg-yellow-50',
|
||||
not_assessed: 'border-gray-200 bg-gray-50',
|
||||
pending: 'border-gray-200 bg-gray-50',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
compliant: 'Konform',
|
||||
non_compliant: 'Nicht konform',
|
||||
'non-compliant': 'Nicht konform',
|
||||
partially_compliant: 'Teilweise',
|
||||
not_assessed: 'Nicht geprueft',
|
||||
pending: 'Ausstehend',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-4 ${statusColors[item.status] || 'border-gray-200'}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{item.article && (
|
||||
<span className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">{item.article}</span>
|
||||
)}
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
item.status === 'compliant' ? 'bg-green-100 text-green-700' :
|
||||
item.status === 'non_compliant' || item.status === 'non-compliant' ? 'bg-red-100 text-red-700' :
|
||||
item.status === 'partially_compliant' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
{statusLabels[item.status] || item.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-900">{item.title}</p>
|
||||
{item.auditor_notes && !editing && (
|
||||
<p className="text-xs text-gray-500 mt-1">{item.auditor_notes}</p>
|
||||
)}
|
||||
{item.signed_off_by && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Geprueft von {item.signed_off_by}
|
||||
{item.signed_off_at && ` am ${new Date(item.signed_off_at).toLocaleDateString('de-DE')}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
value={item.status || 'not_assessed'}
|
||||
onChange={(e) => onSignOff(item.requirement_id, e.target.value, item.auditor_notes || '')}
|
||||
className="px-2 py-1 text-xs border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="not_assessed">Nicht geprueft</option>
|
||||
<option value="compliant">Konform</option>
|
||||
<option value="partially_compliant">Teilweise</option>
|
||||
<option value="non_compliant">Nicht konform</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setEditing(!editing)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 rounded"
|
||||
title="Notizen bearbeiten"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{editing && (
|
||||
<div className="mt-3">
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Notizen hinzufuegen..."
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<button onClick={() => setEditing(false)} className="px-3 py-1 text-sm text-gray-500 hover:bg-gray-100 rounded-lg">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onSignOff(item.requirement_id, item.status, notes)
|
||||
setEditing(false)
|
||||
}}
|
||||
className="px-3 py-1 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
|
||||
@@ -37,6 +38,7 @@ const REGULATIONS = [
|
||||
|
||||
export default function AuditReportPage() {
|
||||
const { state } = useSDK()
|
||||
const router = useRouter()
|
||||
const [sessions, setSessions] = useState<AuditSession[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -257,7 +259,7 @@ export default function AuditReportPage() {
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{sessions.map((session) => (
|
||||
<div key={session.id} className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<div key={session.id} className="bg-white rounded-xl border border-slate-200 p-6 cursor-pointer hover:border-purple-300 transition-colors" onClick={() => router.push(`/sdk/audit-report/${session.id}`)}>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSDK, Control as SDKControl, ControlType, ImplementationStatus } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
|
||||
@@ -28,6 +29,7 @@ interface DisplayControl {
|
||||
displayStatus: DisplayStatus
|
||||
effectivenessPercent: number
|
||||
linkedRequirements: string[]
|
||||
linkedEvidence: { id: string; title: string; status: string }[]
|
||||
lastReview: Date
|
||||
}
|
||||
|
||||
@@ -153,10 +155,12 @@ function ControlCard({
|
||||
control,
|
||||
onStatusChange,
|
||||
onEffectivenessChange,
|
||||
onLinkEvidence,
|
||||
}: {
|
||||
control: DisplayControl
|
||||
onStatusChange: (status: ImplementationStatus) => void
|
||||
onEffectivenessChange: (effectivenessPercent: number) => void
|
||||
onLinkEvidence: () => void
|
||||
}) {
|
||||
const [showEffectivenessSlider, setShowEffectivenessSlider] = useState(false)
|
||||
|
||||
@@ -279,6 +283,33 @@ function ControlCard({
|
||||
{statusLabels[control.displayStatus]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Linked Evidence */}
|
||||
{control.linkedEvidence.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<span className="text-xs text-gray-500 mb-1 block">Nachweise:</span>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{control.linkedEvidence.map(ev => (
|
||||
<span key={ev.id} className={`px-2 py-0.5 text-xs rounded ${
|
||||
ev.status === 'valid' ? 'bg-green-50 text-green-700' :
|
||||
ev.status === 'expired' ? 'bg-red-50 text-red-700' :
|
||||
'bg-yellow-50 text-yellow-700'
|
||||
}`}>
|
||||
{ev.title}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<button
|
||||
onClick={onLinkEvidence}
|
||||
className="text-sm text-purple-600 hover:text-purple-700 font-medium"
|
||||
>
|
||||
Evidence verknuepfen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -400,6 +431,7 @@ function LoadingSkeleton() {
|
||||
|
||||
export default function ControlsPage() {
|
||||
const { state, dispatch } = useSDK()
|
||||
const router = useRouter()
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -407,6 +439,33 @@ export default function ControlsPage() {
|
||||
|
||||
// Track effectiveness locally as it's not in the SDK state type
|
||||
const [effectivenessMap, setEffectivenessMap] = useState<Record<string, number>>({})
|
||||
// Track linked evidence per control
|
||||
const [evidenceMap, setEvidenceMap] = useState<Record<string, { id: string; title: string; status: string }[]>>({})
|
||||
|
||||
const fetchEvidenceForControls = async (controlIds: string[]) => {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/evidence')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const allEvidence = data.evidence || data
|
||||
if (Array.isArray(allEvidence)) {
|
||||
const map: Record<string, { id: string; title: string; status: string }[]> = {}
|
||||
for (const ev of allEvidence) {
|
||||
const ctrlId = ev.control_id || ''
|
||||
if (!map[ctrlId]) map[ctrlId] = []
|
||||
map[ctrlId].push({
|
||||
id: ev.id,
|
||||
title: ev.title || ev.name || 'Nachweis',
|
||||
status: ev.status || 'pending',
|
||||
})
|
||||
}
|
||||
setEvidenceMap(map)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch controls from backend on mount
|
||||
useEffect(() => {
|
||||
@@ -432,6 +491,8 @@ export default function ControlsPage() {
|
||||
}))
|
||||
dispatch({ type: 'SET_STATE', payload: { controls: mapped } })
|
||||
setError(null)
|
||||
// Fetch evidence for all controls
|
||||
fetchEvidenceForControls(mapped.map(c => c.id))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -494,6 +555,7 @@ export default function ControlsPage() {
|
||||
displayStatus: mapStatusToDisplay(ctrl.implementationStatus),
|
||||
effectivenessPercent,
|
||||
linkedRequirements: template?.linkedRequirements || [],
|
||||
linkedEvidence: evidenceMap[ctrl.id] || [],
|
||||
lastReview: new Date(),
|
||||
}
|
||||
})
|
||||
@@ -673,6 +735,7 @@ export default function ControlsPage() {
|
||||
control={control}
|
||||
onStatusChange={(status) => handleStatusChange(control.id, status)}
|
||||
onEffectivenessChange={(effectiveness) => handleEffectivenessChange(control.id, effectiveness)}
|
||||
onLinkEvidence={() => router.push(`/sdk/evidence?control_id=${control.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -310,15 +310,19 @@ export default function EvidencePage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize] = useState(20)
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
// Fetch evidence from backend on mount
|
||||
// Fetch evidence from backend on mount and when page changes
|
||||
useEffect(() => {
|
||||
const fetchEvidence = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch('/api/sdk/v1/compliance/evidence')
|
||||
const res = await fetch(`/api/sdk/v1/compliance/evidence?page=${page}&limit=${pageSize}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.total !== undefined) setTotal(data.total)
|
||||
const backendEvidence = data.evidence || data
|
||||
if (Array.isArray(backendEvidence) && backendEvidence.length > 0) {
|
||||
const mapped: SDKEvidence[] = backendEvidence.map((e: Record<string, unknown>) => ({
|
||||
@@ -380,7 +384,7 @@ export default function EvidencePage() {
|
||||
}
|
||||
|
||||
fetchEvidence()
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [page, pageSize]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Convert SDK evidence to display evidence
|
||||
const displayEvidence: DisplayEvidence[] = state.evidence.map(ev => {
|
||||
@@ -638,6 +642,34 @@ export default function EvidencePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{!loading && total > pageSize && (
|
||||
<div className="flex items-center justify-between bg-white rounded-xl border border-gray-200 p-4">
|
||||
<span className="text-sm text-gray-500">
|
||||
Zeige {((page - 1) * pageSize) + 1}–{Math.min(page * pageSize, total)} von {total} Nachweisen
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Zurueck
|
||||
</button>
|
||||
<span className="text-sm text-gray-700">
|
||||
Seite {page} von {Math.ceil(total / pageSize)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(Math.ceil(total / pageSize), p + 1))}
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
className="px-3 py-1 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filteredEvidence.length === 0 && state.controls.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||
|
||||
@@ -257,12 +257,14 @@ function AddRequirementForm({
|
||||
function RequirementCard({
|
||||
requirement,
|
||||
onStatusChange,
|
||||
onDelete,
|
||||
expanded,
|
||||
onToggleDetails,
|
||||
linkedControls,
|
||||
}: {
|
||||
requirement: DisplayRequirement
|
||||
onStatusChange: (status: RequirementStatus) => void
|
||||
onDelete: () => void
|
||||
expanded: boolean
|
||||
onToggleDetails: () => void
|
||||
linkedControls: { id: string; name: string }[]
|
||||
@@ -345,19 +347,27 @@ function RequirementCard({
|
||||
<p className="text-sm text-gray-400">Keine Kontrollen zugeordnet</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-1">Status-Historie</h4>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
requirement.displayStatus === 'compliant' ? 'bg-green-100 text-green-700' :
|
||||
requirement.displayStatus === 'partial' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{requirement.status === 'NOT_STARTED' ? 'Nicht begonnen' :
|
||||
requirement.status === 'IN_PROGRESS' ? 'In Bearbeitung' :
|
||||
requirement.status === 'IMPLEMENTED' ? 'Implementiert' : 'Verifiziert'}
|
||||
</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-1">Status-Historie</h4>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
requirement.displayStatus === 'compliant' ? 'bg-green-100 text-green-700' :
|
||||
requirement.displayStatus === 'partial' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{requirement.status === 'NOT_STARTED' ? 'Nicht begonnen' :
|
||||
requirement.status === 'IN_PROGRESS' ? 'In Bearbeitung' :
|
||||
requirement.status === 'IMPLEMENTED' ? 'Implementiert' : 'Verifiziert'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 border border-red-200 rounded-lg transition-colors"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -493,6 +503,7 @@ export default function RequirementsPage() {
|
||||
const nonCompliantCount = displayRequirements.filter(r => r.displayStatus === 'non-compliant').length
|
||||
|
||||
const handleStatusChange = async (requirementId: string, status: RequirementStatus) => {
|
||||
const previousStatus = state.requirements.find(r => r.id === requirementId)?.status
|
||||
dispatch({
|
||||
type: 'UPDATE_REQUIREMENT',
|
||||
payload: { id: requirementId, data: { status } },
|
||||
@@ -500,17 +511,94 @@ export default function RequirementsPage() {
|
||||
|
||||
// Persist to backend
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/compliance/requirements/${requirementId}`, {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/requirements/${requirementId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
body: JSON.stringify({ implementation_status: status.toLowerCase() }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
// Rollback on failure
|
||||
if (previousStatus) {
|
||||
dispatch({ type: 'UPDATE_REQUIREMENT', payload: { id: requirementId, data: { status: previousStatus } } })
|
||||
}
|
||||
setError('Status-Aenderung konnte nicht gespeichert werden')
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — SDK state is already updated
|
||||
if (previousStatus) {
|
||||
dispatch({ type: 'UPDATE_REQUIREMENT', payload: { id: requirementId, data: { status: previousStatus } } })
|
||||
}
|
||||
setError('Backend nicht erreichbar — Aenderung zurueckgesetzt')
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddRequirement = (data: { regulation: string; article: string; title: string; description: string; criticality: RiskSeverity }) => {
|
||||
const handleDeleteRequirement = async (requirementId: string) => {
|
||||
if (!confirm('Anforderung wirklich loeschen?')) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/requirements/${requirementId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (res.ok) {
|
||||
dispatch({ type: 'SET_STATE', payload: { requirements: state.requirements.filter(r => r.id !== requirementId) } })
|
||||
} else {
|
||||
setError('Loeschen fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
setError('Backend nicht erreichbar')
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddRequirement = async (data: { regulation: string; article: string; title: string; description: string; criticality: RiskSeverity }) => {
|
||||
// Try to resolve regulation_id from backend
|
||||
let regulationId = ''
|
||||
try {
|
||||
const regRes = await fetch(`/api/sdk/v1/compliance/regulations/${data.regulation}`)
|
||||
if (regRes.ok) {
|
||||
const regData = await regRes.json()
|
||||
regulationId = regData.id
|
||||
}
|
||||
} catch {
|
||||
// Regulation not found — still add locally
|
||||
}
|
||||
|
||||
const priorityMap: Record<string, number> = { LOW: 1, MEDIUM: 2, HIGH: 3, CRITICAL: 4 }
|
||||
|
||||
if (regulationId) {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/requirements', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
regulation_id: regulationId,
|
||||
article: data.article,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
priority: priorityMap[data.criticality] || 2,
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
const created = await res.json()
|
||||
const newReq: SDKRequirement = {
|
||||
id: created.id,
|
||||
regulation: data.regulation,
|
||||
article: data.article,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
criticality: data.criticality,
|
||||
applicableModules: [],
|
||||
status: 'NOT_STARTED',
|
||||
controls: [],
|
||||
}
|
||||
dispatch({ type: 'ADD_REQUIREMENT', payload: newReq })
|
||||
setShowAddForm(false)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Fall through to local-only add
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: add locally only
|
||||
const newReq: SDKRequirement = {
|
||||
id: `req-${Date.now()}`,
|
||||
regulation: data.regulation,
|
||||
@@ -651,6 +739,7 @@ export default function RequirementsPage() {
|
||||
key={requirement.id}
|
||||
requirement={requirement}
|
||||
onStatusChange={(status) => handleStatusChange(requirement.id, status)}
|
||||
onDelete={() => handleDeleteRequirement(requirement.id)}
|
||||
expanded={expandedId === requirement.id}
|
||||
onToggleDetails={() => setExpandedId(expandedId === requirement.id ? null : requirement.id)}
|
||||
linkedControls={linkedControls}
|
||||
|
||||
@@ -271,11 +271,14 @@ function RiskCard({
|
||||
risk,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onStatusChange,
|
||||
}: {
|
||||
risk: Risk
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onStatusChange: (status: RiskStatus) => void
|
||||
}) {
|
||||
const [showMitigations, setShowMitigations] = useState(false)
|
||||
const severityColors = {
|
||||
CRITICAL: 'border-red-200 bg-red-50',
|
||||
HIGH: 'border-orange-200 bg-orange-50',
|
||||
@@ -335,7 +338,7 @@ function RiskCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-3 gap-4 text-sm">
|
||||
<div className="mt-4 grid grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Wahrscheinlichkeit:</span>
|
||||
<span className="ml-2 font-medium">{risk.likelihood}/5</span>
|
||||
@@ -345,14 +348,69 @@ function RiskCard({
|
||||
<span className="ml-2 font-medium">{risk.impact}/5</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Score:</span>
|
||||
<span className="text-gray-500">Inherent:</span>
|
||||
<span className="ml-2 font-medium">{risk.inherentRiskScore}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Residual:</span>
|
||||
<span className={`ml-2 font-medium ${
|
||||
risk.residualRiskScore < risk.inherentRiskScore ? 'text-green-600' : ''
|
||||
}`}>
|
||||
{risk.residualRiskScore}
|
||||
</span>
|
||||
{risk.residualRiskScore < risk.inherentRiskScore && (
|
||||
<span className="ml-1 text-xs text-green-600">
|
||||
({risk.inherentRiskScore} → {risk.residualRiskScore})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{risk.mitigation.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<span className="text-sm text-gray-500">Mitigationen: {risk.mitigation.length}</span>
|
||||
{/* Status Workflow */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Status:</span>
|
||||
<select
|
||||
value={risk.status}
|
||||
onChange={(e) => onStatusChange(e.target.value as RiskStatus)}
|
||||
className="px-2 py-1 text-sm border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="IDENTIFIED">Identifiziert</option>
|
||||
<option value="ASSESSED">Bewertet</option>
|
||||
<option value="MITIGATED">Mitigiert</option>
|
||||
<option value="ACCEPTED">Akzeptiert</option>
|
||||
<option value="CLOSED">Geschlossen</option>
|
||||
</select>
|
||||
</div>
|
||||
{risk.mitigation.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowMitigations(!showMitigations)}
|
||||
className="text-sm text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
{showMitigations ? 'Mitigationen ausblenden' : `${risk.mitigation.length} Mitigation(en) anzeigen`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded Mitigations */}
|
||||
{showMitigations && risk.mitigation.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{risk.mitigation.map((m, idx) => (
|
||||
<div key={idx} className="p-3 bg-gray-50 rounded-lg text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-gray-700">{m.controlId || `Mitigation ${idx + 1}`}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
m.status === 'IMPLEMENTED' ? 'bg-green-100 text-green-700' :
|
||||
m.status === 'IN_PROGRESS' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
{m.status === 'IMPLEMENTED' ? 'Implementiert' :
|
||||
m.status === 'IN_PROGRESS' ? 'In Bearbeitung' : m.status || 'Geplant'}
|
||||
</span>
|
||||
</div>
|
||||
{m.description && <p className="text-gray-500 mt-1">{m.description}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -516,6 +574,23 @@ export default function RisksPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleStatusChange = async (riskId: string, status: RiskStatus) => {
|
||||
dispatch({
|
||||
type: 'UPDATE_RISK',
|
||||
payload: { id: riskId, data: { status } },
|
||||
})
|
||||
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/compliance/risks/${riskId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (risk: Risk) => {
|
||||
setEditingRisk(risk)
|
||||
setShowForm(true)
|
||||
@@ -640,6 +715,7 @@ export default function RisksPage() {
|
||||
risk={risk}
|
||||
onEdit={() => handleEdit(risk)}
|
||||
onDelete={() => handleDelete(risk.id)}
|
||||
onStatusChange={(status) => handleStatusChange(risk.id, status)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ from ..db import (
|
||||
RequirementRepository,
|
||||
ControlRepository,
|
||||
)
|
||||
from ..db.models import RegulationDB, RequirementDB
|
||||
from ..db.models import RegulationDB, RequirementDB, AISystemDB, AIClassificationEnum, AISystemStatusEnum
|
||||
from .schemas import (
|
||||
# AI Assistant schemas
|
||||
AIInterpretationRequest, AIInterpretationResponse,
|
||||
@@ -39,6 +39,8 @@ from .schemas import (
|
||||
AIRiskAssessmentRequest, AIRiskAssessmentResponse, AIRiskFactor,
|
||||
AIGapAnalysisRequest, AIGapAnalysisResponse,
|
||||
AIStatusResponse,
|
||||
# AI System schemas
|
||||
AISystemCreate, AISystemUpdate, AISystemResponse, AISystemListResponse,
|
||||
# PDF extraction schemas
|
||||
BSIAspectResponse, PDFExtractionResponse,
|
||||
)
|
||||
@@ -47,6 +49,361 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["compliance-ai"])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# AI System CRUD Endpoints (AI Act Compliance)
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/ai/systems", response_model=AISystemListResponse)
|
||||
async def list_ai_systems(
|
||||
classification: Optional[str] = Query(None, description="Filter by classification"),
|
||||
status: Optional[str] = Query(None, description="Filter by status"),
|
||||
sector: Optional[str] = Query(None, description="Filter by sector"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List all registered AI systems."""
|
||||
import uuid as _uuid
|
||||
query = db.query(AISystemDB)
|
||||
|
||||
if classification:
|
||||
try:
|
||||
cls_enum = AIClassificationEnum(classification)
|
||||
query = query.filter(AISystemDB.classification == cls_enum)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if status:
|
||||
try:
|
||||
status_enum = AISystemStatusEnum(status)
|
||||
query = query.filter(AISystemDB.status == status_enum)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if sector:
|
||||
query = query.filter(AISystemDB.sector.ilike(f"%{sector}%"))
|
||||
|
||||
systems = query.order_by(AISystemDB.created_at.desc()).all()
|
||||
|
||||
results = [
|
||||
AISystemResponse(
|
||||
id=s.id,
|
||||
name=s.name,
|
||||
description=s.description,
|
||||
purpose=s.purpose,
|
||||
sector=s.sector,
|
||||
classification=s.classification.value if s.classification else "unclassified",
|
||||
status=s.status.value if s.status else "draft",
|
||||
obligations=s.obligations or [],
|
||||
assessment_date=s.assessment_date,
|
||||
assessment_result=s.assessment_result,
|
||||
risk_factors=s.risk_factors,
|
||||
recommendations=s.recommendations,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
)
|
||||
for s in systems
|
||||
]
|
||||
|
||||
return AISystemListResponse(systems=results, total=len(results))
|
||||
|
||||
|
||||
@router.post("/ai/systems", response_model=AISystemResponse)
|
||||
async def create_ai_system(
|
||||
data: AISystemCreate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Register a new AI system."""
|
||||
import uuid as _uuid
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
cls_enum = AIClassificationEnum(data.classification) if data.classification else AIClassificationEnum.UNCLASSIFIED
|
||||
except ValueError:
|
||||
cls_enum = AIClassificationEnum.UNCLASSIFIED
|
||||
|
||||
try:
|
||||
status_enum = AISystemStatusEnum(data.status) if data.status else AISystemStatusEnum.DRAFT
|
||||
except ValueError:
|
||||
status_enum = AISystemStatusEnum.DRAFT
|
||||
|
||||
system = AISystemDB(
|
||||
id=str(_uuid.uuid4()),
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
purpose=data.purpose,
|
||||
sector=data.sector,
|
||||
classification=cls_enum,
|
||||
status=status_enum,
|
||||
obligations=data.obligations or [],
|
||||
)
|
||||
db.add(system)
|
||||
db.commit()
|
||||
db.refresh(system)
|
||||
|
||||
return AISystemResponse(
|
||||
id=system.id,
|
||||
name=system.name,
|
||||
description=system.description,
|
||||
purpose=system.purpose,
|
||||
sector=system.sector,
|
||||
classification=system.classification.value if system.classification else "unclassified",
|
||||
status=system.status.value if system.status else "draft",
|
||||
obligations=system.obligations or [],
|
||||
assessment_date=system.assessment_date,
|
||||
assessment_result=system.assessment_result,
|
||||
risk_factors=system.risk_factors,
|
||||
recommendations=system.recommendations,
|
||||
created_at=system.created_at,
|
||||
updated_at=system.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ai/systems/{system_id}", response_model=AISystemResponse)
|
||||
async def get_ai_system(system_id: str, db: Session = Depends(get_db)):
|
||||
"""Get a specific AI system by ID."""
|
||||
system = db.query(AISystemDB).filter(AISystemDB.id == system_id).first()
|
||||
if not system:
|
||||
raise HTTPException(status_code=404, detail=f"AI System {system_id} not found")
|
||||
|
||||
return AISystemResponse(
|
||||
id=system.id,
|
||||
name=system.name,
|
||||
description=system.description,
|
||||
purpose=system.purpose,
|
||||
sector=system.sector,
|
||||
classification=system.classification.value if system.classification else "unclassified",
|
||||
status=system.status.value if system.status else "draft",
|
||||
obligations=system.obligations or [],
|
||||
assessment_date=system.assessment_date,
|
||||
assessment_result=system.assessment_result,
|
||||
risk_factors=system.risk_factors,
|
||||
recommendations=system.recommendations,
|
||||
created_at=system.created_at,
|
||||
updated_at=system.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/ai/systems/{system_id}", response_model=AISystemResponse)
|
||||
async def update_ai_system(
|
||||
system_id: str,
|
||||
data: AISystemUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update an AI system."""
|
||||
from datetime import datetime
|
||||
|
||||
system = db.query(AISystemDB).filter(AISystemDB.id == system_id).first()
|
||||
if not system:
|
||||
raise HTTPException(status_code=404, detail=f"AI System {system_id} not found")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
if "classification" in update_data:
|
||||
try:
|
||||
update_data["classification"] = AIClassificationEnum(update_data["classification"])
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid classification: {update_data['classification']}")
|
||||
|
||||
if "status" in update_data:
|
||||
try:
|
||||
update_data["status"] = AISystemStatusEnum(update_data["status"])
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid status: {update_data['status']}")
|
||||
|
||||
for key, value in update_data.items():
|
||||
if hasattr(system, key):
|
||||
setattr(system, key, value)
|
||||
|
||||
system.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(system)
|
||||
|
||||
return AISystemResponse(
|
||||
id=system.id,
|
||||
name=system.name,
|
||||
description=system.description,
|
||||
purpose=system.purpose,
|
||||
sector=system.sector,
|
||||
classification=system.classification.value if system.classification else "unclassified",
|
||||
status=system.status.value if system.status else "draft",
|
||||
obligations=system.obligations or [],
|
||||
assessment_date=system.assessment_date,
|
||||
assessment_result=system.assessment_result,
|
||||
risk_factors=system.risk_factors,
|
||||
recommendations=system.recommendations,
|
||||
created_at=system.created_at,
|
||||
updated_at=system.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/ai/systems/{system_id}")
|
||||
async def delete_ai_system(system_id: str, db: Session = Depends(get_db)):
|
||||
"""Delete an AI system."""
|
||||
system = db.query(AISystemDB).filter(AISystemDB.id == system_id).first()
|
||||
if not system:
|
||||
raise HTTPException(status_code=404, detail=f"AI System {system_id} not found")
|
||||
|
||||
db.delete(system)
|
||||
db.commit()
|
||||
return {"success": True, "message": "AI System deleted"}
|
||||
|
||||
|
||||
@router.post("/ai/systems/{system_id}/assess", response_model=AISystemResponse)
|
||||
async def assess_ai_system(
|
||||
system_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Run AI Act risk assessment for an AI system."""
|
||||
from datetime import datetime
|
||||
|
||||
system = db.query(AISystemDB).filter(AISystemDB.id == system_id).first()
|
||||
if not system:
|
||||
raise HTTPException(status_code=404, detail=f"AI System {system_id} not found")
|
||||
|
||||
# Try AI-based assessment
|
||||
assessment_result = None
|
||||
try:
|
||||
from ..services.ai_compliance_assistant import get_ai_assistant
|
||||
assistant = get_ai_assistant()
|
||||
result = await assistant.assess_module_risk(
|
||||
module_name=system.name,
|
||||
service_type="ai_system",
|
||||
description=system.description or "",
|
||||
processes_pii=True,
|
||||
ai_components=True,
|
||||
criticality="high",
|
||||
data_categories=[],
|
||||
regulations=[{"code": "AI-ACT", "relevance": "high"}],
|
||||
)
|
||||
assessment_result = {
|
||||
"overall_risk": result.overall_risk,
|
||||
"risk_factors": result.risk_factors,
|
||||
"recommendations": result.recommendations,
|
||||
"compliance_gaps": result.compliance_gaps,
|
||||
"confidence_score": result.confidence_score,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"AI assessment failed for {system_id}, using rule-based: {e}")
|
||||
# Rule-based fallback
|
||||
assessment_result = _rule_based_assessment(system)
|
||||
|
||||
# Update system with assessment results
|
||||
classification = _derive_classification(assessment_result)
|
||||
try:
|
||||
system.classification = AIClassificationEnum(classification)
|
||||
except ValueError:
|
||||
system.classification = AIClassificationEnum.UNCLASSIFIED
|
||||
|
||||
system.assessment_date = datetime.utcnow()
|
||||
system.assessment_result = assessment_result
|
||||
system.obligations = _derive_obligations(classification)
|
||||
system.risk_factors = assessment_result.get("risk_factors", [])
|
||||
system.recommendations = assessment_result.get("recommendations", [])
|
||||
system.status = AISystemStatusEnum.CLASSIFIED
|
||||
|
||||
db.commit()
|
||||
db.refresh(system)
|
||||
|
||||
return AISystemResponse(
|
||||
id=system.id,
|
||||
name=system.name,
|
||||
description=system.description,
|
||||
purpose=system.purpose,
|
||||
sector=system.sector,
|
||||
classification=system.classification.value if system.classification else "unclassified",
|
||||
status=system.status.value if system.status else "draft",
|
||||
obligations=system.obligations or [],
|
||||
assessment_date=system.assessment_date,
|
||||
assessment_result=system.assessment_result,
|
||||
risk_factors=system.risk_factors,
|
||||
recommendations=system.recommendations,
|
||||
created_at=system.created_at,
|
||||
updated_at=system.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _rule_based_assessment(system: AISystemDB) -> dict:
|
||||
"""Simple rule-based AI Act classification when AI service is unavailable."""
|
||||
desc = (system.description or "").lower() + " " + (system.purpose or "").lower()
|
||||
sector = (system.sector or "").lower()
|
||||
|
||||
risk_factors = []
|
||||
risk_score = 0
|
||||
|
||||
# Check for prohibited use cases
|
||||
prohibited_keywords = ["social scoring", "biometric surveillance", "emotion recognition", "subliminal manipulation"]
|
||||
for kw in prohibited_keywords:
|
||||
if kw in desc:
|
||||
risk_factors.append({"factor": f"Prohibited use case: {kw}", "severity": "critical", "likelihood": "high"})
|
||||
risk_score += 10
|
||||
|
||||
# Check for high-risk indicators
|
||||
high_risk_keywords = ["education", "employment", "credit scoring", "law enforcement", "migration", "critical infrastructure", "medical", "bildung", "gesundheit"]
|
||||
for kw in high_risk_keywords:
|
||||
if kw in desc or kw in sector:
|
||||
risk_factors.append({"factor": f"High-risk sector: {kw}", "severity": "high", "likelihood": "medium"})
|
||||
risk_score += 5
|
||||
|
||||
# Check for limited-risk indicators
|
||||
limited_keywords = ["chatbot", "deepfake", "emotion", "biometric"]
|
||||
for kw in limited_keywords:
|
||||
if kw in desc:
|
||||
risk_factors.append({"factor": f"Transparency requirement: {kw}", "severity": "medium", "likelihood": "high"})
|
||||
risk_score += 3
|
||||
|
||||
return {
|
||||
"overall_risk": "critical" if risk_score >= 10 else "high" if risk_score >= 5 else "medium" if risk_score >= 3 else "low",
|
||||
"risk_factors": risk_factors,
|
||||
"recommendations": [
|
||||
"Dokumentation des AI-Systems vervollstaendigen",
|
||||
"Risikomanagement-Framework implementieren",
|
||||
"Transparenzpflichten pruefen",
|
||||
],
|
||||
"compliance_gaps": [],
|
||||
"confidence_score": 0.6,
|
||||
"risk_score": risk_score,
|
||||
}
|
||||
|
||||
|
||||
def _derive_classification(assessment: dict) -> str:
|
||||
"""Derive AI Act classification from assessment result."""
|
||||
risk = assessment.get("overall_risk", "medium")
|
||||
score = assessment.get("risk_score", 0)
|
||||
|
||||
if score >= 10:
|
||||
return "prohibited"
|
||||
elif risk in ("critical", "high") or score >= 5:
|
||||
return "high-risk"
|
||||
elif risk == "medium" or score >= 3:
|
||||
return "limited-risk"
|
||||
else:
|
||||
return "minimal-risk"
|
||||
|
||||
|
||||
def _derive_obligations(classification: str) -> list:
|
||||
"""Derive AI Act obligations based on classification."""
|
||||
obligations_map = {
|
||||
"prohibited": ["Einsatz verboten (Art. 5 AI Act)"],
|
||||
"high-risk": [
|
||||
"Risikomanagementsystem (Art. 9)",
|
||||
"Daten-Governance (Art. 10)",
|
||||
"Technische Dokumentation (Art. 11)",
|
||||
"Aufzeichnungspflicht (Art. 12)",
|
||||
"Transparenz (Art. 13)",
|
||||
"Menschliche Aufsicht (Art. 14)",
|
||||
"Genauigkeit & Robustheit (Art. 15)",
|
||||
"Konformitaetsbewertung (Art. 43)",
|
||||
],
|
||||
"limited-risk": [
|
||||
"Transparenzpflicht (Art. 52)",
|
||||
"Kennzeichnung als KI-System",
|
||||
],
|
||||
"minimal-risk": [
|
||||
"Freiwillige Verhaltenskodizes (Art. 69)",
|
||||
],
|
||||
}
|
||||
return obligations_map.get(classification, [])
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# AI Assistant Endpoints (Sprint 4)
|
||||
# ============================================================================
|
||||
|
||||
@@ -46,9 +46,11 @@ async def list_evidence(
|
||||
control_id: Optional[str] = None,
|
||||
evidence_type: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
page: Optional[int] = Query(None, ge=1, description="Page number (1-based)"),
|
||||
limit: Optional[int] = Query(None, ge=1, le=500, description="Items per page"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List evidence with optional filters."""
|
||||
"""List evidence with optional filters and pagination."""
|
||||
repo = EvidenceRepository(db)
|
||||
|
||||
if control_id:
|
||||
@@ -71,6 +73,13 @@ async def list_evidence(
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
total = len(evidence)
|
||||
|
||||
# Apply pagination if requested
|
||||
if page is not None and limit is not None:
|
||||
offset = (page - 1) * limit
|
||||
evidence = evidence[offset:offset + limit]
|
||||
|
||||
results = [
|
||||
EvidenceResponse(
|
||||
id=e.id,
|
||||
@@ -95,7 +104,7 @@ async def list_evidence(
|
||||
for e in evidence
|
||||
]
|
||||
|
||||
return EvidenceListResponse(evidence=results, total=len(results))
|
||||
return EvidenceListResponse(evidence=results, total=total)
|
||||
|
||||
|
||||
@router.post("/evidence", response_model=EvidenceResponse)
|
||||
|
||||
@@ -324,6 +324,59 @@ async def list_requirements_paginated(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/requirements", response_model=RequirementResponse)
|
||||
async def create_requirement(
|
||||
data: RequirementCreate,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Create a new requirement."""
|
||||
# Verify regulation exists
|
||||
reg_repo = RegulationRepository(db)
|
||||
regulation = reg_repo.get_by_id(data.regulation_id)
|
||||
if not regulation:
|
||||
raise HTTPException(status_code=404, detail=f"Regulation {data.regulation_id} not found")
|
||||
|
||||
req_repo = RequirementRepository(db)
|
||||
requirement = req_repo.create(
|
||||
regulation_id=data.regulation_id,
|
||||
article=data.article,
|
||||
title=data.title,
|
||||
paragraph=data.paragraph,
|
||||
description=data.description,
|
||||
requirement_text=data.requirement_text,
|
||||
breakpilot_interpretation=data.breakpilot_interpretation,
|
||||
is_applicable=data.is_applicable,
|
||||
priority=data.priority,
|
||||
)
|
||||
|
||||
return RequirementResponse(
|
||||
id=requirement.id,
|
||||
regulation_id=requirement.regulation_id,
|
||||
regulation_code=regulation.code,
|
||||
article=requirement.article,
|
||||
paragraph=requirement.paragraph,
|
||||
title=requirement.title,
|
||||
description=requirement.description,
|
||||
requirement_text=requirement.requirement_text,
|
||||
breakpilot_interpretation=requirement.breakpilot_interpretation,
|
||||
is_applicable=requirement.is_applicable,
|
||||
applicability_reason=requirement.applicability_reason,
|
||||
priority=requirement.priority,
|
||||
created_at=requirement.created_at,
|
||||
updated_at=requirement.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/requirements/{requirement_id}")
|
||||
async def delete_requirement(requirement_id: str, db: Session = Depends(get_db)):
|
||||
"""Delete a requirement by ID."""
|
||||
req_repo = RequirementRepository(db)
|
||||
deleted = req_repo.delete(requirement_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"Requirement {requirement_id} not found")
|
||||
return {"success": True, "message": "Requirement deleted"}
|
||||
|
||||
|
||||
@router.put("/requirements/{requirement_id}")
|
||||
async def update_requirement(requirement_id: str, updates: dict, db: Session = Depends(get_db)):
|
||||
"""Update a requirement with implementation/audit details."""
|
||||
@@ -818,7 +871,7 @@ async def init_tables(db: Session = Depends(get_db)):
|
||||
from classroom_engine.database import engine
|
||||
from ..db.models import (
|
||||
RegulationDB, RequirementDB, ControlDB, ControlMappingDB,
|
||||
EvidenceDB, RiskDB, AuditExportDB
|
||||
EvidenceDB, RiskDB, AuditExportDB, AISystemDB
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -830,6 +883,7 @@ async def init_tables(db: Session = Depends(get_db)):
|
||||
EvidenceDB.__table__.create(engine, checkfirst=True)
|
||||
RiskDB.__table__.create(engine, checkfirst=True)
|
||||
AuditExportDB.__table__.create(engine, checkfirst=True)
|
||||
AISystemDB.__table__.create(engine, checkfirst=True)
|
||||
|
||||
return {"success": True, "message": "Tables created successfully"}
|
||||
except Exception as e:
|
||||
|
||||
@@ -385,6 +385,52 @@ class RiskMatrixResponse(BaseModel):
|
||||
risks: List[RiskResponse]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# AI System Schemas (AI Act Compliance)
|
||||
# ============================================================================
|
||||
|
||||
class AISystemBase(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
purpose: Optional[str] = None
|
||||
sector: Optional[str] = None
|
||||
classification: str = "unclassified"
|
||||
status: str = "draft"
|
||||
obligations: Optional[List[str]] = None
|
||||
|
||||
|
||||
class AISystemCreate(AISystemBase):
|
||||
pass
|
||||
|
||||
|
||||
class AISystemUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
purpose: Optional[str] = None
|
||||
sector: Optional[str] = None
|
||||
classification: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
obligations: Optional[List[str]] = None
|
||||
|
||||
|
||||
class AISystemResponse(AISystemBase):
|
||||
id: str
|
||||
assessment_date: Optional[datetime] = None
|
||||
assessment_result: Optional[Dict[str, Any]] = None
|
||||
risk_factors: Optional[List[Dict[str, Any]]] = None
|
||||
recommendations: Optional[List[str]] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AISystemListResponse(BaseModel):
|
||||
systems: List[AISystemResponse]
|
||||
total: int
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Dashboard & Export Schemas
|
||||
# ============================================================================
|
||||
|
||||
@@ -401,6 +401,60 @@ class RiskDB(Base):
|
||||
return RiskLevelEnum.LOW
|
||||
|
||||
|
||||
class AIClassificationEnum(str, enum.Enum):
|
||||
"""AI Act risk classification."""
|
||||
PROHIBITED = "prohibited"
|
||||
HIGH_RISK = "high-risk"
|
||||
LIMITED_RISK = "limited-risk"
|
||||
MINIMAL_RISK = "minimal-risk"
|
||||
UNCLASSIFIED = "unclassified"
|
||||
|
||||
|
||||
class AISystemStatusEnum(str, enum.Enum):
|
||||
"""Status of an AI system in compliance tracking."""
|
||||
DRAFT = "draft"
|
||||
CLASSIFIED = "classified"
|
||||
COMPLIANT = "compliant"
|
||||
NON_COMPLIANT = "non-compliant"
|
||||
|
||||
|
||||
class AISystemDB(Base):
|
||||
"""
|
||||
AI System registry for AI Act compliance.
|
||||
Tracks AI systems, their risk classification, and compliance status.
|
||||
"""
|
||||
__tablename__ = 'compliance_ai_systems'
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String(300), nullable=False)
|
||||
description = Column(Text)
|
||||
purpose = Column(String(500))
|
||||
sector = Column(String(100))
|
||||
|
||||
# AI Act classification
|
||||
classification = Column(Enum(AIClassificationEnum), default=AIClassificationEnum.UNCLASSIFIED)
|
||||
status = Column(Enum(AISystemStatusEnum), default=AISystemStatusEnum.DRAFT)
|
||||
|
||||
# Assessment
|
||||
assessment_date = Column(DateTime)
|
||||
assessment_result = Column(JSON) # Full assessment result
|
||||
obligations = Column(JSON) # List of AI Act obligations
|
||||
risk_factors = Column(JSON) # Risk factors from assessment
|
||||
recommendations = Column(JSON) # Recommendations from assessment
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
Index('ix_ai_system_classification', 'classification'),
|
||||
Index('ix_ai_system_status', 'status'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<AISystem {self.name} ({self.classification.value})>"
|
||||
|
||||
|
||||
class AuditExportDB(Base):
|
||||
"""
|
||||
Tracks audit export packages generated for external auditors.
|
||||
|
||||
@@ -252,6 +252,15 @@ class RequirementRepository:
|
||||
|
||||
return items, total
|
||||
|
||||
def delete(self, requirement_id: str) -> bool:
|
||||
"""Delete a requirement."""
|
||||
requirement = self.db.query(RequirementDB).filter(RequirementDB.id == requirement_id).first()
|
||||
if not requirement:
|
||||
return False
|
||||
self.db.delete(requirement)
|
||||
self.db.commit()
|
||||
return True
|
||||
|
||||
def count(self) -> int:
|
||||
"""Count all requirements."""
|
||||
return self.db.query(func.count(RequirementDB.id)).scalar() or 0
|
||||
|
||||
Reference in New Issue
Block a user