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:
Benjamin Admin
2026-05-07 15:44:05 +02:00
parent 6e71996733
commit 1cc0c3d34a
5 changed files with 766 additions and 260 deletions
@@ -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}
<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>
{/* 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>
{/* 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>}
// ────────────────────────────────────────
// Sub-components (small, page-local)
// ────────────────────────────────────────
{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[] || [])
function SaveIndicator({ status }: { status: SaveStatus }) {
if (status === 'idle') return null
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 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>
</>
)}
</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>
{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>
</>
)}
</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)
}
@@ -0,0 +1,243 @@
'use client'
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useParams } from 'next/navigation'
interface OrderData {
client: {
company: string; contact: string; email: string; phone: string; address: string
}
order: {
number: string; received_date: string; description: string; scope: string[]
}
offer: {
date: string; number: string; amount: string; status: string; accepted_date: string
}
notes: string
}
const EMPTY: OrderData = {
client: { company: '', contact: '', email: '', phone: '', address: '' },
order: { number: '', received_date: '', description: '', scope: [] },
offer: { date: '', number: '', amount: '', status: 'offen', accepted_date: '' },
notes: '',
}
const SCOPE_OPTIONS = [
'Risikobeurteilung', 'Normenrecherche', 'Betriebsanleitung', 'CE-Kennzeichnung', 'Schulung',
]
const STATUS_OPTIONS = [
{ value: 'offen', label: 'Offen' },
{ value: 'angenommen', label: 'Angenommen' },
{ value: 'abgelehnt', label: 'Abgelehnt' },
{ value: 'storniert', label: 'Storniert' },
]
const STATUS_COLORS: Record<string, string> = {
offen: 'bg-gray-100 text-gray-700',
angenommen: 'bg-green-100 text-green-700',
abgelehnt: 'bg-red-100 text-red-700',
storniert: 'bg-gray-100 text-gray-500',
}
export default function OrderPage() {
const params = useParams()
const projectId = params.projectId as string
const [data, setData] = useState<OrderData>(EMPTY)
const [loading, setLoading] = useState(true)
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved'>('idle')
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const existingMetaRef = useRef<Record<string, unknown>>({})
useEffect(() => {
fetch(`/api/sdk/v1/iace/projects/${projectId}`)
.then((r) => r.ok ? r.json() : null)
.then((json) => {
if (!json) return
const proj = json.project || json
const meta = proj.metadata || {}
existingMetaRef.current = meta
if (meta.order_data) setData({ ...EMPTY, ...meta.order_data })
})
.catch(() => {})
.finally(() => setLoading(false))
}, [projectId])
const save = useCallback(async (next: OrderData) => {
setSaveState('saving')
try {
const merged = { ...existingMetaRef.current, order_data: next }
await fetch(`/api/sdk/v1/iace/projects/${projectId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metadata: merged }),
})
existingMetaRef.current = merged
setSaveState('saved')
setTimeout(() => setSaveState('idle'), 2000)
} catch {
setSaveState('idle')
}
}, [projectId])
const update = useCallback((next: OrderData) => {
setData(next)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => save(next), 800)
}, [save])
const setClient = (k: keyof OrderData['client'], v: string) =>
update({ ...data, client: { ...data.client, [k]: v } })
const setOrder = (k: keyof OrderData['order'], v: string) =>
update({ ...data, order: { ...data.order, [k]: v } })
const setOffer = (k: keyof OrderData['offer'], v: string) =>
update({ ...data, offer: { ...data.offer, [k]: v } })
const toggleScope = (s: string) => {
const cur = data.order.scope
const next = cur.includes(s) ? cur.filter((x) => x !== s) : [...cur, s]
update({ ...data, order: { ...data.order, scope: next } })
}
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 max-w-3xl">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Auftrag</h1>
<p className="mt-1 text-sm text-gray-500">Auftraggeber und Auftragsdaten erfassen</p>
</div>
<span className={`text-xs px-2.5 py-1 rounded-full font-medium transition-opacity ${
saveState === 'saving' ? 'bg-yellow-100 text-yellow-700'
: saveState === 'saved' ? 'bg-green-100 text-green-700'
: 'opacity-0'
}`}>
{saveState === 'saving' ? 'Speichert...' : 'Gespeichert'}
</span>
</div>
{/* Auftraggeber */}
<Card title="Auftraggeber">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input label="Firmenname" value={data.client.company} onChange={(v) => setClient('company', v)} />
<Input label="Ansprechpartner" value={data.client.contact} onChange={(v) => setClient('contact', v)} />
<Input label="E-Mail" type="email" value={data.client.email} onChange={(v) => setClient('email', v)} />
<Input label="Telefon" type="tel" value={data.client.phone} onChange={(v) => setClient('phone', v)} />
</div>
<div className="mt-4">
<Textarea label="Adresse" value={data.client.address} onChange={(v) => setClient('address', v)} rows={2} />
</div>
</Card>
{/* Auftrag */}
<Card title="Auftrag">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input label="Auftragsnummer" value={data.order.number} onChange={(v) => setOrder('number', v)} />
<Input label="Eingangsdatum" type="date" value={data.order.received_date} onChange={(v) => setOrder('received_date', v)} />
</div>
<div className="mt-4">
<Textarea label="Beschreibung des Auftrags" value={data.order.description} onChange={(v) => setOrder('description', v)}
placeholder="z.B. CE-Risikobeurteilung fuer Cobot-Zelle" rows={2} />
</div>
<div className="mt-4">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">Umfang</label>
<div className="flex flex-wrap gap-2">
{SCOPE_OPTIONS.map((s) => {
const active = data.order.scope.includes(s)
return (
<button key={s} type="button" onClick={() => toggleScope(s)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
active ? 'bg-purple-100 border-purple-300 text-purple-700 dark:bg-purple-900/40 dark:border-purple-600 dark:text-purple-300'
: 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-400'
}`}>{active ? '\u2713 ' : ''}{s}</button>
)
})}
</div>
</div>
</Card>
{/* Angebot */}
<Card title="Angebot">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input label="Angebotsdatum" type="date" value={data.offer.date} onChange={(v) => setOffer('date', v)} />
<Input label="Angebotsnummer" value={data.offer.number} onChange={(v) => setOffer('number', v)} />
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Angebotssumme</label>
<div className="relative">
<input type="number" min="0" step="0.01" value={data.offer.amount}
onChange={(e) => setOffer('amount', e.target.value)}
className="w-full pr-8 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-gray-400 pointer-events-none">&euro;</span>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
<div className="flex items-center gap-3">
<select value={data.offer.status} onChange={(e) => setOffer('status', e.target.value)}
className="flex-1 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent">
{STATUS_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<span className={`px-2.5 py-1 rounded-full text-xs font-medium whitespace-nowrap ${STATUS_COLORS[data.offer.status] || ''}`}>
{STATUS_OPTIONS.find((o) => o.value === data.offer.status)?.label}
</span>
</div>
</div>
</div>
{data.offer.status === 'angenommen' && (
<div className="mt-4">
<Input label="Annahmedatum" type="date" value={data.offer.accepted_date} onChange={(v) => setOffer('accepted_date', v)} />
</div>
)}
</Card>
{/* Notizen */}
<Card title="Notizen">
<Textarea value={data.notes} onChange={(v) => update({ ...data, notes: v })}
placeholder="Freitext-Notizen zum Auftrag..." rows={4} />
</Card>
</div>
)
}
/* --- Shared form primitives --- */
function Card({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">{title}</h2>
{children}
</div>
)
}
function Input({ label, value, onChange, type = 'text', placeholder }: {
label?: string; value: string; onChange: (v: string) => void; type?: string; placeholder?: string
}) {
return (
<div>
{label && <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">{label}</label>}
<input type={type} value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder}
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
)
}
function Textarea({ label, value, onChange, placeholder, rows = 3 }: {
label?: string; value: string; onChange: (v: string) => void; placeholder?: string; rows?: number
}) {
return (
<div>
{label && <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">{label}</label>}
<textarea value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} rows={rows}
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none" />
</div>
)
}
@@ -0,0 +1,285 @@
'use client'
import React, { useState, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { ReportPrintView } from './ReportPrintView'
import {
ReportData, ProjectData, HazardData, MitigationData,
NormResult, ComplianceTrigger, RiskSummary,
CATEGORY_LABELS, REDUCTION_LABELS, STATUS_LABELS,
rpz, plFromRpz, silFromRpz, riskLevelLabel,
} from './report-types'
interface ReportGeneratorProps {
projectId: string
}
type ExportStatus = 'idle' | 'loading' | 'ready' | 'error'
/** Fetches all IACE data and generates PDF (via print) or CSV export. */
export function ReportGenerator({ projectId }: ReportGeneratorProps) {
const [status, setStatus] = useState<ExportStatus>('idle')
const [error, setError] = useState<string | null>(null)
const [reportData, setReportData] = useState<ReportData | null>(null)
const [showPreview, setShowPreview] = useState(false)
const fetchAllData = useCallback(async (): Promise<ReportData> => {
const base = `/api/sdk/v1/iace/projects/${projectId}`
const [projRes, hazRes, mitRes, normRes, trigRes, riskRes] = await Promise.all([
fetch(base),
fetch(`${base}/hazards`),
fetch(`${base}/mitigations`),
fetch(`${base}/suggested-norms`),
fetch(`${base}/compliance-triggers`),
fetch(`${base}/risk-summary`),
])
const project: ProjectData = await projRes.json()
const hazJson = await hazRes.json()
const hazards: HazardData[] = hazJson.hazards || hazJson || []
const mitJson = await mitRes.json()
const mitigations: MitigationData[] = mitJson.mitigations || mitJson || []
let norms: NormResult | null = null
if (normRes.ok) {
const normJson = await normRes.json()
norms = normJson.suggestions || (normJson.a_norms !== undefined ? normJson : null)
}
let triggers: ComplianceTrigger[] = []
if (trigRes.ok) {
const trigJson = await trigRes.json()
triggers = trigJson.triggers || trigJson || []
}
let riskSummary: RiskSummary = {}
if (riskRes.ok) {
const riskJson = await riskRes.json()
riskSummary = riskJson.summary || riskJson || {}
}
return { project, hazards, mitigations, norms, triggers, riskSummary }
}, [projectId])
async function handlePdfExport() {
setStatus('loading')
setError(null)
try {
const data = await fetchAllData()
setReportData(data)
setShowPreview(true)
setStatus('ready')
// Print is triggered after the preview renders
setTimeout(() => triggerPrint(data), 300)
} catch (err) {
setError(err instanceof Error ? err.message : 'Export fehlgeschlagen')
setStatus('error')
}
}
function triggerPrint(data: ReportData) {
const printWindow = window.open('', '_blank', 'width=900,height=700')
if (!printWindow) {
// Popup blocked — fall back to preview
setShowPreview(true)
return
}
printWindow.document.write('<!DOCTYPE html><html><head><title>CE-Akte - ')
printWindow.document.write(data.project.machine_name)
printWindow.document.write('</title></head><body>')
printWindow.document.write('<div id="report-root"></div></body></html>')
printWindow.document.close()
// Render the report into the print window
const root = printWindow.document.getElementById('report-root')
if (root) {
// Use createRoot from react-dom/client
import('react-dom/client').then(({ createRoot }) => {
const reactRoot = createRoot(root)
reactRoot.render(<ReportPrintView data={data} />)
// Wait for render, then print
setTimeout(() => {
printWindow.print()
}, 500)
})
}
setShowPreview(false)
}
async function handleCsvExport() {
setStatus('loading')
setError(null)
try {
const data = await fetchAllData()
generateCsvDownload(data)
setStatus('idle')
} catch (err) {
setError(err instanceof Error ? err.message : 'Export fehlgeschlagen')
setStatus('error')
}
}
function generateCsvDownload(data: ReportData) {
const { project, hazards, mitigations, norms, triggers, riskSummary } = data
const lines: string[] = []
const sep = '\t'
// Sheet 1: Projekt
lines.push('=== PROJEKT ===')
lines.push(['Feld', 'Wert'].join(sep))
lines.push(['Maschinenname', project.machine_name].join(sep))
lines.push(['Maschinentyp', project.machine_type || ''].join(sep))
lines.push(['Hersteller', project.manufacturer || ''].join(sep))
lines.push(['Status', project.status].join(sep))
lines.push(['Vollstaendigkeit', `${project.completeness_pct}%`].join(sep))
lines.push(['Erstellt', project.created_at].join(sep))
lines.push(['Aktualisiert', project.updated_at].join(sep))
lines.push('')
// Sheet 2: Gefaehrdungen
lines.push('=== GEFAEHRDUNGSLISTE ===')
lines.push(['Nr', 'Gefaehrdung', 'Komponente', 'Kategorie', 'Szenario',
'Lebensphase', 'S', 'E', 'P', 'A', 'RPZ', 'SIL', 'PL', 'Risiko'].join(sep))
const sorted = [...hazards].sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0))
sorted.forEach((h, i) => {
const r = rpz(h.severity, h.exposure, h.probability, h.avoidance)
lines.push([
String(i + 1), esc(h.name), esc(h.component_name || ''),
CATEGORY_LABELS[h.category] || h.category,
esc(h.possible_harm || h.trigger_event || ''),
h.lifecycle_phase || '',
String(h.severity), String(h.exposure), String(h.probability), String(h.avoidance),
String(r), String(silFromRpz(r)), plFromRpz(r), riskLevelLabel(h.risk_level),
].join(sep))
})
lines.push('')
// Sheet 3: Massnahmen
lines.push('=== MASSNAHMENLISTE ===')
lines.push(['Nr', 'Massnahme', 'Beschreibung', 'Typ', 'Zugeordnete Gefaehrdungen', 'Status'].join(sep))
mitigations.forEach((m, i) => {
lines.push([
String(i + 1), esc(m.title), esc(m.description),
REDUCTION_LABELS[m.reduction_type] || m.reduction_type,
esc(m.linked_hazard_names?.join('; ') || ''),
STATUS_LABELS[m.status] || m.status,
].join(sep))
})
lines.push('')
// Sheet 4: Normen
if (norms && norms.total > 0) {
lines.push('=== ANGEWANDTE NORMEN ===')
lines.push(['Typ', 'Nummer', 'Titel', 'Grund'].join(sep))
for (const key of ['a_norms', 'b1_norms', 'b2_norms', 'c_norms'] as const) {
for (const ns of norms[key]) {
lines.push([key, ns.norm.number, esc(ns.norm.title_de), esc(ns.reason)].join(sep))
}
}
lines.push('')
}
// Sheet 5: Compliance Triggers
if (triggers.length > 0) {
lines.push('=== COMPLIANCE-HINWEISE ===')
lines.push(['Regulation', 'Artikel', 'Titel', 'Schwere', 'Grund'].join(sep))
triggers.forEach(t => {
lines.push([t.regulation, t.article, esc(t.title), t.severity, esc(t.reason)].join(sep))
})
lines.push('')
}
// Sheet 6: Risikozusammenfassung
lines.push('=== RISIKOZUSAMMENFASSUNG ===')
lines.push(['Stufe', 'Anzahl'].join(sep))
lines.push(['Kritisch', String(riskSummary.critical || 0)].join(sep))
lines.push(['Hoch', String(riskSummary.high || 0)].join(sep))
lines.push(['Mittel', String(riskSummary.medium || 0)].join(sep))
lines.push(['Niedrig', String(riskSummary.low || 0)].join(sep))
// BOM for Excel to recognize UTF-8
const bom = '\uFEFF'
const blob = new Blob([bom + lines.join('\n')], { type: 'text/tab-separated-values;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `CE-Akte-${project.machine_name.replace(/[^a-zA-Z0-9_-]/g, '_')}.tsv`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
return (
<>
<div className="flex items-center gap-2">
<button
onClick={handlePdfExport}
disabled={status === 'loading'}
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50 text-sm font-medium"
>
{status === 'loading' ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
)}
PDF exportieren
</button>
<button
onClick={handleCsvExport}
disabled={status === 'loading'}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 text-sm font-medium"
>
{status === 'loading' ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
)}
Excel exportieren
</button>
</div>
{error && (
<p className="text-xs text-red-600 mt-1">{error}</p>
)}
{/* Hidden preview portal for fallback printing */}
{showPreview && reportData && typeof document !== 'undefined' && createPortal(
<div
className="fixed inset-0 bg-white z-[9999] overflow-auto print:static"
style={{ padding: '20px' }}
>
<div className="print:hidden flex items-center gap-3 mb-4 p-4 bg-gray-100 rounded-lg">
<button
onClick={() => window.print()}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium"
>
Drucken / Als PDF speichern
</button>
<button
onClick={() => setShowPreview(false)}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 text-sm"
>
Schliessen
</button>
<span className="text-xs text-gray-500">
Popup wurde blockiert. Nutzen Sie die Druckfunktion Ihres Browsers (Strg+P).
</span>
</div>
<ReportPrintView data={reportData} />
</div>,
document.body,
)}
</>
)
}
/** Escape tab and newline for TSV. */
function esc(s: string): string {
return s.replace(/[\t\n\r]/g, ' ')
}
@@ -3,6 +3,7 @@
import React, { useState, useEffect, useRef } from 'react'
import { useParams } from 'next/navigation'
import { TechFileEditor } from '@/components/sdk/iace/TechFileEditor'
import { ReportGenerator } from './_components/ReportGenerator'
interface TechFileSection {
id: string
@@ -308,7 +309,10 @@ export default function TechFilePage() {
Sie alle erforderlichen Abschnitte.
</p>
</div>
{/* Export Dropdown */}
<div className="flex items-center gap-3">
{/* Risk Report Export (PDF + Excel) — always available */}
<ReportGenerator projectId={projectId} />
{/* Tech-File Export Dropdown — requires all sections approved */}
<div className="relative" ref={exportMenuRef}>
<button
onClick={() => setShowExportMenu((prev) => !prev)}
@@ -348,6 +352,7 @@ export default function TechFilePage() {
)}
</div>
</div>
</div>
{/* Progress */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
+7
View File
@@ -7,6 +7,7 @@ import IACEFlowFAB from './[projectId]/_components/IACEFlowFAB'
const IACE_NAV_ITEMS = [
{ id: 'overview', label: 'Uebersicht', href: '', icon: 'grid' },
{ id: 'order', label: 'Auftrag', href: '/order', icon: 'briefcase' },
{ id: 'interview', label: 'Grenzen & Verwendung', href: '/interview', icon: 'chat' },
{ id: 'components', label: 'Komponenten', href: '/components', icon: 'cube' },
{ id: 'hazards', label: 'Hazard Log', href: '/hazards', icon: 'warning' },
@@ -78,6 +79,12 @@ function NavIcon({ icon, className }: { icon: string; className?: string }) {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
)
case 'briefcase':
return (
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m10 0H6a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V8a2 2 0 00-2-2z" />
</svg>
)
case 'chat':
return (
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">