From 1cc0c3d34ad28395ce0a10f81520bdbf2cf5f428 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Thu, 7 May 2026 15:44:05 +0200 Subject: [PATCH] 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) --- .../sdk/iace/[projectId]/interview/page.tsx | 484 ++++++++---------- .../app/sdk/iace/[projectId]/order/page.tsx | 243 +++++++++ .../tech-file/_components/ReportGenerator.tsx | 285 +++++++++++ .../sdk/iace/[projectId]/tech-file/page.tsx | 7 +- admin-compliance/app/sdk/iace/layout.tsx | 7 + 5 files changed, 766 insertions(+), 260 deletions(-) create mode 100644 admin-compliance/app/sdk/iace/[projectId]/order/page.tsx create mode 100644 admin-compliance/app/sdk/iace/[projectId]/tech-file/_components/ReportGenerator.tsx diff --git a/admin-compliance/app/sdk/iace/[projectId]/interview/page.tsx b/admin-compliance/app/sdk/iace/[projectId]/interview/page.tsx index 7d605f9..8c9f7a6 100644 --- a/admin-compliance/app/sdk/iace/[projectId]/interview/page.tsx +++ b/admin-compliance/app/sdk/iace/[projectId]/interview/page.tsx @@ -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('interview') - const [answers, setAnswers] = useState([]) - const [currentQ, setCurrentQ] = useState(0) - const [currentSection, setCurrentSection] = useState(1) - const [analyzing, setAnalyzing] = useState(false) - const [result, setResult] = useState(null) - const [inputValue, setInputValue] = useState('') - const [multiValue, setMultiValue] = useState([]) + const router = useRouter() + const [formData, setFormData] = useState(EMPTY_LIMITS_FORM) + const [projectData, setProjectData] = useState(null) + const [loading, setLoading] = useState(true) + const [saveStatus, setSaveStatus] = useState('idle') + const saveTimerRef = useRef | null>(null) + const latestFormRef = useRef(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 ( +
+
+
+ ) } return (
- {/* Mode Switcher */} + {/* Header */}
-

CE-Risikobeurteilung — Datenerfassung

-
- {([['interview', 'Interview'], ['wizard', 'Wizard'], ['form', 'Formular']] as [InputMode, string][]).map(([m, label]) => ( - - ))} +
+

+ Grenzen & Verwendung +

+

+ Schritt 3 — Bestimmungsgemasse Verwendung, Fehlanwendung und Maschinengrenzen definieren (ISO 12100) +

+
+
+ +
- {/* Result */} - {result && ( -
-

Analyse-Ergebnis (deterministisch)

-
-
{result.components?.length || 0}
Komponenten
-
{result.suggested_hazards?.length || 0}
Gefahren
-
{result.matched_patterns || 0}
Patterns
-
{result.energy_sources?.length || 0}
Energiequellen
-
- {result.suggested_hazards?.length > 0 && ( -
-

Erkannte Gefahren:

- {result.suggested_hazards.map((h: any, i: number) => ( -
- = 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} - {h.pattern_name || h.category} - {h.category} -
- ))} -
- )} -
+ {/* Form Sections */} + + + {/* Navigation */} +
+ + +
+
+ ) +} + +// ──────────────────────────────────────── +// Sub-components (small, page-local) +// ──────────────────────────────────────── + +function SaveIndicator({ status }: { status: SaveStatus }) { + if (status === 'idle') return null + return ( +
+ {status === 'saving' && ( + <> +
+ Speichert... + )} - - {/* ═══════════════ INTERVIEW MODE ═══════════════ */} - {mode === 'interview' && !result && ( -
-
- {/* Previous answers (chat history) */} -
- {INTERVIEW_QUESTIONS.slice(0, currentQ).map((pq, i) => { - const ans = getAnswer(pq.id) - if (!ans || (Array.isArray(ans) && ans.length === 0)) return null - return ( -
-
{pq.question}
-
- {Array.isArray(ans) ? ans.join(', ') : String(ans)} -
-
- ) - })} -
- - {/* Current question */} - {currentQ < INTERVIEW_QUESTIONS.length && ( -
-
Frage {currentQ + 1}/{INTERVIEW_QUESTIONS.length} — {q.sectionTitle}
-
{q.question}
- {q.helpText &&

{q.helpText}

} - - {q.type === 'text' && ( - 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' && ( -