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
@@ -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)}
@@ -347,6 +351,7 @@ export default function TechFilePage() {
</div>
)}
</div>
</div>
</div>
{/* Progress */}