Files
breakpilot-compliance/admin-compliance/app/sdk/iace/[projectId]/interview/page.tsx
T
Benjamin Admin d80cb9c8e4 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>
2026-05-05 08:22:59 +02:00

286 lines
16 KiB
TypeScript

'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>
)
}