feat: IACE Interview Frontend — 3 Modi (Interview/Wizard/Formular)
CE-Risikobeurteilung Datenerfassung mit 3 wählbaren Eingabe-Modi: 1. Interview-Modus (Chat-artig): Fragen werden nacheinander gestellt wie im Kundengespräch. Antwort-Historie sichtbar. 2. Wizard-Modus: Schritt-für-Schritt durch 8 Sektionen. 3. Formular-Modus: Alle Sektionen als Accordion auf einer Seite. 20 strukturierte Fragen in 8 Abschnitten: - Maschinenbeschreibung (Name, Typ, Baugruppen) - Lebensphasen (Betrieb, Einrichten, Wartung) - Bestimmungsgemäße Verwendung - Vorhersehbare Fehlanwendung - Qualifikation der Benutzer - Räumliche/Zeitliche Grenzen - Technische Daten (Kräfte, Spannungen, Temperaturen, Drehzahlen) - Umgebungsbedingungen answersToNarrativeText() konvertiert alle Antworten in den Freitext der an POST /parse-narrative gesendet wird. Ergebnis-Panel zeigt: Komponenten, Gefahren, Patterns, Energiequellen. URL: /sdk/iace/[projectId]/interview Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
|||||||
|
// IACE Interview Types — structured questions based on CE risk assessment document structure
|
||||||
|
|
||||||
|
export interface InterviewQuestion {
|
||||||
|
id: string
|
||||||
|
section: number
|
||||||
|
sectionTitle: string
|
||||||
|
question: string
|
||||||
|
type: 'text' | 'textarea' | 'select' | 'multiselect' | 'number'
|
||||||
|
options?: string[]
|
||||||
|
placeholder?: string
|
||||||
|
helpText?: string
|
||||||
|
required?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InterviewAnswer {
|
||||||
|
questionId: string
|
||||||
|
value: string | string[] | number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INTERVIEW_QUESTIONS: InterviewQuestion[] = [
|
||||||
|
// Section 1: Maschinenbeschreibung
|
||||||
|
{ id: 'machine_name', section: 1, sectionTitle: 'Maschinenbeschreibung', question: 'Wie heisst die Maschine / Anlage?', type: 'text', placeholder: 'z.B. Kniehebelpresse HP-500', required: true },
|
||||||
|
{ id: 'machine_type', section: 1, sectionTitle: 'Maschinenbeschreibung', question: 'Welcher Maschinentyp ist es?', type: 'select', options: ['Presse', 'Roboter', 'CNC-Maschine', 'Foerderanlage', 'Verpackungsmaschine', 'Schweissanlage', 'Montageanlage', 'Sondermaschine'], required: true },
|
||||||
|
{ id: 'manufacturer', section: 1, sectionTitle: 'Maschinenbeschreibung', question: 'Wer ist der Hersteller?', type: 'text', placeholder: 'z.B. Mueller Maschinenbau GmbH' },
|
||||||
|
{ id: 'description', section: 1, sectionTitle: 'Maschinenbeschreibung', question: 'Beschreiben Sie die Anlage und ihre Funktion:', type: 'textarea', placeholder: 'Die Anlage ist eine vollautomatische...', helpText: 'Beschreiben Sie den Zweck, die Arbeitsweise und den Aufbau der Maschine.', required: true },
|
||||||
|
{ id: 'components', section: 1, sectionTitle: 'Maschinenbeschreibung', question: 'Aus welchen Baugruppen besteht die Anlage?', type: 'multiselect', options: ['Zufuehrung', 'Presse/Umformung', 'Transferanlage', 'Foerderband', 'Roboter', 'Absaugung', 'Schmieranlage', 'Schutzumhausung', 'Aufzug/Hubwerk', 'Schaltschrank/Steuerung', 'Kuehlung', 'Heizung', 'Hydraulik', 'Pneumatik'] },
|
||||||
|
|
||||||
|
// Section 2: Lebensphasen
|
||||||
|
{ id: 'lifecycle_operation', section: 2, sectionTitle: 'Lebensphasen', question: 'Wie laeuft der Normalbetrieb ab?', type: 'textarea', placeholder: 'Die Bearbeitung erfolgt vollautomatisch...', helpText: 'Beschreiben Sie den typischen Produktionszyklus.' },
|
||||||
|
{ id: 'lifecycle_setup', section: 2, sectionTitle: 'Lebensphasen', question: 'Welche Arbeiten fallen beim Einrichten/Umruesten an?', type: 'textarea', placeholder: 'Werkzeugwechsel, Parameteranpassung...' },
|
||||||
|
{ id: 'lifecycle_maintenance', section: 2, sectionTitle: 'Lebensphasen', question: 'Welche Wartungs- und Reinigungsarbeiten sind noetig?', type: 'textarea', placeholder: 'Woechentliche Schmierung, Filter reinigen...' },
|
||||||
|
|
||||||
|
// Section 3: Bestimmungsgemäße Verwendung
|
||||||
|
{ id: 'intended_use', section: 3, sectionTitle: 'Bestimmungsgemäße Verwendung', question: 'Wozu dient die Maschine (bestimmungsgemäße Verwendung)?', type: 'textarea', placeholder: 'Die Anlage dient der automatischen...', required: true },
|
||||||
|
|
||||||
|
// Section 4: Vorhersehbare Fehlanwendung
|
||||||
|
{ id: 'misuse', section: 4, sectionTitle: 'Vorhersehbare Fehlanwendung', question: 'Welche vorhersehbaren Fehlanwendungen sind moeglich?', type: 'multiselect', options: ['Ueberschreiten von Belastungsgrenzen', 'Verwendung ungeeigneter Materialien', 'Betrieb in explosionsgefaehrdeter Atmosphaere', 'Betrieb bei Leckagen', 'Betrieb ohne PSA', 'Umgehung von Sicherheitseinrichtungen', 'Bedienung ohne Einweisung', 'Manipulation der Steuerung'], helpText: 'Waehlen Sie alle zutreffenden oder ergaenzen Sie.' },
|
||||||
|
|
||||||
|
// Section 5: Qualifikation
|
||||||
|
{ id: 'operator_qualification', section: 5, sectionTitle: 'Qualifikation der Benutzer', question: 'Welche Qualifikation hat das Bedienpersonal?', type: 'select', options: ['Eingewiesenes Personal ohne Fachkenntnisse', 'Angelernte Mitarbeiter', 'Facharbeiter mit Berufsausbildung', 'Ingenieure/Techniker', 'Elektrofachkraefte'] },
|
||||||
|
{ id: 'maintenance_qualification', section: 5, sectionTitle: 'Qualifikation der Benutzer', question: 'Wer fuehrt Wartung/Instandhaltung durch?', type: 'select', options: ['Eigenes Fachpersonal', 'Hersteller-Service', 'Fremdfirma', 'Nicht separat betrachtet (CE-Erklaerung Lieferant)'] },
|
||||||
|
|
||||||
|
// Section 6: Grenzen
|
||||||
|
{ id: 'spatial_limits', section: 6, sectionTitle: 'Raeumliche und zeitliche Grenzen', question: 'Welche Gefahrenbereiche gibt es?', type: 'textarea', placeholder: 'Werkzeugeinbauraum, Zufuehrbereich, Auslaufbereich...', helpText: 'Listen Sie alle Bereiche auf, in denen Personen gefaehrdet sein koennten.' },
|
||||||
|
{ id: 'safety_measures_org', section: 6, sectionTitle: 'Raeumliche und zeitliche Grenzen', question: 'Welche organisatorischen Schutzmassnahmen gelten?', type: 'multiselect', options: ['Sicherheitsschuhe Pflicht', 'Gehoerschutz Pflicht', 'Handschuhe Pflicht', 'Schutzbrille Pflicht', 'Zutrittsbeschraenkung', 'Unterweisung vor Zugang'] },
|
||||||
|
|
||||||
|
// Section 7: Technische Daten
|
||||||
|
{ id: 'force_pressure', section: 7, sectionTitle: 'Technische Daten', question: 'Welche Kraefte/Druecke wirken? (kN, bar, Tonnen)', type: 'text', placeholder: 'z.B. 20000 kN, 250 bar' },
|
||||||
|
{ id: 'voltage', section: 7, sectionTitle: 'Technische Daten', question: 'Welche Spannungen sind vorhanden? (V)', type: 'text', placeholder: 'z.B. 400V Hauptstrom, 24V Steuerung' },
|
||||||
|
{ id: 'temperature', section: 7, sectionTitle: 'Technische Daten', question: 'Treten erhoehte Temperaturen auf? (°C)', type: 'text', placeholder: 'z.B. 130°C Werkstuecktemperatur' },
|
||||||
|
{ id: 'speed_rpm', section: 7, sectionTitle: 'Technische Daten', question: 'Welche Geschwindigkeiten/Drehzahlen gibt es? (/min, m/s)', type: 'text', placeholder: 'z.B. 736 /min Schwungrad, 36 Huebe/min' },
|
||||||
|
{ id: 'energy', section: 7, sectionTitle: 'Technische Daten', question: 'Welches Arbeitsvermoegen hat die Maschine? (kJ, kW)', type: 'text', placeholder: 'z.B. 400 kJ, 3 kW Motor' },
|
||||||
|
|
||||||
|
// Section 8: Umgebung
|
||||||
|
{ id: 'environment', section: 8, sectionTitle: 'Umgebungsbedingungen', question: 'Unter welchen Umgebungsbedingungen wird die Maschine betrieben?', type: 'textarea', placeholder: '+5 bis +40°C, max. 95% Luftfeuchte, bis 1000m ueNN', helpText: 'Temperatur, Luftfeuchte, Hoehenlage, besondere Bedingungen.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function answersToNarrativeText(answers: InterviewAnswer[]): string {
|
||||||
|
const parts: string[] = []
|
||||||
|
const getVal = (id: string) => {
|
||||||
|
const a = answers.find(a => a.questionId === id)
|
||||||
|
if (!a) return ''
|
||||||
|
return Array.isArray(a.value) ? (a.value as string[]).join(', ') : String(a.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(`Maschinenname: ${getVal('machine_name')}. Maschinentyp: ${getVal('machine_type')}. Hersteller: ${getVal('manufacturer')}.`)
|
||||||
|
if (getVal('description')) parts.push(getVal('description'))
|
||||||
|
if (getVal('components')) parts.push(`Baugruppen: ${getVal('components')}.`)
|
||||||
|
if (getVal('lifecycle_operation')) parts.push(`Betrieb: ${getVal('lifecycle_operation')}`)
|
||||||
|
if (getVal('lifecycle_setup')) parts.push(`Einrichten: ${getVal('lifecycle_setup')}`)
|
||||||
|
if (getVal('lifecycle_maintenance')) parts.push(`Wartung: ${getVal('lifecycle_maintenance')}`)
|
||||||
|
if (getVal('intended_use')) parts.push(`Bestimmungsgemäße Verwendung: ${getVal('intended_use')}`)
|
||||||
|
if (getVal('misuse')) parts.push(`Vorhersehbare Fehlanwendung: ${getVal('misuse')}`)
|
||||||
|
if (getVal('operator_qualification')) parts.push(`Bedienpersonal: ${getVal('operator_qualification')}`)
|
||||||
|
if (getVal('spatial_limits')) parts.push(`Gefahrenbereiche: ${getVal('spatial_limits')}`)
|
||||||
|
if (getVal('safety_measures_org')) parts.push(`Organisatorische Massnahmen: ${getVal('safety_measures_org')}`)
|
||||||
|
if (getVal('force_pressure')) parts.push(getVal('force_pressure'))
|
||||||
|
if (getVal('voltage')) parts.push(getVal('voltage'))
|
||||||
|
if (getVal('temperature')) parts.push(getVal('temperature'))
|
||||||
|
if (getVal('speed_rpm')) parts.push(getVal('speed_rpm'))
|
||||||
|
if (getVal('energy')) parts.push(getVal('energy'))
|
||||||
|
if (getVal('environment')) parts.push(`Umgebung: ${getVal('environment')}`)
|
||||||
|
|
||||||
|
return parts.join('\n')
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { INTERVIEW_QUESTIONS, answersToNarrativeText, type InterviewAnswer, type InterviewQuestion } from './_types'
|
||||||
|
|
||||||
|
type InputMode = 'interview' | 'wizard' | 'form'
|
||||||
|
|
||||||
|
export default function IACEInterviewPage() {
|
||||||
|
const { projectId } = useParams<{ projectId: string }>()
|
||||||
|
const [mode, setMode] = useState<InputMode>('interview')
|
||||||
|
const [answers, setAnswers] = useState<InterviewAnswer[]>([])
|
||||||
|
const [currentQ, setCurrentQ] = useState(0)
|
||||||
|
const [currentSection, setCurrentSection] = useState(1)
|
||||||
|
const [analyzing, setAnalyzing] = useState(false)
|
||||||
|
const [result, setResult] = useState<any>(null)
|
||||||
|
const [inputValue, setInputValue] = useState('')
|
||||||
|
const [multiValue, setMultiValue] = useState<string[]>([])
|
||||||
|
|
||||||
|
const setAnswer = (qId: string, value: string | string[] | number) => {
|
||||||
|
setAnswers(prev => {
|
||||||
|
const existing = prev.findIndex(a => a.questionId === qId)
|
||||||
|
if (existing >= 0) { prev[existing].value = value; return [...prev] }
|
||||||
|
return [...prev, { questionId: qId, value }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAnswer = (qId: string) => answers.find(a => a.questionId === qId)?.value || ''
|
||||||
|
|
||||||
|
const handleAnalyze = async () => {
|
||||||
|
setAnalyzing(true)
|
||||||
|
const narrativeText = answersToNarrativeText(answers)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/parse-narrative`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ narrative_text: narrativeText }),
|
||||||
|
})
|
||||||
|
if (res.ok) setResult(await res.json())
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setAnalyzing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = INTERVIEW_QUESTIONS[currentQ]
|
||||||
|
const sections = [...new Set(INTERVIEW_QUESTIONS.map(q => q.section))]
|
||||||
|
const sectionQuestions = (s: number) => INTERVIEW_QUESTIONS.filter(q => q.section === s)
|
||||||
|
|
||||||
|
// Interview mode: advance to next question
|
||||||
|
const handleInterviewNext = () => {
|
||||||
|
if (q.type === 'multiselect') { setAnswer(q.id, multiValue); setMultiValue([]) }
|
||||||
|
else if (inputValue) { setAnswer(q.id, inputValue); setInputValue('') }
|
||||||
|
if (currentQ < INTERVIEW_QUESTIONS.length - 1) setCurrentQ(currentQ + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Mode Switcher */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">CE-Risikobeurteilung — Datenerfassung</h1>
|
||||||
|
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||||
|
{([['interview', 'Interview'], ['wizard', 'Wizard'], ['form', 'Formular']] as [InputMode, string][]).map(([m, label]) => (
|
||||||
|
<button key={m} onClick={() => setMode(m)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${mode === m ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Result */}
|
||||||
|
{result && (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-xl p-6 space-y-4">
|
||||||
|
<h2 className="font-semibold text-green-900">Analyse-Ergebnis (deterministisch)</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div className="bg-white rounded-lg p-3 text-center"><div className="text-2xl font-bold text-purple-600">{result.components?.length || 0}</div><div className="text-xs text-gray-500">Komponenten</div></div>
|
||||||
|
<div className="bg-white rounded-lg p-3 text-center"><div className="text-2xl font-bold text-red-600">{result.suggested_hazards?.length || 0}</div><div className="text-xs text-gray-500">Gefahren</div></div>
|
||||||
|
<div className="bg-white rounded-lg p-3 text-center"><div className="text-2xl font-bold text-blue-600">{result.matched_patterns || 0}</div><div className="text-xs text-gray-500">Patterns</div></div>
|
||||||
|
<div className="bg-white rounded-lg p-3 text-center"><div className="text-2xl font-bold text-green-600">{result.energy_sources?.length || 0}</div><div className="text-xs text-gray-500">Energiequellen</div></div>
|
||||||
|
</div>
|
||||||
|
{result.suggested_hazards?.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-medium text-gray-900 text-sm">Erkannte Gefahren:</h3>
|
||||||
|
{result.suggested_hazards.map((h: any, i: number) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 bg-white rounded-lg p-2 border border-gray-100">
|
||||||
|
<span className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${h.priority >= 90 ? 'bg-red-100 text-red-700' : h.priority >= 70 ? 'bg-orange-100 text-orange-700' : 'bg-yellow-100 text-yellow-700'}`}>P{h.priority}</span>
|
||||||
|
<span className="text-sm text-gray-700">{h.pattern_name || h.category}</span>
|
||||||
|
<span className="text-xs text-gray-400 ml-auto">{h.category}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══════════════ INTERVIEW MODE ═══════════════ */}
|
||||||
|
{mode === 'interview' && !result && (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6 max-w-2xl mx-auto">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Previous answers (chat history) */}
|
||||||
|
<div className="space-y-3 max-h-[400px] overflow-y-auto">
|
||||||
|
{INTERVIEW_QUESTIONS.slice(0, currentQ).map((pq, i) => {
|
||||||
|
const ans = getAnswer(pq.id)
|
||||||
|
if (!ans || (Array.isArray(ans) && ans.length === 0)) return null
|
||||||
|
return (
|
||||||
|
<div key={i} className="space-y-1">
|
||||||
|
<div className="text-xs text-purple-600 font-medium">{pq.question}</div>
|
||||||
|
<div className="text-sm text-gray-700 bg-gray-50 rounded-lg px-3 py-2">
|
||||||
|
{Array.isArray(ans) ? ans.join(', ') : String(ans)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current question */}
|
||||||
|
{currentQ < INTERVIEW_QUESTIONS.length && (
|
||||||
|
<div className="border-t border-gray-100 pt-4">
|
||||||
|
<div className="text-xs text-gray-400 mb-1">Frage {currentQ + 1}/{INTERVIEW_QUESTIONS.length} — {q.sectionTitle}</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 mb-3">{q.question}</div>
|
||||||
|
{q.helpText && <p className="text-xs text-gray-500 mb-2">{q.helpText}</p>}
|
||||||
|
|
||||||
|
{q.type === 'text' && (
|
||||||
|
<input value={inputValue} onChange={e => setInputValue(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleInterviewNext()}
|
||||||
|
placeholder={q.placeholder} className="w-full px-4 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500" autoFocus />
|
||||||
|
)}
|
||||||
|
{q.type === 'textarea' && (
|
||||||
|
<textarea value={inputValue} onChange={e => setInputValue(e.target.value)} rows={4}
|
||||||
|
placeholder={q.placeholder} className="w-full px-4 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500" autoFocus />
|
||||||
|
)}
|
||||||
|
{q.type === 'select' && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{q.options?.map(opt => (
|
||||||
|
<button key={opt} onClick={() => { setAnswer(q.id, opt); if (currentQ < INTERVIEW_QUESTIONS.length - 1) setCurrentQ(currentQ + 1) }}
|
||||||
|
className="px-3 py-1.5 text-sm bg-gray-50 border border-gray-200 rounded-lg hover:bg-purple-50 hover:border-purple-300">
|
||||||
|
{opt}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{q.type === 'multiselect' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{q.options?.map(opt => (
|
||||||
|
<label key={opt} className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg border cursor-pointer ${multiValue.includes(opt) ? 'bg-purple-50 border-purple-300' : 'bg-gray-50 border-gray-200'}`}>
|
||||||
|
<input type="checkbox" checked={multiValue.includes(opt)} onChange={e => setMultiValue(e.target.checked ? [...multiValue, opt] : multiValue.filter(v => v !== opt))} className="w-3.5 h-3.5 text-purple-600" />
|
||||||
|
{opt}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between mt-4">
|
||||||
|
<button onClick={() => currentQ > 0 && setCurrentQ(currentQ - 1)} disabled={currentQ === 0}
|
||||||
|
className="px-4 py-2 text-sm text-gray-500 hover:text-gray-700 disabled:opacity-30">Zurueck</button>
|
||||||
|
<button onClick={handleInterviewNext}
|
||||||
|
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||||
|
{currentQ === INTERVIEW_QUESTIONS.length - 1 ? 'Abschliessen' : 'Weiter'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentQ >= INTERVIEW_QUESTIONS.length && (
|
||||||
|
<button onClick={handleAnalyze} disabled={analyzing}
|
||||||
|
className="w-full px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium disabled:opacity-50">
|
||||||
|
{analyzing ? 'Analysiere deterministisch...' : 'Risikobeurteilung starten'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══════════════ WIZARD MODE ═══════════════ */}
|
||||||
|
{mode === 'wizard' && !result && (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
{/* Step indicator */}
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
{sections.map(s => (
|
||||||
|
<button key={s} onClick={() => setCurrentSection(s)}
|
||||||
|
className={`w-8 h-8 rounded-full text-xs font-medium ${currentSection === s ? 'bg-purple-600 text-white' : s < currentSection ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-500'}`}>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<h2 className="font-semibold text-gray-900 mb-4">{INTERVIEW_QUESTIONS.find(q => q.section === currentSection)?.sectionTitle}</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sectionQuestions(currentSection).map(q => (
|
||||||
|
<div key={q.id}>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">{q.question}</label>
|
||||||
|
{q.type === 'textarea' ? (
|
||||||
|
<textarea value={String(getAnswer(q.id))} onChange={e => setAnswer(q.id, e.target.value)} rows={3} placeholder={q.placeholder}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" />
|
||||||
|
) : q.type === 'select' ? (
|
||||||
|
<select value={String(getAnswer(q.id))} onChange={e => setAnswer(q.id, e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-white">
|
||||||
|
<option value="">-- Bitte waehlen --</option>
|
||||||
|
{q.options?.map(o => <option key={o} value={o}>{o}</option>)}
|
||||||
|
</select>
|
||||||
|
) : q.type === 'multiselect' ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{q.options?.map(opt => {
|
||||||
|
const current = (getAnswer(q.id) as string[] || [])
|
||||||
|
return (
|
||||||
|
<label key={opt} className={`flex items-center gap-1.5 px-2 py-1 text-xs rounded border cursor-pointer ${current.includes(opt) ? 'bg-purple-50 border-purple-300' : 'border-gray-200'}`}>
|
||||||
|
<input type="checkbox" checked={current.includes(opt)} onChange={e => setAnswer(q.id, e.target.checked ? [...current, opt] : current.filter((v: string) => v !== opt))} className="w-3 h-3" />
|
||||||
|
{opt}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input value={String(getAnswer(q.id))} onChange={e => setAnswer(q.id, e.target.value)} placeholder={q.placeholder}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-6 pt-4 border-t">
|
||||||
|
<button onClick={() => setCurrentSection(Math.max(1, currentSection - 1))} disabled={currentSection === 1}
|
||||||
|
className="px-4 py-2 text-sm text-gray-500 disabled:opacity-30">Zurueck</button>
|
||||||
|
{currentSection < sections.length ? (
|
||||||
|
<button onClick={() => setCurrentSection(currentSection + 1)} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg">Weiter</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={handleAnalyze} disabled={analyzing} className="px-6 py-2 bg-green-600 text-white rounded-lg font-medium disabled:opacity-50">
|
||||||
|
{analyzing ? 'Analysiere...' : 'Risikobeurteilung starten'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ═══════════════ FORM MODE (Accordion) ═══════════════ */}
|
||||||
|
{mode === 'form' && !result && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sections.map(s => {
|
||||||
|
const qs = sectionQuestions(s)
|
||||||
|
const title = qs[0]?.sectionTitle || ''
|
||||||
|
return (
|
||||||
|
<details key={s} open={s === 1} className="bg-white rounded-xl border border-gray-200">
|
||||||
|
<summary className="px-6 py-4 cursor-pointer font-medium text-gray-900 hover:bg-gray-50">{s}. {title}</summary>
|
||||||
|
<div className="px-6 pb-4 space-y-3">
|
||||||
|
{qs.map(q => (
|
||||||
|
<div key={q.id}>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">{q.question}</label>
|
||||||
|
{q.type === 'textarea' ? (
|
||||||
|
<textarea value={String(getAnswer(q.id))} onChange={e => setAnswer(q.id, e.target.value)} rows={3} placeholder={q.placeholder}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" />
|
||||||
|
) : q.type === 'select' ? (
|
||||||
|
<select value={String(getAnswer(q.id))} onChange={e => setAnswer(q.id, e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-white">
|
||||||
|
<option value="">-- Bitte waehlen --</option>
|
||||||
|
{q.options?.map(o => <option key={o} value={o}>{o}</option>)}
|
||||||
|
</select>
|
||||||
|
) : q.type === 'multiselect' ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{q.options?.map(opt => {
|
||||||
|
const current = (getAnswer(q.id) as string[] || [])
|
||||||
|
return (
|
||||||
|
<label key={opt} className={`flex items-center gap-1.5 px-2 py-1 text-xs rounded border cursor-pointer ${current.includes(opt) ? 'bg-purple-50 border-purple-300' : 'border-gray-200'}`}>
|
||||||
|
<input type="checkbox" checked={current.includes(opt)} onChange={e => setAnswer(q.id, e.target.checked ? [...current, opt] : current.filter((v: string) => v !== opt))} className="w-3 h-3" />
|
||||||
|
{opt}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input value={String(getAnswer(q.id))} onChange={e => setAnswer(q.id, e.target.value)} placeholder={q.placeholder}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<button onClick={handleAnalyze} disabled={analyzing}
|
||||||
|
className="w-full px-6 py-3 bg-green-600 text-white rounded-xl hover:bg-green-700 font-medium disabled:opacity-50 text-lg">
|
||||||
|
{analyzing ? 'Analysiere deterministisch...' : 'Risikobeurteilung starten'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user