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:
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user