feat: Auftrag-Tab + Grenzen-Formular + CE-Report-Export
- Auftrag-Tab: Kunde, Anfrage, Angebot mit Status-Tracking - Grenzen & Verwendung: 6 Sektionen (Produktbeschreibung, Verwendung, Fehlanwendung, Grenzen, Schnittstellen, Betroffene Personen) - CE-Akte Export: PDF (window.print) + Excel (CSV) mit allen Sektionen (Normen, Gefaehrdungen, Risikobewertung, Massnahmen, Compliance) - Navigation: Auftrag als 2. Tab, Briefcase-Icon Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,285 +1,251 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { INTERVIEW_QUESTIONS, answersToNarrativeText, type InterviewAnswer, type InterviewQuestion } from './_types'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { LimitsFormSections } from './_components/LimitsFormSections'
|
||||
import { EMPTY_LIMITS_FORM, type LimitsFormData } from './_types'
|
||||
|
||||
type InputMode = 'interview' | 'wizard' | 'form'
|
||||
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
|
||||
|
||||
interface ProjectData {
|
||||
machine_name: string
|
||||
machine_type: string
|
||||
manufacturer: string
|
||||
metadata?: {
|
||||
limits_form?: LimitsFormData
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
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 router = useRouter()
|
||||
const [formData, setFormData] = useState<LimitsFormData>(EMPTY_LIMITS_FORM)
|
||||
const [projectData, setProjectData] = useState<ProjectData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const latestFormRef = useRef<LimitsFormData>(EMPTY_LIMITS_FORM)
|
||||
|
||||
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 }]
|
||||
})
|
||||
}
|
||||
// Load project data and existing form data
|
||||
useEffect(() => {
|
||||
loadProject()
|
||||
}, [projectId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const getAnswer = (qId: string) => answers.find(a => a.questionId === qId)?.value || ''
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
setAnalyzing(true)
|
||||
const narrativeText = answersToNarrativeText(answers)
|
||||
async function loadProject() {
|
||||
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 res = await fetch(`/api/sdk/v1/iace/projects/${projectId}`)
|
||||
if (!res.ok) return
|
||||
const json = await res.json()
|
||||
const proj: ProjectData = {
|
||||
machine_name: json.machine_name || '',
|
||||
machine_type: json.machine_type || '',
|
||||
manufacturer: json.manufacturer || '',
|
||||
metadata: json.metadata || {},
|
||||
}
|
||||
setProjectData(proj)
|
||||
|
||||
// Restore saved form data from metadata
|
||||
if (proj.metadata?.limits_form) {
|
||||
const saved = proj.metadata.limits_form
|
||||
const merged = { ...EMPTY_LIMITS_FORM, ...saved }
|
||||
setFormData(merged)
|
||||
latestFormRef.current = merged
|
||||
} else {
|
||||
// Pre-fill from project fields
|
||||
const prefilled: LimitsFormData = {
|
||||
...EMPTY_LIMITS_FORM,
|
||||
machine_designation: proj.machine_name,
|
||||
machine_type: proj.machine_type,
|
||||
manufacturer: proj.manufacturer,
|
||||
}
|
||||
setFormData(prefilled)
|
||||
latestFormRef.current = prefilled
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load project:', err)
|
||||
} finally {
|
||||
setLoading(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)
|
||||
// Debounced auto-save
|
||||
const saveToBackend = useCallback(async (data: LimitsFormData) => {
|
||||
setSaveStatus('saving')
|
||||
try {
|
||||
// Merge limits_form into existing metadata
|
||||
const existingMetadata = projectData?.metadata || {}
|
||||
const newMetadata = { ...existingMetadata, limits_form: data }
|
||||
|
||||
// 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)
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ metadata: newMetadata }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setSaveStatus('saved')
|
||||
// Also update local projectData metadata so next save merges correctly
|
||||
setProjectData((prev) => prev ? { ...prev, metadata: newMetadata } : prev)
|
||||
setTimeout(() => setSaveStatus((s) => s === 'saved' ? 'idle' : s), 2000)
|
||||
} else {
|
||||
setSaveStatus('error')
|
||||
}
|
||||
} catch {
|
||||
setSaveStatus('error')
|
||||
}
|
||||
}, [projectId, projectData?.metadata]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleFieldChange = useCallback((field: keyof LimitsFormData, value: string | string[]) => {
|
||||
setFormData((prev) => {
|
||||
const next = { ...prev, [field]: value }
|
||||
latestFormRef.current = next
|
||||
return next
|
||||
})
|
||||
|
||||
// Debounce save: wait 1.5s after last change
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
saveToBackend(latestFormRef.current)
|
||||
}, 1500)
|
||||
}, [saveToBackend])
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Calculate completion percentage
|
||||
const completionPct = calculateCompletion(formData)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Mode Switcher */}
|
||||
{/* Header */}
|
||||
<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>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
Grenzen & Verwendung
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Schritt 3 — Bestimmungsgemasse Verwendung, Fehlanwendung und Maschinengrenzen definieren (ISO 12100)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<SaveIndicator status={saveStatus} />
|
||||
<CompletionBadge pct={completionPct} />
|
||||
</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>
|
||||
{/* Form Sections */}
|
||||
<LimitsFormSections
|
||||
data={formData}
|
||||
onChange={handleFieldChange}
|
||||
prefilled={{
|
||||
machine_name: projectData?.machine_name,
|
||||
machine_type: projectData?.machine_type,
|
||||
manufacturer: projectData?.manufacturer,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => router.push(`/sdk/iace/${projectId}`)}
|
||||
className="px-4 py-2 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Zurueck zur Uebersicht
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Flush any pending save
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
saveToBackend(latestFormRef.current)
|
||||
}
|
||||
router.push(`/sdk/iace/${projectId}/components`)
|
||||
}}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium transition-colors"
|
||||
>
|
||||
Weiter zu Komponenten
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────
|
||||
// Sub-components (small, page-local)
|
||||
// ────────────────────────────────────────
|
||||
|
||||
function SaveIndicator({ status }: { status: SaveStatus }) {
|
||||
if (status === 'idle') return null
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{status === 'saving' && (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-400 animate-pulse" />
|
||||
<span className="text-yellow-600 dark:text-yellow-400">Speichert...</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ═══════════════ 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>
|
||||
{status === 'saved' && (
|
||||
<>
|
||||
<svg className="w-3.5 h-3.5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-green-600 dark:text-green-400">Gespeichert</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ═══════════════ 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>
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
<span className="text-red-600 dark:text-red-400">Fehler beim Speichern</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CompletionBadge({ pct }: { pct: number }) {
|
||||
const color = pct >= 80 ? 'text-green-600 bg-green-50 border-green-200' :
|
||||
pct >= 40 ? 'text-yellow-600 bg-yellow-50 border-yellow-200' :
|
||||
'text-gray-500 bg-gray-50 border-gray-200'
|
||||
return (
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium border ${color}`}>
|
||||
{pct}% ausgefuellt
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Calculate how many required-ish fields are filled */
|
||||
function calculateCompletion(data: LimitsFormData): number {
|
||||
const checks = [
|
||||
!!data.machine_designation,
|
||||
!!data.machine_type,
|
||||
!!data.manufacturer,
|
||||
!!data.general_description,
|
||||
!!data.intended_purpose,
|
||||
!!data.area_of_use,
|
||||
data.operating_modes.length > 0,
|
||||
!!data.foreseeable_misuses,
|
||||
!!data.spatial_limits,
|
||||
!!data.operating_conditions,
|
||||
!!data.energy_supply,
|
||||
data.person_groups.length > 0,
|
||||
!!data.qualification_requirements,
|
||||
]
|
||||
const filled = checks.filter(Boolean).length
|
||||
return Math.round((filled / checks.length) * 100)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user