refactor: Admin-Layout komplett entfernt — SDK als einziges Layout
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 19s
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 19s
Kaputtes (admin) Layout geloescht (Role-Selection, 404-Sidebar, localhost-Dashboard). SDK-Flow nach /sdk/sdk-flow verschoben. Route-Gruppe (sdk) aufgeloest. Root-Seite redirected auf /sdk. ~25 ungenutzte Dateien/Verzeichnisse entfernt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
289
admin-compliance/app/sdk/use-cases/[id]/page.tsx
Normal file
289
admin-compliance/app/sdk/use-cases/[id]/page.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { AssessmentResultCard } from '@/components/sdk/use-case-assessment/AssessmentResultCard'
|
||||
|
||||
interface TriggeredRule {
|
||||
code: string
|
||||
category: string
|
||||
title: string
|
||||
description: string
|
||||
severity: string
|
||||
score_delta: number
|
||||
gdpr_ref: string
|
||||
}
|
||||
|
||||
interface RequiredControl {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
severity: string
|
||||
category: string
|
||||
gdpr_ref: string
|
||||
}
|
||||
|
||||
interface PatternRecommendation {
|
||||
pattern_id: string
|
||||
title: string
|
||||
description: string
|
||||
rationale: string
|
||||
priority: number
|
||||
}
|
||||
|
||||
interface ForbiddenPattern {
|
||||
pattern_id: string
|
||||
title: string
|
||||
description: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
interface FullAssessment {
|
||||
id: string
|
||||
title: string
|
||||
tenant_id: string
|
||||
domain: string
|
||||
created_at: string
|
||||
intake?: {
|
||||
use_case_text?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
// Flat result fields
|
||||
feasibility: string
|
||||
risk_level: string
|
||||
risk_score: number
|
||||
complexity: string
|
||||
dsfa_recommended: boolean
|
||||
art22_risk: boolean
|
||||
training_allowed: string
|
||||
triggered_rules?: TriggeredRule[]
|
||||
required_controls?: RequiredControl[]
|
||||
recommended_architecture?: PatternRecommendation[]
|
||||
forbidden_patterns?: ForbiddenPattern[]
|
||||
explanation_text?: string
|
||||
explanation_model?: string
|
||||
explanation_generated_at?: string
|
||||
policy_version: string
|
||||
}
|
||||
|
||||
export default function AssessmentDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const assessmentId = params.id as string
|
||||
|
||||
const [assessment, setAssessment] = useState<FullAssessment | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [explaining, setExplaining] = useState(false)
|
||||
const [explanationError, setExplanationError] = useState<string | null>(null)
|
||||
|
||||
async function loadAssessment() {
|
||||
const response = await fetch(`/api/sdk/v1/ucca/assessments/${assessmentId}`)
|
||||
if (!response.ok) throw new Error('Assessment nicht gefunden')
|
||||
return response.json()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!assessmentId) return
|
||||
loadAssessment()
|
||||
.then(data => setAssessment(data))
|
||||
.catch(() => {
|
||||
// Fallback: fetch from list
|
||||
fetch('/api/sdk/v1/ucca/assessments')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const found = (data.assessments || []).find((a: FullAssessment) => a.id === assessmentId)
|
||||
if (found) {
|
||||
setAssessment(found)
|
||||
setError(null)
|
||||
} else {
|
||||
setError('Assessment nicht gefunden')
|
||||
}
|
||||
})
|
||||
.catch(() => setError('Fehler beim Laden'))
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [assessmentId])
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Assessment wirklich loeschen?')) return
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/ucca/assessments/${assessmentId}`, { method: 'DELETE' })
|
||||
router.push('/sdk/use-cases')
|
||||
} catch {
|
||||
// Ignore delete errors
|
||||
}
|
||||
}
|
||||
|
||||
const handleExplain = async () => {
|
||||
setExplaining(true)
|
||||
setExplanationError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/ucca/assessments/${assessmentId}/explain`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ language: 'de' }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Fehler bei der Erklärung')
|
||||
// Reload assessment to get updated explanation_text
|
||||
const updated = await fetch(`/api/sdk/v1/ucca/assessments/${assessmentId}`)
|
||||
setAssessment(await updated.json())
|
||||
} catch (err) {
|
||||
setExplanationError(err instanceof Error ? err.message : 'Fehler')
|
||||
} finally {
|
||||
setExplaining(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Lade Assessment...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !assessment) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||
<h3 className="text-lg font-semibold text-red-800">Fehler</h3>
|
||||
<p className="text-red-600 mt-1">{error || 'Assessment nicht gefunden'}</p>
|
||||
</div>
|
||||
<Link href="/sdk/use-cases" className="text-purple-600 hover:text-purple-700">
|
||||
Zurueck zur Uebersicht
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Build result object for AssessmentResultCard from flat assessment fields
|
||||
const resultForCard = {
|
||||
feasibility: assessment.feasibility,
|
||||
risk_level: assessment.risk_level,
|
||||
risk_score: assessment.risk_score,
|
||||
complexity: assessment.complexity,
|
||||
dsfa_recommended: assessment.dsfa_recommended,
|
||||
art22_risk: assessment.art22_risk,
|
||||
training_allowed: assessment.training_allowed,
|
||||
// AssessmentResultCard expects rule_code; backend stores code — map here
|
||||
triggered_rules: assessment.triggered_rules?.map(r => ({
|
||||
rule_code: r.code,
|
||||
title: r.title,
|
||||
severity: r.severity,
|
||||
gdpr_ref: r.gdpr_ref,
|
||||
})),
|
||||
required_controls: assessment.required_controls?.map(c => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
description: c.description,
|
||||
effort: c.category,
|
||||
})),
|
||||
recommended_architecture: assessment.recommended_architecture?.map(p => ({
|
||||
id: p.pattern_id,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
benefit: p.rationale,
|
||||
})),
|
||||
summary: '',
|
||||
recommendation: '',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Link href="/sdk/use-cases" className="hover:text-purple-600">Use Cases</Link>
|
||||
<span>/</span>
|
||||
<span className="text-gray-900">{assessment.title || assessmentId.slice(0, 8)}</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{assessment.title || 'Assessment Detail'}</h1>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-gray-500">
|
||||
<span>Domain: {assessment.domain}</span>
|
||||
<span>Erstellt: {new Date(assessment.created_at).toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||
<button
|
||||
onClick={handleExplain}
|
||||
disabled={explaining}
|
||||
className="px-4 py-2 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{explaining ? 'Generiere...' : '✨ KI-Erklärung'}
|
||||
</button>
|
||||
<a
|
||||
href={`/api/sdk/v1/ucca/export/${assessmentId}?format=md`}
|
||||
download
|
||||
className="px-4 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
↓ Markdown
|
||||
</a>
|
||||
<a
|
||||
href={`/api/sdk/v1/ucca/export/${assessmentId}?format=json`}
|
||||
download
|
||||
className="px-4 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
↓ JSON
|
||||
</a>
|
||||
<Link
|
||||
href={`/sdk/use-cases/new?edit=${assessmentId}`}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Bearbeiten
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
<Link
|
||||
href="/sdk/use-cases"
|
||||
className="px-4 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Zurueck
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Explanation error */}
|
||||
{explanationError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700 text-sm">
|
||||
{explanationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Use Case Text */}
|
||||
{assessment.intake?.use_case_text && (
|
||||
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-2">Beschreibung des Anwendungsfalls</h3>
|
||||
<p className="text-gray-800 whitespace-pre-wrap">{assessment.intake.use_case_text as string}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
<AssessmentResultCard result={resultForCard as Parameters<typeof AssessmentResultCard>[0]['result']} />
|
||||
|
||||
{/* KI-Erklärung */}
|
||||
{assessment.explanation_text && (
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-xl p-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-lg">✨</span>
|
||||
<h3 className="text-sm font-semibold text-purple-800">KI-Erklärung</h3>
|
||||
{assessment.explanation_model && (
|
||||
<span className="text-xs text-purple-500 ml-auto">via {assessment.explanation_model}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-purple-900 whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{assessment.explanation_text}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
681
admin-compliance/app/sdk/use-cases/new/page.tsx
Normal file
681
admin-compliance/app/sdk/use-cases/new/page.tsx
Normal file
@@ -0,0 +1,681 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { AssessmentResultCard } from '@/components/sdk/use-case-assessment/AssessmentResultCard'
|
||||
|
||||
// =============================================================================
|
||||
// WIZARD STEPS CONFIG
|
||||
// =============================================================================
|
||||
|
||||
const WIZARD_STEPS = [
|
||||
{ id: 1, title: 'Grundlegendes', description: 'Titel und Beschreibung' },
|
||||
{ id: 2, title: 'Datenkategorien', description: 'Welche Daten werden verarbeitet?' },
|
||||
{ id: 3, title: 'Verarbeitungszweck', description: 'Rechtsgrundlage und Zweck' },
|
||||
{ id: 4, title: 'Automatisierung', description: 'Grad der Automatisierung' },
|
||||
{ id: 5, title: 'Hosting & Modell', description: 'Technische Details' },
|
||||
{ id: 6, title: 'Datentransfer', description: 'Internationaler Datentransfer' },
|
||||
{ id: 7, title: 'Datenhaltung', description: 'Aufbewahrung und Speicherung' },
|
||||
{ id: 8, title: 'Vertraege', description: 'Compliance und Vereinbarungen' },
|
||||
]
|
||||
|
||||
const DOMAINS = [
|
||||
{ value: 'healthcare', label: 'Gesundheit' },
|
||||
{ value: 'finance', label: 'Finanzen' },
|
||||
{ value: 'education', label: 'Bildung' },
|
||||
{ value: 'retail', label: 'Handel' },
|
||||
{ value: 'it_services', label: 'IT-Dienstleistungen' },
|
||||
{ value: 'consulting', label: 'Beratung' },
|
||||
{ value: 'manufacturing', label: 'Produktion' },
|
||||
{ value: 'hr', label: 'Personalwesen' },
|
||||
{ value: 'marketing', label: 'Marketing' },
|
||||
{ value: 'legal', label: 'Recht' },
|
||||
{ value: 'public', label: 'Oeffentlicher Sektor' },
|
||||
{ value: 'general', label: 'Allgemein' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
function NewUseCasePageInner() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const editId = searchParams.get('edit')
|
||||
const isEditMode = !!editId
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
const [result, setResult] = useState<unknown>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Form state
|
||||
const [form, setForm] = useState({
|
||||
title: '',
|
||||
use_case_text: '',
|
||||
domain: 'general',
|
||||
// Data Types
|
||||
personal_data: false,
|
||||
special_categories: false,
|
||||
minors_data: false,
|
||||
health_data: false,
|
||||
biometric_data: false,
|
||||
financial_data: false,
|
||||
// Purpose
|
||||
purpose_profiling: false,
|
||||
purpose_automated_decision: false,
|
||||
purpose_marketing: false,
|
||||
purpose_analytics: false,
|
||||
purpose_service_delivery: false,
|
||||
// Automation
|
||||
automation: 'assistive' as 'assistive' | 'semi_automated' | 'fully_automated',
|
||||
// Hosting
|
||||
hosting_provider: 'self_hosted',
|
||||
hosting_region: 'eu',
|
||||
// Model Usage
|
||||
model_rag: false,
|
||||
model_finetune: false,
|
||||
model_training: false,
|
||||
model_inference: true,
|
||||
// Legal Basis (Step 3)
|
||||
legal_basis: 'consent' as 'consent' | 'contract' | 'legitimate_interest' | 'legal_obligation' | 'vital_interest' | 'public_interest',
|
||||
// Data Transfer (Step 6)
|
||||
international_transfer: false,
|
||||
transfer_countries: [] as string[],
|
||||
transfer_mechanism: 'none' as 'none' | 'scc' | 'bcr' | 'adequacy' | 'derogation',
|
||||
// Retention (Step 7)
|
||||
retention_days: 90,
|
||||
retention_purpose: '',
|
||||
// Contracts (Step 8)
|
||||
has_dpa: false,
|
||||
has_aia_documentation: false,
|
||||
has_risk_assessment: false,
|
||||
subprocessors: '',
|
||||
})
|
||||
|
||||
const updateForm = (updates: Partial<typeof form>) => {
|
||||
setForm(prev => ({ ...prev, ...updates }))
|
||||
}
|
||||
|
||||
// Pre-fill form when in edit mode
|
||||
useEffect(() => {
|
||||
if (!editId) return
|
||||
setEditLoading(true)
|
||||
fetch(`/api/sdk/v1/ucca/assessments/${editId}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const intake = data.intake || {}
|
||||
setForm({
|
||||
title: data.title || '',
|
||||
use_case_text: intake.use_case_text || '',
|
||||
domain: data.domain || 'general',
|
||||
personal_data: intake.data_types?.personal_data || false,
|
||||
special_categories: intake.data_types?.article_9_data || false,
|
||||
minors_data: intake.data_types?.minor_data || false,
|
||||
health_data: intake.data_types?.health_data || false,
|
||||
biometric_data: intake.data_types?.biometric_data || false,
|
||||
financial_data: intake.data_types?.financial_data || false,
|
||||
purpose_profiling: intake.purpose?.profiling || false,
|
||||
purpose_automated_decision: intake.purpose?.automated_decision || intake.purpose?.decision_making || false,
|
||||
purpose_marketing: intake.purpose?.marketing || false,
|
||||
purpose_analytics: intake.purpose?.analytics || false,
|
||||
purpose_service_delivery: intake.purpose?.service_delivery || intake.purpose?.customer_support || false,
|
||||
automation: intake.automation || 'assistive',
|
||||
hosting_provider: intake.hosting?.provider || 'self_hosted',
|
||||
hosting_region: intake.hosting?.region || 'eu',
|
||||
model_rag: intake.model_usage?.rag || false,
|
||||
model_finetune: intake.model_usage?.finetune || false,
|
||||
model_training: intake.model_usage?.training || false,
|
||||
model_inference: intake.model_usage?.inference ?? true,
|
||||
legal_basis: intake.legal_basis || 'consent',
|
||||
international_transfer: intake.international_transfer?.enabled || false,
|
||||
transfer_countries: intake.international_transfer?.countries || [],
|
||||
transfer_mechanism: intake.international_transfer?.mechanism || 'none',
|
||||
retention_days: intake.retention?.days || 90,
|
||||
retention_purpose: intake.retention?.purpose || '',
|
||||
has_dpa: intake.contracts?.has_dpa || false,
|
||||
has_aia_documentation: intake.contracts?.has_aia_documentation || false,
|
||||
has_risk_assessment: intake.contracts?.has_risk_assessment || false,
|
||||
subprocessors: intake.contracts?.subprocessors || '',
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setEditLoading(false))
|
||||
}, [editId])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const intake = {
|
||||
title: form.title,
|
||||
use_case_text: form.use_case_text,
|
||||
domain: form.domain,
|
||||
data_types: {
|
||||
personal_data: form.personal_data,
|
||||
special_categories: form.special_categories,
|
||||
minors_data: form.minors_data,
|
||||
health_data: form.health_data,
|
||||
biometric_data: form.biometric_data,
|
||||
financial_data: form.financial_data,
|
||||
},
|
||||
purpose: {
|
||||
profiling: form.purpose_profiling,
|
||||
automated_decision: form.purpose_automated_decision,
|
||||
marketing: form.purpose_marketing,
|
||||
analytics: form.purpose_analytics,
|
||||
service_delivery: form.purpose_service_delivery,
|
||||
},
|
||||
automation: form.automation,
|
||||
hosting: {
|
||||
provider: form.hosting_provider,
|
||||
region: form.hosting_region,
|
||||
},
|
||||
model_usage: {
|
||||
rag: form.model_rag,
|
||||
finetune: form.model_finetune,
|
||||
training: form.model_training,
|
||||
inference: form.model_inference,
|
||||
},
|
||||
legal_basis: form.legal_basis,
|
||||
international_transfer: {
|
||||
enabled: form.international_transfer,
|
||||
countries: form.transfer_countries,
|
||||
mechanism: form.transfer_mechanism,
|
||||
},
|
||||
retention: {
|
||||
days: form.retention_days,
|
||||
purpose: form.retention_purpose,
|
||||
},
|
||||
contracts: {
|
||||
has_dpa: form.has_dpa,
|
||||
has_aia_documentation: form.has_aia_documentation,
|
||||
has_risk_assessment: form.has_risk_assessment,
|
||||
subprocessors: form.subprocessors,
|
||||
},
|
||||
store_raw_text: true,
|
||||
}
|
||||
|
||||
const url = isEditMode
|
||||
? `/api/sdk/v1/ucca/assessments/${editId}`
|
||||
: '/api/sdk/v1/ucca/assess'
|
||||
const method = isEditMode ? 'PUT' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(intake),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => null)
|
||||
throw new Error(errData?.error || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
if (isEditMode) {
|
||||
router.push(`/sdk/use-cases/${editId}`)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setResult(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler bei der Bewertung')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a result, show it
|
||||
if (result) {
|
||||
const r = result as { assessment?: { id: string }; result?: Record<string, unknown> }
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Assessment Ergebnis</h1>
|
||||
<div className="flex gap-2">
|
||||
{r.assessment?.id && (
|
||||
<button
|
||||
onClick={() => router.push(`/sdk/use-cases/${r.assessment!.id}`)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
Zum Assessment
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => router.push('/sdk/use-cases')}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
Zur Uebersicht
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{r.result && (
|
||||
<AssessmentResultCard result={r.result as Parameters<typeof AssessmentResultCard>[0]['result']} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{isEditMode ? 'Assessment bearbeiten' : 'Neues Use Case Assessment'}
|
||||
</h1>
|
||||
<p className="mt-1 text-gray-500">
|
||||
{isEditMode
|
||||
? 'Angaben anpassen und Assessment neu bewerten'
|
||||
: 'Beschreiben Sie Ihren KI-Anwendungsfall Schritt fuer Schritt'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Edit loading indicator */}
|
||||
{editLoading && (
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 text-purple-700 text-sm">
|
||||
Lade Assessment-Daten...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
{WIZARD_STEPS.map((step, idx) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<button
|
||||
onClick={() => setCurrentStep(step.id)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
currentStep === step.id
|
||||
? 'bg-purple-600 text-white'
|
||||
: currentStep > step.id
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<span className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center text-xs font-bold">
|
||||
{currentStep > step.id ? '✓' : step.id}
|
||||
</span>
|
||||
<span className="hidden md:inline">{step.title}</span>
|
||||
</button>
|
||||
{idx < WIZARD_STEPS.length - 1 && <div className="flex-1 h-px bg-gray-200" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
{/* Step 1: Grundlegendes */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Grundlegende Informationen</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={e => updateForm({ title: e.target.value })}
|
||||
placeholder="z.B. Chatbot fuer Kundenservice"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={form.use_case_text}
|
||||
onChange={e => updateForm({ use_case_text: e.target.value })}
|
||||
rows={4}
|
||||
placeholder="Beschreiben Sie den Anwendungsfall..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Branche</label>
|
||||
<select
|
||||
value={form.domain}
|
||||
onChange={e => updateForm({ domain: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
{DOMAINS.map(d => (
|
||||
<option key={d.value} value={d.value}>{d.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Datenkategorien */}
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Welche Daten werden verarbeitet?</h2>
|
||||
{[
|
||||
{ key: 'personal_data', label: 'Personenbezogene Daten', desc: 'Name, E-Mail, Adresse etc.' },
|
||||
{ key: 'special_categories', label: 'Besondere Kategorien (Art. 9)', desc: 'Religion, Gesundheit, politische Meinung' },
|
||||
{ key: 'health_data', label: 'Gesundheitsdaten', desc: 'Diagnosen, Medikation, Fitness' },
|
||||
{ key: 'biometric_data', label: 'Biometrische Daten', desc: 'Gesichtserkennung, Fingerabdruck, Stimme' },
|
||||
{ key: 'minors_data', label: 'Daten von Minderjaehrigen', desc: 'Unter 16 Jahren' },
|
||||
{ key: 'financial_data', label: 'Finanzdaten', desc: 'Kontodaten, Transaktionen, Kreditwuerdigkeit' },
|
||||
].map(item => (
|
||||
<label key={item.key} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form[item.key as keyof typeof form] as boolean}
|
||||
onChange={e => updateForm({ [item.key]: e.target.checked })}
|
||||
className="mt-1 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{item.label}</div>
|
||||
<div className="text-sm text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Verarbeitungszweck & Rechtsgrundlage */}
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Verarbeitungszweck & Rechtsgrundlage</h2>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsgrundlage (Art. 6 DSGVO)</label>
|
||||
<select
|
||||
value={form.legal_basis}
|
||||
onChange={e => updateForm({ legal_basis: e.target.value as typeof form.legal_basis })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="consent">Einwilligung (Art. 6 Abs. 1a)</option>
|
||||
<option value="contract">Vertragserfullung (Art. 6 Abs. 1b)</option>
|
||||
<option value="legal_obligation">Rechtliche Verpflichtung (Art. 6 Abs. 1c)</option>
|
||||
<option value="vital_interest">Lebenswichtige Interessen (Art. 6 Abs. 1d)</option>
|
||||
<option value="public_interest">Oeffentliches Interesse (Art. 6 Abs. 1e)</option>
|
||||
<option value="legitimate_interest">Berechtigtes Interesse (Art. 6 Abs. 1f)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-gray-700 mt-4">Zweck der Verarbeitung</h3>
|
||||
{[
|
||||
{ key: 'purpose_profiling', label: 'Profiling', desc: 'Automatisierte Analyse personenbezogener Aspekte' },
|
||||
{ key: 'purpose_automated_decision', label: 'Automatisierte Entscheidung', desc: 'Art. 22 DSGVO — Entscheidung ohne menschliches Zutun' },
|
||||
{ key: 'purpose_marketing', label: 'Marketing', desc: 'Werbung, Personalisierung, Targeting' },
|
||||
{ key: 'purpose_analytics', label: 'Analytics', desc: 'Statistische Auswertung, Business Intelligence' },
|
||||
{ key: 'purpose_service_delivery', label: 'Serviceerbringung', desc: 'Kernfunktion des Produkts/Services' },
|
||||
].map(item => (
|
||||
<label key={item.key} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form[item.key as keyof typeof form] as boolean}
|
||||
onChange={e => updateForm({ [item.key]: e.target.checked })}
|
||||
className="mt-1 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{item.label}</div>
|
||||
<div className="text-sm text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Automatisierung */}
|
||||
{currentStep === 4 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Grad der Automatisierung</h2>
|
||||
{[
|
||||
{ value: 'assistive', label: 'Assistiv', desc: 'KI unterstuetzt, Mensch entscheidet immer' },
|
||||
{ value: 'semi_automated', label: 'Teilautomatisiert', desc: 'KI schlaegt vor, Mensch prueft und bestaetigt' },
|
||||
{ value: 'fully_automated', label: 'Vollautomatisiert', desc: 'KI entscheidet autonom, Mensch ueberwacht nur' },
|
||||
].map(item => (
|
||||
<label
|
||||
key={item.value}
|
||||
className={`flex items-start gap-3 p-4 rounded-lg border-2 cursor-pointer transition-all ${
|
||||
form.automation === item.value
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="automation"
|
||||
value={item.value}
|
||||
checked={form.automation === item.value}
|
||||
onChange={e => updateForm({ automation: e.target.value as typeof form.automation })}
|
||||
className="mt-1 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{item.label}</div>
|
||||
<div className="text-sm text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 5: Hosting & Modell */}
|
||||
{currentStep === 5 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Technische Details</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Hosting</label>
|
||||
<select
|
||||
value={form.hosting_provider}
|
||||
onChange={e => updateForm({ hosting_provider: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="self_hosted">Eigenes Hosting</option>
|
||||
<option value="aws">AWS</option>
|
||||
<option value="azure">Microsoft Azure</option>
|
||||
<option value="gcp">Google Cloud</option>
|
||||
<option value="hetzner">Hetzner (DE)</option>
|
||||
<option value="other">Anderer Anbieter</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Region</label>
|
||||
<select
|
||||
value={form.hosting_region}
|
||||
onChange={e => updateForm({ hosting_region: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="eu">EU</option>
|
||||
<option value="de">Deutschland</option>
|
||||
<option value="us">USA</option>
|
||||
<option value="other">Andere</option>
|
||||
</select>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mt-4">Modell-Nutzung</h3>
|
||||
{[
|
||||
{ key: 'model_inference', label: 'Inferenz', desc: 'Vortrainiertes Modell nutzen (Standard)' },
|
||||
{ key: 'model_rag', label: 'RAG (Retrieval-Augmented)', desc: 'Eigene Daten als Kontext bereitstellen' },
|
||||
{ key: 'model_finetune', label: 'Fine-Tuning', desc: 'Modell mit eigenen Daten nachtrainieren' },
|
||||
{ key: 'model_training', label: 'Training', desc: 'Eigenes Modell von Grund auf trainieren' },
|
||||
].map(item => (
|
||||
<label key={item.key} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form[item.key as keyof typeof form] as boolean}
|
||||
onChange={e => updateForm({ [item.key]: e.target.checked })}
|
||||
className="mt-1 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{item.label}</div>
|
||||
<div className="text-sm text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 6: Internationaler Datentransfer */}
|
||||
{currentStep === 6 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Internationaler Datentransfer</h2>
|
||||
|
||||
<label className="flex items-start gap-3 p-4 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.international_transfer}
|
||||
onChange={e => updateForm({ international_transfer: e.target.checked })}
|
||||
className="mt-1 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Daten werden in Drittlaender uebermittelt</div>
|
||||
<div className="text-sm text-gray-500">Ausserhalb des EWR (z.B. USA, UK, Schweiz)</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{form.international_transfer && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ziellaender</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.transfer_countries.join(', ')}
|
||||
onChange={e => updateForm({ transfer_countries: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })}
|
||||
placeholder="z.B. USA, UK, CH"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Kommagetrennte Laenderkuerzel</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Transfer-Mechanismus</label>
|
||||
<select
|
||||
value={form.transfer_mechanism}
|
||||
onChange={e => updateForm({ transfer_mechanism: e.target.value as typeof form.transfer_mechanism })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="none">Noch nicht festgelegt</option>
|
||||
<option value="adequacy">Angemessenheitsbeschluss</option>
|
||||
<option value="scc">Standardvertragsklauseln (SCC)</option>
|
||||
<option value="bcr">Binding Corporate Rules (BCR)</option>
|
||||
<option value="derogation">Ausnahmeregelung (Art. 49 DSGVO)</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 7: Datenhaltung */}
|
||||
{currentStep === 7 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Datenhaltung & Aufbewahrung</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Aufbewahrungsdauer (Tage)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.retention_days}
|
||||
onChange={e => updateForm({ retention_days: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Zweck der Aufbewahrung
|
||||
</label>
|
||||
<textarea
|
||||
value={form.retention_purpose}
|
||||
onChange={e => updateForm({ retention_purpose: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="z.B. Vertragliche Pflichten, gesetzliche Aufbewahrungsfristen..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 8: Vertraege & Compliance */}
|
||||
{currentStep === 8 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Vertraege & Compliance-Dokumentation</h2>
|
||||
|
||||
{[
|
||||
{ key: 'has_dpa', label: 'Auftragsverarbeitungsvertrag (AVV/DPA)', desc: 'Vertrag mit KI-Anbieter / Subprozessor nach Art. 28 DSGVO' },
|
||||
{ key: 'has_aia_documentation', label: 'AI Act Dokumentation', desc: 'Risikoklassifizierung und technische Dokumentation nach EU AI Act' },
|
||||
{ key: 'has_risk_assessment', label: 'Risikobewertung / DSFA', desc: 'Datenschutz-Folgenabschaetzung nach Art. 35 DSGVO' },
|
||||
].map(item => (
|
||||
<label key={item.key} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form[item.key as keyof typeof form] as boolean}
|
||||
onChange={e => updateForm({ [item.key]: e.target.checked })}
|
||||
className="mt-1 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{item.label}</div>
|
||||
<div className="text-sm text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Subprozessoren</label>
|
||||
<textarea
|
||||
value={form.subprocessors}
|
||||
onChange={e => updateForm({ subprocessors: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="z.B. OpenAI (USA, SCC), Hetzner Cloud (DE)..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => currentStep > 1 ? setCurrentStep(currentStep - 1) : router.push('/sdk/use-cases')}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{currentStep === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||
</button>
|
||||
|
||||
{currentStep < 8 ? (
|
||||
<button
|
||||
onClick={() => setCurrentStep(currentStep + 1)}
|
||||
disabled={currentStep === 1 && !form.title}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !form.title}
|
||||
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Bewerte...
|
||||
</>
|
||||
) : (
|
||||
isEditMode ? 'Speichern & neu bewerten' : 'Assessment starten'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NewUseCasePage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex items-center justify-center h-64 text-gray-500">Lade...</div>}>
|
||||
<NewUseCasePageInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
260
admin-compliance/app/sdk/use-cases/page.tsx
Normal file
260
admin-compliance/app/sdk/use-cases/page.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { RiskScoreGauge } from '@/components/sdk/use-case-assessment/RiskScoreGauge'
|
||||
|
||||
interface Assessment {
|
||||
id: string
|
||||
title: string
|
||||
feasibility: string
|
||||
risk_level: string
|
||||
risk_score: number
|
||||
domain: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const FEASIBILITY_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||
YES: { bg: 'bg-green-100', text: 'text-green-700', label: 'Machbar' },
|
||||
CONDITIONAL: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Bedingt' },
|
||||
NO: { bg: 'bg-red-100', text: 'text-red-700', label: 'Nein' },
|
||||
}
|
||||
|
||||
export default function UseCasesPage() {
|
||||
const [assessments, setAssessments] = useState<Assessment[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [filterFeasibility, setFilterFeasibility] = useState<string>('all')
|
||||
const [filterRisk, setFilterRisk] = useState<string>('all')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [page, setPage] = useState(0)
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
useEffect(() => {
|
||||
fetchAssessments()
|
||||
}, [page, searchQuery])
|
||||
|
||||
async function fetchAssessments() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const params = new URLSearchParams({
|
||||
limit: String(PAGE_SIZE),
|
||||
offset: String(page * PAGE_SIZE),
|
||||
})
|
||||
if (searchQuery) params.set('search', searchQuery)
|
||||
|
||||
const response = await fetch(`/api/sdk/v1/ucca/assessments?${params}`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Fehler beim Laden der Assessments')
|
||||
}
|
||||
const data = await response.json()
|
||||
setAssessments(data.assessments || [])
|
||||
setTotalCount(data.total || data.assessments?.length || 0)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = assessments.filter(a => {
|
||||
if (filterFeasibility !== 'all' && a.feasibility !== filterFeasibility) return false
|
||||
if (filterRisk !== 'all' && a.risk_level !== filterRisk) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const stats = {
|
||||
total: assessments.length,
|
||||
feasible: assessments.filter(a => a.feasibility === 'YES').length,
|
||||
conditional: assessments.filter(a => a.feasibility === 'CONDITIONAL').length,
|
||||
rejected: assessments.filter(a => a.feasibility === 'NO').length,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Use Case Assessment</h1>
|
||||
<p className="mt-1 text-gray-500">
|
||||
KI-Anwendungsfaelle erfassen und auf Compliance pruefen
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/sdk/use-cases/new"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Neues Assessment
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{stats.total}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Machbar</div>
|
||||
<div className="text-3xl font-bold text-green-600">{stats.feasible}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-yellow-200 p-6">
|
||||
<div className="text-sm text-yellow-600">Bedingt</div>
|
||||
<div className="text-3xl font-bold text-yellow-600">{stats.conditional}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-red-200 p-6">
|
||||
<div className="text-sm text-red-600">Abgelehnt</div>
|
||||
<div className="text-3xl font-bold text-red-600">{stats.rejected}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Assessments durchsuchen..."
|
||||
value={searchQuery}
|
||||
onChange={e => { setSearchQuery(e.target.value); setPage(0) }}
|
||||
className="w-full px-4 py-2 pl-10 bg-white border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
<svg className="absolute left-3 top-2.5 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Machbarkeit:</span>
|
||||
{['all', 'YES', 'CONDITIONAL', 'NO'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilterFeasibility(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filterFeasibility === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' : FEASIBILITY_STYLES[f]?.label || f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Risiko:</span>
|
||||
{['all', 'MINIMAL', 'LOW', 'MEDIUM', 'HIGH', 'UNACCEPTABLE'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilterRisk(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filterRisk === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' : f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
|
||||
{error}
|
||||
<button onClick={fetchAssessments} className="ml-3 underline">Erneut versuchen</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="text-center py-12 text-gray-500">Lade Assessments...</div>
|
||||
)}
|
||||
|
||||
{/* Assessment List */}
|
||||
{!loading && filtered.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{filtered.map(assessment => {
|
||||
const feasibility = FEASIBILITY_STYLES[assessment.feasibility] || FEASIBILITY_STYLES.YES
|
||||
return (
|
||||
<Link
|
||||
key={assessment.id}
|
||||
href={`/sdk/use-cases/${assessment.id}`}
|
||||
className="block bg-white rounded-xl border border-gray-200 p-6 hover:border-purple-300 hover:shadow-md transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-6">
|
||||
<RiskScoreGauge score={assessment.risk_score} riskLevel={assessment.risk_level} size="sm" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{assessment.title || 'Unbenanntes Assessment'}</h3>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${feasibility.bg} ${feasibility.text}`}>
|
||||
{feasibility.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span>{assessment.domain}</span>
|
||||
<span>{new Date(assessment.created_at).toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg className="w-5 h-5 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>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{!loading && totalCount > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, totalCount)} von {totalCount}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-3 py-1 text-sm bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Zurueck
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={(page + 1) * PAGE_SIZE >= totalCount}
|
||||
className="px-3 py-1 text-sm bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && filtered.length === 0 && !error && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Noch keine Assessments</h3>
|
||||
<p className="mt-2 text-gray-500 mb-4">
|
||||
Erstellen Sie Ihr erstes Use Case Assessment, um die Compliance-Bewertung zu starten.
|
||||
</p>
|
||||
<Link
|
||||
href="/sdk/use-cases/new"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
Erstes Assessment erstellen
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user