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'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { INTERVIEW_QUESTIONS, answersToNarrativeText, type InterviewAnswer, type InterviewQuestion } from './_types'
|
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() {
|
export default function IACEInterviewPage() {
|
||||||
const { projectId } = useParams<{ projectId: string }>()
|
const { projectId } = useParams<{ projectId: string }>()
|
||||||
const [mode, setMode] = useState<InputMode>('interview')
|
const router = useRouter()
|
||||||
const [answers, setAnswers] = useState<InterviewAnswer[]>([])
|
const [formData, setFormData] = useState<LimitsFormData>(EMPTY_LIMITS_FORM)
|
||||||
const [currentQ, setCurrentQ] = useState(0)
|
const [projectData, setProjectData] = useState<ProjectData | null>(null)
|
||||||
const [currentSection, setCurrentSection] = useState(1)
|
const [loading, setLoading] = useState(true)
|
||||||
const [analyzing, setAnalyzing] = useState(false)
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle')
|
||||||
const [result, setResult] = useState<any>(null)
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
const [inputValue, setInputValue] = useState('')
|
const latestFormRef = useRef<LimitsFormData>(EMPTY_LIMITS_FORM)
|
||||||
const [multiValue, setMultiValue] = useState<string[]>([])
|
|
||||||
|
|
||||||
const setAnswer = (qId: string, value: string | string[] | number) => {
|
// Load project data and existing form data
|
||||||
setAnswers(prev => {
|
useEffect(() => {
|
||||||
const existing = prev.findIndex(a => a.questionId === qId)
|
loadProject()
|
||||||
if (existing >= 0) { prev[existing].value = value; return [...prev] }
|
}, [projectId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
return [...prev, { questionId: qId, value }]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const getAnswer = (qId: string) => answers.find(a => a.questionId === qId)?.value || ''
|
async function loadProject() {
|
||||||
|
|
||||||
const handleAnalyze = async () => {
|
|
||||||
setAnalyzing(true)
|
|
||||||
const narrativeText = answersToNarrativeText(answers)
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/parse-narrative`, {
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}`)
|
||||||
method: 'POST',
|
if (!res.ok) return
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const json = await res.json()
|
||||||
body: JSON.stringify({ narrative_text: narrativeText }),
|
const proj: ProjectData = {
|
||||||
})
|
machine_name: json.machine_name || '',
|
||||||
if (res.ok) setResult(await res.json())
|
machine_type: json.machine_type || '',
|
||||||
} catch { /* ignore */ }
|
manufacturer: json.manufacturer || '',
|
||||||
setAnalyzing(false)
|
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]
|
// Debounced auto-save
|
||||||
const sections = [...new Set(INTERVIEW_QUESTIONS.map(q => q.section))]
|
const saveToBackend = useCallback(async (data: LimitsFormData) => {
|
||||||
const sectionQuestions = (s: number) => INTERVIEW_QUESTIONS.filter(q => q.section === s)
|
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 res = await fetch(`/api/sdk/v1/iace/projects/${projectId}`, {
|
||||||
const handleInterviewNext = () => {
|
method: 'PUT',
|
||||||
if (q.type === 'multiselect') { setAnswer(q.id, multiValue); setMultiValue([]) }
|
headers: { 'Content-Type': 'application/json' },
|
||||||
else if (inputValue) { setAnswer(q.id, inputValue); setInputValue('') }
|
body: JSON.stringify({ metadata: newMetadata }),
|
||||||
if (currentQ < INTERVIEW_QUESTIONS.length - 1) setCurrentQ(currentQ + 1)
|
})
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Mode Switcher */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-bold text-gray-900">CE-Risikobeurteilung — Datenerfassung</h1>
|
<div>
|
||||||
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
{([['interview', 'Interview'], ['wizard', 'Wizard'], ['form', 'Formular']] as [InputMode, string][]).map(([m, label]) => (
|
Grenzen & Verwendung
|
||||||
<button key={m} onClick={() => setMode(m)}
|
</h1>
|
||||||
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'}`}>
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
{label}
|
Schritt 3 — Bestimmungsgemasse Verwendung, Fehlanwendung und Maschinengrenzen definieren (ISO 12100)
|
||||||
</button>
|
</p>
|
||||||
))}
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<SaveIndicator status={saveStatus} />
|
||||||
|
<CompletionBadge pct={completionPct} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Result */}
|
{/* Form Sections */}
|
||||||
{result && (
|
<LimitsFormSections
|
||||||
<div className="bg-green-50 border border-green-200 rounded-xl p-6 space-y-4">
|
data={formData}
|
||||||
<h2 className="font-semibold text-green-900">Analyse-Ergebnis (deterministisch)</h2>
|
onChange={handleFieldChange}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
prefilled={{
|
||||||
<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>
|
machine_name: projectData?.machine_name,
|
||||||
<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>
|
machine_type: projectData?.machine_type,
|
||||||
<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>
|
manufacturer: projectData?.manufacturer,
|
||||||
<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">
|
{/* Navigation */}
|
||||||
<h3 className="font-medium text-gray-900 text-sm">Erkannte Gefahren:</h3>
|
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
{result.suggested_hazards.map((h: any, i: number) => (
|
<button
|
||||||
<div key={i} className="flex items-center gap-3 bg-white rounded-lg p-2 border border-gray-100">
|
onClick={() => router.push(`/sdk/iace/${projectId}`)}
|
||||||
<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>
|
className="px-4 py-2 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
<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>
|
Zurueck zur Uebersicht
|
||||||
</div>
|
</button>
|
||||||
))}
|
<button
|
||||||
</div>
|
onClick={() => {
|
||||||
)}
|
// Flush any pending save
|
||||||
</div>
|
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>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
{status === 'saved' && (
|
||||||
{/* ═══════════════ INTERVIEW MODE ═══════════════ */}
|
<>
|
||||||
{mode === 'interview' && !result && (
|
<svg className="w-3.5 h-3.5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6 max-w-2xl mx-auto">
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
<div className="space-y-4">
|
</svg>
|
||||||
{/* Previous answers (chat history) */}
|
<span className="text-green-600 dark:text-green-400">Gespeichert</span>
|
||||||
<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 === 'error' && (
|
||||||
{/* ═══════════════ WIZARD MODE ═══════════════ */}
|
<>
|
||||||
{mode === 'wizard' && !result && (
|
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
<span className="text-red-600 dark:text-red-400">Fehler beim Speichern</span>
|
||||||
{/* 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>
|
</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">€</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 React, { useState, useEffect, useRef } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { TechFileEditor } from '@/components/sdk/iace/TechFileEditor'
|
import { TechFileEditor } from '@/components/sdk/iace/TechFileEditor'
|
||||||
|
import { ReportGenerator } from './_components/ReportGenerator'
|
||||||
|
|
||||||
interface TechFileSection {
|
interface TechFileSection {
|
||||||
id: string
|
id: string
|
||||||
@@ -308,7 +309,10 @@ export default function TechFilePage() {
|
|||||||
Sie alle erforderlichen Abschnitte.
|
Sie alle erforderlichen Abschnitte.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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}>
|
<div className="relative" ref={exportMenuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowExportMenu((prev) => !prev)}
|
onClick={() => setShowExportMenu((prev) => !prev)}
|
||||||
@@ -347,6 +351,7 @@ export default function TechFilePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import IACEFlowFAB from './[projectId]/_components/IACEFlowFAB'
|
|||||||
|
|
||||||
const IACE_NAV_ITEMS = [
|
const IACE_NAV_ITEMS = [
|
||||||
{ id: 'overview', label: 'Uebersicht', href: '', icon: 'grid' },
|
{ 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: 'interview', label: 'Grenzen & Verwendung', href: '/interview', icon: 'chat' },
|
||||||
{ id: 'components', label: 'Komponenten', href: '/components', icon: 'cube' },
|
{ id: 'components', label: 'Komponenten', href: '/components', icon: 'cube' },
|
||||||
{ id: 'hazards', label: 'Hazard Log', href: '/hazards', icon: 'warning' },
|
{ 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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
</svg>
|
</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':
|
case 'chat':
|
||||||
return (
|
return (
|
||||||
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
|||||||
Reference in New Issue
Block a user