Interaktiver 12-Fragen-Entscheidungsbaum für die AI Act Klassifikation auf zwei Achsen: High-Risk (Anhang III, Q1-Q7) und GPAI (Art. 51-56, Q8-Q12). Deterministische Auswertung ohne LLM. Backend (Go): - Neue Structs: GPAIClassification, DecisionTreeAnswer, DecisionTreeResult - Decision Tree Engine mit BuildDecisionTreeDefinition() und EvaluateDecisionTree() - Store-Methoden für CRUD der Ergebnisse - API-Endpoints: GET/POST /decision-tree, GET/DELETE /decision-tree/results - 12 Unit Tests (alle bestanden) Frontend (Next.js): - DecisionTreeWizard: Wizard-UI mit Ja/Nein-Fragen, Dual-Progress-Bar, Ergebnis-Ansicht - AI Act Page refactored: Tabs (Übersicht | Entscheidungsbaum | Ergebnisse) - Proxy-Route für decision-tree Endpoints Migration 083: ai_act_decision_tree_results Tabelle Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
555 lines
23 KiB
TypeScript
555 lines
23 KiB
TypeScript
'use client'
|
||
|
||
import React, { useState, useEffect, useCallback } from 'react'
|
||
|
||
// =============================================================================
|
||
// TYPES
|
||
// =============================================================================
|
||
|
||
interface DecisionTreeQuestion {
|
||
id: string
|
||
axis: 'high_risk' | 'gpai'
|
||
question: string
|
||
description: string
|
||
article_ref: string
|
||
skip_if?: string
|
||
}
|
||
|
||
interface DecisionTreeDefinition {
|
||
id: string
|
||
name: string
|
||
version: string
|
||
questions: DecisionTreeQuestion[]
|
||
}
|
||
|
||
interface DecisionTreeAnswer {
|
||
question_id: string
|
||
value: boolean
|
||
note?: string
|
||
}
|
||
|
||
interface GPAIClassification {
|
||
is_gpai: boolean
|
||
is_systemic_risk: boolean
|
||
gpai_category: 'none' | 'standard' | 'systemic'
|
||
applicable_articles: string[]
|
||
obligations: string[]
|
||
}
|
||
|
||
interface DecisionTreeResult {
|
||
id: string
|
||
tenant_id: string
|
||
system_name: string
|
||
system_description?: string
|
||
answers: Record<string, DecisionTreeAnswer>
|
||
high_risk_result: string
|
||
gpai_result: GPAIClassification
|
||
combined_obligations: string[]
|
||
applicable_articles: string[]
|
||
created_at: string
|
||
}
|
||
|
||
// =============================================================================
|
||
// CONSTANTS
|
||
// =============================================================================
|
||
|
||
const RISK_LEVEL_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
|
||
unacceptable: { label: 'Unzulässig', color: 'text-red-700', bg: 'bg-red-50', border: 'border-red-200' },
|
||
high_risk: { label: 'Hochrisiko', color: 'text-orange-700', bg: 'bg-orange-50', border: 'border-orange-200' },
|
||
limited_risk: { label: 'Begrenztes Risiko', color: 'text-yellow-700', bg: 'bg-yellow-50', border: 'border-yellow-200' },
|
||
minimal_risk: { label: 'Minimales Risiko', color: 'text-green-700', bg: 'bg-green-50', border: 'border-green-200' },
|
||
not_applicable: { label: 'Nicht anwendbar', color: 'text-gray-500', bg: 'bg-gray-50', border: 'border-gray-200' },
|
||
}
|
||
|
||
const GPAI_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
|
||
none: { label: 'Kein GPAI', color: 'text-gray-500', bg: 'bg-gray-50', border: 'border-gray-200' },
|
||
standard: { label: 'GPAI Standard', color: 'text-blue-700', bg: 'bg-blue-50', border: 'border-blue-200' },
|
||
systemic: { label: 'GPAI Systemisches Risiko', color: 'text-purple-700', bg: 'bg-purple-50', border: 'border-purple-200' },
|
||
}
|
||
|
||
// =============================================================================
|
||
// MAIN COMPONENT
|
||
// =============================================================================
|
||
|
||
export default function DecisionTreeWizard() {
|
||
const [definition, setDefinition] = useState<DecisionTreeDefinition | null>(null)
|
||
const [answers, setAnswers] = useState<Record<string, DecisionTreeAnswer>>({})
|
||
const [currentIdx, setCurrentIdx] = useState(0)
|
||
const [systemName, setSystemName] = useState('')
|
||
const [systemDescription, setSystemDescription] = useState('')
|
||
const [result, setResult] = useState<DecisionTreeResult | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [saving, setSaving] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [phase, setPhase] = useState<'intro' | 'questions' | 'result'>('intro')
|
||
|
||
// Load decision tree definition
|
||
useEffect(() => {
|
||
const load = async () => {
|
||
try {
|
||
const res = await fetch('/api/sdk/v1/ucca/decision-tree')
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
setDefinition(data)
|
||
} else {
|
||
setError('Entscheidungsbaum konnte nicht geladen werden')
|
||
}
|
||
} catch {
|
||
setError('Verbindung zum Backend fehlgeschlagen')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
load()
|
||
}, [])
|
||
|
||
// Get visible questions (respecting skip logic)
|
||
const getVisibleQuestions = useCallback((): DecisionTreeQuestion[] => {
|
||
if (!definition) return []
|
||
return definition.questions.filter(q => {
|
||
if (!q.skip_if) return true
|
||
// Skip this question if the gate question was answered "no"
|
||
const gateAnswer = answers[q.skip_if]
|
||
if (gateAnswer && !gateAnswer.value) return false
|
||
return true
|
||
})
|
||
}, [definition, answers])
|
||
|
||
const visibleQuestions = getVisibleQuestions()
|
||
const currentQuestion = visibleQuestions[currentIdx]
|
||
const totalVisible = visibleQuestions.length
|
||
|
||
const highRiskQuestions = visibleQuestions.filter(q => q.axis === 'high_risk')
|
||
const gpaiQuestions = visibleQuestions.filter(q => q.axis === 'gpai')
|
||
|
||
const handleAnswer = (value: boolean) => {
|
||
if (!currentQuestion) return
|
||
setAnswers(prev => ({
|
||
...prev,
|
||
[currentQuestion.id]: {
|
||
question_id: currentQuestion.id,
|
||
value,
|
||
},
|
||
}))
|
||
|
||
// Auto-advance
|
||
if (currentIdx < totalVisible - 1) {
|
||
setCurrentIdx(prev => prev + 1)
|
||
}
|
||
}
|
||
|
||
const handleBack = () => {
|
||
if (currentIdx > 0) {
|
||
setCurrentIdx(prev => prev - 1)
|
||
}
|
||
}
|
||
|
||
const handleSubmit = async () => {
|
||
setSaving(true)
|
||
setError(null)
|
||
|
||
try {
|
||
const res = await fetch('/api/sdk/v1/ucca/decision-tree/evaluate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
system_name: systemName,
|
||
system_description: systemDescription,
|
||
answers,
|
||
}),
|
||
})
|
||
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
setResult(data)
|
||
setPhase('result')
|
||
} else {
|
||
const err = await res.json().catch(() => ({ error: 'Auswertung fehlgeschlagen' }))
|
||
setError(err.error || 'Auswertung fehlgeschlagen')
|
||
}
|
||
} catch {
|
||
setError('Verbindung zum Backend fehlgeschlagen')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const handleReset = () => {
|
||
setAnswers({})
|
||
setCurrentIdx(0)
|
||
setSystemName('')
|
||
setSystemDescription('')
|
||
setResult(null)
|
||
setPhase('intro')
|
||
setError(null)
|
||
}
|
||
|
||
const allAnswered = visibleQuestions.every(q => answers[q.id] !== undefined)
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||
<div className="w-10 h-10 border-2 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||
<p className="text-gray-500">Entscheidungsbaum wird geladen...</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (error && !definition) {
|
||
return (
|
||
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
|
||
<p className="text-red-700">{error}</p>
|
||
<p className="text-red-500 text-sm mt-2">Bitte stellen Sie sicher, dass der AI Compliance SDK Service läuft.</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// =========================================================================
|
||
// INTRO PHASE
|
||
// =========================================================================
|
||
if (phase === 'intro') {
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||
<h3 className="text-lg font-semibold text-gray-900 mb-2">AI Act Entscheidungsbaum</h3>
|
||
<p className="text-sm text-gray-500 mb-6">
|
||
Klassifizieren Sie Ihr KI-System anhand von 12 Fragen auf zwei Achsen:
|
||
<strong> High-Risk</strong> (Anhang III) und <strong>GPAI</strong> (Art. 51–56).
|
||
</p>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||
<div className="p-4 bg-orange-50 border border-orange-200 rounded-lg">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<svg className="w-5 h-5 text-orange-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126z" />
|
||
</svg>
|
||
<span className="font-medium text-orange-700">Achse 1: High-Risk</span>
|
||
</div>
|
||
<p className="text-sm text-orange-600">7 Fragen zu Anhang III Kategorien (Biometrie, kritische Infrastruktur, Bildung, Beschäftigung, etc.)</p>
|
||
</div>
|
||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<svg className="w-5 h-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||
</svg>
|
||
<span className="font-medium text-blue-700">Achse 2: GPAI</span>
|
||
</div>
|
||
<p className="text-sm text-blue-600">5 Fragen zu General-Purpose AI (Foundation Models, systemisches Risiko, Art. 51–56)</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Name des KI-Systems *</label>
|
||
<input
|
||
type="text"
|
||
value={systemName}
|
||
onChange={e => setSystemName(e.target.value)}
|
||
placeholder="z.B. Dokumenten-Analyse-KI, Chatbot-Service, Code-Assistent"
|
||
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 (optional)</label>
|
||
<textarea
|
||
value={systemDescription}
|
||
onChange={e => setSystemDescription(e.target.value)}
|
||
placeholder="Kurze Beschreibung des KI-Systems und seines Einsatzzwecks..."
|
||
rows={2}
|
||
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>
|
||
|
||
<div className="mt-6 flex justify-end">
|
||
<button
|
||
onClick={() => setPhase('questions')}
|
||
disabled={!systemName.trim()}
|
||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||
systemName.trim()
|
||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||
}`}
|
||
>
|
||
Klassifizierung starten
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// =========================================================================
|
||
// RESULT PHASE
|
||
// =========================================================================
|
||
if (phase === 'result' && result) {
|
||
const riskConfig = RISK_LEVEL_CONFIG[result.high_risk_result] || RISK_LEVEL_CONFIG.not_applicable
|
||
const gpaiConfig = GPAI_CONFIG[result.gpai_result.gpai_category] || GPAI_CONFIG.none
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Header */}
|
||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-lg font-semibold text-gray-900">Klassifizierungsergebnis: {result.system_name}</h3>
|
||
<button
|
||
onClick={handleReset}
|
||
className="px-4 py-2 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||
>
|
||
Neue Klassifizierung
|
||
</button>
|
||
</div>
|
||
|
||
{/* Two-Axis Result Cards */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||
<div className={`p-5 rounded-xl border-2 ${riskConfig.border} ${riskConfig.bg}`}>
|
||
<div className="text-sm font-medium text-gray-500 mb-1">Achse 1: High-Risk (Anhang III)</div>
|
||
<div className={`text-xl font-bold ${riskConfig.color}`}>{riskConfig.label}</div>
|
||
</div>
|
||
<div className={`p-5 rounded-xl border-2 ${gpaiConfig.border} ${gpaiConfig.bg}`}>
|
||
<div className="text-sm font-medium text-gray-500 mb-1">Achse 2: GPAI (Art. 51–56)</div>
|
||
<div className={`text-xl font-bold ${gpaiConfig.color}`}>{gpaiConfig.label}</div>
|
||
{result.gpai_result.is_systemic_risk && (
|
||
<div className="mt-1 text-xs text-purple-600 font-medium">Systemisches Risiko</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Applicable Articles */}
|
||
{result.applicable_articles && result.applicable_articles.length > 0 && (
|
||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||
<h4 className="text-sm font-semibold text-gray-900 mb-3">Anwendbare Artikel</h4>
|
||
<div className="flex flex-wrap gap-2">
|
||
{result.applicable_articles.map(art => (
|
||
<span key={art} className="px-3 py-1 text-xs bg-indigo-50 text-indigo-700 rounded-full border border-indigo-200">
|
||
{art}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Combined Obligations */}
|
||
{result.combined_obligations && result.combined_obligations.length > 0 && (
|
||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||
<h4 className="text-sm font-semibold text-gray-900 mb-3">
|
||
Pflichten ({result.combined_obligations.length})
|
||
</h4>
|
||
<div className="space-y-2">
|
||
{result.combined_obligations.map((obl, i) => (
|
||
<div key={i} className="flex items-start gap-2 text-sm">
|
||
<svg className="w-4 h-4 text-purple-500 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
<span className="text-gray-700">{obl}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* GPAI-specific obligations */}
|
||
{result.gpai_result.is_gpai && result.gpai_result.obligations.length > 0 && (
|
||
<div className="bg-blue-50 rounded-xl border border-blue-200 p-6">
|
||
<h4 className="text-sm font-semibold text-blue-900 mb-3">
|
||
GPAI-spezifische Pflichten ({result.gpai_result.obligations.length})
|
||
</h4>
|
||
<div className="space-y-2">
|
||
{result.gpai_result.obligations.map((obl, i) => (
|
||
<div key={i} className="flex items-start gap-2 text-sm">
|
||
<svg className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||
</svg>
|
||
<span className="text-blue-800">{obl}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Answer Summary */}
|
||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||
<h4 className="text-sm font-semibold text-gray-900 mb-3">Ihre Antworten</h4>
|
||
<div className="space-y-2">
|
||
{definition?.questions.map(q => {
|
||
const answer = result.answers[q.id]
|
||
if (!answer) return null
|
||
return (
|
||
<div key={q.id} className="flex items-center gap-3 text-sm py-1.5 border-b border-gray-100 last:border-0">
|
||
<span className="text-xs font-mono text-gray-400 w-8">{q.id}</span>
|
||
<span className="flex-1 text-gray-600">{q.question}</span>
|
||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||
answer.value ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
|
||
}`}>
|
||
{answer.value ? 'Ja' : 'Nein'}
|
||
</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// =========================================================================
|
||
// QUESTIONS PHASE
|
||
// =========================================================================
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Progress */}
|
||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<span className="text-sm font-medium text-gray-700">
|
||
{systemName} — Frage {currentIdx + 1} von {totalVisible}
|
||
</span>
|
||
<span className={`px-2 py-1 text-xs rounded-full font-medium ${
|
||
currentQuestion?.axis === 'high_risk'
|
||
? 'bg-orange-100 text-orange-700'
|
||
: 'bg-blue-100 text-blue-700'
|
||
}`}>
|
||
{currentQuestion?.axis === 'high_risk' ? 'High-Risk' : 'GPAI'}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Dual progress bar */}
|
||
<div className="flex gap-2">
|
||
<div className="flex-1">
|
||
<div className="text-[10px] text-orange-600 mb-1 font-medium">
|
||
Achse 1: High-Risk ({highRiskQuestions.filter(q => answers[q.id] !== undefined).length}/{highRiskQuestions.length})
|
||
</div>
|
||
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||
<div
|
||
className="h-full bg-orange-500 rounded-full transition-all"
|
||
style={{ width: `${highRiskQuestions.length ? (highRiskQuestions.filter(q => answers[q.id] !== undefined).length / highRiskQuestions.length) * 100 : 0}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="flex-1">
|
||
<div className="text-[10px] text-blue-600 mb-1 font-medium">
|
||
Achse 2: GPAI ({gpaiQuestions.filter(q => answers[q.id] !== undefined).length}/{gpaiQuestions.length})
|
||
</div>
|
||
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||
<div
|
||
className="h-full bg-blue-500 rounded-full transition-all"
|
||
style={{ width: `${gpaiQuestions.length ? (gpaiQuestions.filter(q => answers[q.id] !== undefined).length / gpaiQuestions.length) * 100 : 0}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</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>
|
||
)}
|
||
|
||
{/* Current Question */}
|
||
{currentQuestion && (
|
||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||
<div className="flex items-start gap-3 mb-4">
|
||
<span className="px-2 py-1 text-xs font-mono bg-gray-100 text-gray-500 rounded">{currentQuestion.id}</span>
|
||
<span className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded">{currentQuestion.article_ref}</span>
|
||
</div>
|
||
|
||
<h3 className="text-lg font-semibold text-gray-900 mb-3">{currentQuestion.question}</h3>
|
||
<p className="text-sm text-gray-500 mb-6">{currentQuestion.description}</p>
|
||
|
||
{/* Answer buttons */}
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<button
|
||
onClick={() => handleAnswer(true)}
|
||
className={`p-4 rounded-xl border-2 transition-all text-center font-medium ${
|
||
answers[currentQuestion.id]?.value === true
|
||
? 'border-green-500 bg-green-50 text-green-700'
|
||
: 'border-gray-200 hover:border-green-300 hover:bg-green-50/50 text-gray-700'
|
||
}`}
|
||
>
|
||
<svg className="w-8 h-8 mx-auto mb-2 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
Ja
|
||
</button>
|
||
<button
|
||
onClick={() => handleAnswer(false)}
|
||
className={`p-4 rounded-xl border-2 transition-all text-center font-medium ${
|
||
answers[currentQuestion.id]?.value === false
|
||
? 'border-gray-500 bg-gray-50 text-gray-700'
|
||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50/50 text-gray-700'
|
||
}`}
|
||
>
|
||
<svg className="w-8 h-8 mx-auto mb-2 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
Nein
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Navigation */}
|
||
<div className="flex items-center justify-between">
|
||
<button
|
||
onClick={currentIdx === 0 ? () => setPhase('intro') : handleBack}
|
||
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||
>
|
||
Zurück
|
||
</button>
|
||
|
||
<div className="flex items-center gap-1">
|
||
{visibleQuestions.map((q, i) => (
|
||
<button
|
||
key={q.id}
|
||
onClick={() => setCurrentIdx(i)}
|
||
className={`w-2.5 h-2.5 rounded-full transition-colors ${
|
||
i === currentIdx
|
||
? q.axis === 'high_risk' ? 'bg-orange-500' : 'bg-blue-500'
|
||
: answers[q.id] !== undefined
|
||
? 'bg-green-400'
|
||
: 'bg-gray-200'
|
||
}`}
|
||
title={`${q.id}: ${q.question}`}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{allAnswered ? (
|
||
<button
|
||
onClick={handleSubmit}
|
||
disabled={saving}
|
||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||
saving
|
||
? 'bg-purple-300 text-white cursor-wait'
|
||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||
}`}
|
||
>
|
||
{saving ? (
|
||
<span className="flex items-center gap-2">
|
||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||
</svg>
|
||
Auswertung...
|
||
</span>
|
||
) : (
|
||
'Auswerten'
|
||
)}
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={() => setCurrentIdx(prev => Math.min(prev + 1, totalVisible - 1))}
|
||
disabled={currentIdx >= totalVisible - 1}
|
||
className="px-4 py-2 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors disabled:opacity-30"
|
||
>
|
||
Weiter
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|