d80cb9c8e4
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>
286 lines
16 KiB
TypeScript
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>
|
|
)
|
|
}
|