All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 19s
Kaputtes (admin) Layout geloescht (Role-Selection, 404-Sidebar, localhost-Dashboard). SDK-Flow nach /sdk/sdk-flow verschoben. Route-Gruppe (sdk) aufgeloest. Root-Seite redirected auf /sdk. ~25 ungenutzte Dateien/Verzeichnisse entfernt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
417 lines
16 KiB
TypeScript
417 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect } from 'react'
|
|
import { useParams } from 'next/navigation'
|
|
|
|
interface TechFileSection {
|
|
id: string
|
|
section_type: string
|
|
title: string
|
|
description: string
|
|
content: string | null
|
|
status: 'empty' | 'draft' | 'generated' | 'reviewed' | 'approved'
|
|
generated_at: string | null
|
|
approved_at: string | null
|
|
approved_by: string | null
|
|
required: boolean
|
|
}
|
|
|
|
const SECTION_TYPES: Record<string, { icon: string; description: string }> = {
|
|
risk_assessment_report: {
|
|
icon: '📊',
|
|
description: 'Zusammenfassung der Risikobeurteilung mit allen bewerteten Gefaehrdungen',
|
|
},
|
|
hazard_log: {
|
|
icon: '⚠️',
|
|
description: 'Vollstaendiges Gefaehrdungsprotokoll mit S/E/P-Bewertungen',
|
|
},
|
|
component_list: {
|
|
icon: '🔧',
|
|
description: 'Verzeichnis aller sicherheitsrelevanten Komponenten',
|
|
},
|
|
classification_report: {
|
|
icon: '📋',
|
|
description: 'Regulatorische Klassifikation (AI Act, MVO, CRA, NIS2)',
|
|
},
|
|
mitigation_report: {
|
|
icon: '🛡️',
|
|
description: 'Uebersicht aller Schutzmassnahmen nach 3-Stufen-Verfahren',
|
|
},
|
|
verification_report: {
|
|
icon: '✅',
|
|
description: 'Verifikationsplan und Ergebnisse aller Nachweisverfahren',
|
|
},
|
|
evidence_index: {
|
|
icon: '📎',
|
|
description: 'Index aller Nachweisdokumente mit Verknuepfungen',
|
|
},
|
|
declaration_of_conformity: {
|
|
icon: '📜',
|
|
description: 'EU-Konformitaetserklaerung',
|
|
},
|
|
instructions_for_use: {
|
|
icon: '📖',
|
|
description: 'Sicherheitshinweise fuer Betriebsanleitung',
|
|
},
|
|
monitoring_plan: {
|
|
icon: '📡',
|
|
description: 'Post-Market Surveillance Plan',
|
|
},
|
|
}
|
|
|
|
const STATUS_CONFIG: Record<string, { label: string; color: string; bgColor: string }> = {
|
|
empty: { label: 'Leer', color: 'text-gray-500', bgColor: 'bg-gray-100' },
|
|
draft: { label: 'Entwurf', color: 'text-yellow-700', bgColor: 'bg-yellow-100' },
|
|
generated: { label: 'Generiert', color: 'text-blue-700', bgColor: 'bg-blue-100' },
|
|
reviewed: { label: 'Geprueft', color: 'text-orange-700', bgColor: 'bg-orange-100' },
|
|
approved: { label: 'Freigegeben', color: 'text-green-700', bgColor: 'bg-green-100' },
|
|
}
|
|
|
|
function StatusBadge({ status }: { status: string }) {
|
|
const config = STATUS_CONFIG[status] || STATUS_CONFIG.empty
|
|
return (
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.bgColor} ${config.color}`}>
|
|
{config.label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function SectionViewer({
|
|
section,
|
|
onClose,
|
|
onApprove,
|
|
onSave,
|
|
}: {
|
|
section: TechFileSection
|
|
onClose: () => void
|
|
onApprove: (id: string) => void
|
|
onSave: (id: string, content: string) => void
|
|
}) {
|
|
const [editedContent, setEditedContent] = useState(section.content || '')
|
|
const [editing, setEditing] = useState(false)
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-xl">{SECTION_TYPES[section.section_type]?.icon || '📄'}</span>
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{section.title}</h3>
|
|
<StatusBadge status={section.status} />
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{!editing && section.content && (
|
|
<button
|
|
onClick={() => setEditing(true)}
|
|
className="text-sm px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
|
|
>
|
|
Bearbeiten
|
|
</button>
|
|
)}
|
|
{editing && (
|
|
<button
|
|
onClick={() => {
|
|
onSave(section.id, editedContent)
|
|
setEditing(false)
|
|
}}
|
|
className="text-sm px-3 py-1.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
|
>
|
|
Speichern
|
|
</button>
|
|
)}
|
|
{section.status !== 'approved' && section.content && !editing && (
|
|
<button
|
|
onClick={() => onApprove(section.id)}
|
|
className="text-sm px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
|
>
|
|
Freigeben
|
|
</button>
|
|
)}
|
|
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="p-6">
|
|
{editing ? (
|
|
<textarea
|
|
value={editedContent}
|
|
onChange={(e) => setEditedContent(e.target.value)}
|
|
rows={20}
|
|
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
/>
|
|
) : section.content ? (
|
|
<div className="prose prose-sm max-w-none dark:prose-invert">
|
|
<pre className="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-750 p-4 rounded-lg">
|
|
{section.content}
|
|
</pre>
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8 text-gray-500">
|
|
Kein Inhalt vorhanden. Klicken Sie "Generieren" um den Abschnitt zu erstellen.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function TechFilePage() {
|
|
const params = useParams()
|
|
const projectId = params.projectId as string
|
|
const [sections, setSections] = useState<TechFileSection[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [generatingSection, setGeneratingSection] = useState<string | null>(null)
|
|
const [viewingSection, setViewingSection] = useState<TechFileSection | null>(null)
|
|
const [exporting, setExporting] = useState(false)
|
|
|
|
useEffect(() => {
|
|
fetchSections()
|
|
}, [projectId])
|
|
|
|
async function fetchSections() {
|
|
try {
|
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/tech-file`)
|
|
if (res.ok) {
|
|
const json = await res.json()
|
|
setSections(json.sections || json || [])
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch tech file sections:', err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
async function handleGenerate(sectionId: string) {
|
|
setGeneratingSection(sectionId)
|
|
try {
|
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/tech-file/${sectionId}/generate`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
if (res.ok) {
|
|
await fetchSections()
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to generate section:', err)
|
|
} finally {
|
|
setGeneratingSection(null)
|
|
}
|
|
}
|
|
|
|
async function handleApprove(sectionId: string) {
|
|
try {
|
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/tech-file/${sectionId}/approve`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
if (res.ok) {
|
|
await fetchSections()
|
|
if (viewingSection && viewingSection.id === sectionId) {
|
|
const updated = sections.find((s) => s.id === sectionId)
|
|
if (updated) setViewingSection({ ...updated, status: 'approved' })
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to approve section:', err)
|
|
}
|
|
}
|
|
|
|
async function handleSave(sectionId: string, content: string) {
|
|
try {
|
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/tech-file/${sectionId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ content }),
|
|
})
|
|
if (res.ok) {
|
|
await fetchSections()
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to save section:', err)
|
|
}
|
|
}
|
|
|
|
async function handleExportZip() {
|
|
setExporting(true)
|
|
try {
|
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/tech-file/export`, {
|
|
method: 'POST',
|
|
})
|
|
if (res.ok) {
|
|
const blob = await res.blob()
|
|
const url = window.URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `CE-Akte-${projectId}.zip`
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
window.URL.revokeObjectURL(url)
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to export:', err)
|
|
} finally {
|
|
setExporting(false)
|
|
}
|
|
}
|
|
|
|
const approvedCount = sections.filter((s) => s.status === 'approved').length
|
|
const requiredCount = sections.filter((s) => s.required).length
|
|
const requiredApproved = sections.filter((s) => s.required && s.status === 'approved').length
|
|
const allRequiredApproved = requiredApproved === requiredCount && requiredCount > 0
|
|
|
|
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">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">CE-Akte (Technical File)</h1>
|
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
Technische Dokumentation gemaess Maschinenverordnung Anhang IV. Generieren, pruefen und freigeben
|
|
Sie alle erforderlichen Abschnitte.
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={handleExportZip}
|
|
disabled={!allRequiredApproved || exporting}
|
|
title={!allRequiredApproved ? 'Alle Pflichtabschnitte muessen freigegeben sein' : 'CE-Akte als ZIP exportieren'}
|
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
|
|
allRequiredApproved && !exporting
|
|
? 'bg-purple-600 text-white hover:bg-purple-700'
|
|
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
|
}`}
|
|
>
|
|
{exporting ? (
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
|
) : (
|
|
<svg className="w-5 h-5" 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>
|
|
)}
|
|
ZIP exportieren
|
|
</button>
|
|
</div>
|
|
|
|
{/* Progress */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
Fortschritt: {approvedCount} von {sections.length} Abschnitten freigegeben
|
|
</span>
|
|
<span className="text-sm text-gray-500">
|
|
Pflicht: {requiredApproved}/{requiredCount}
|
|
</span>
|
|
</div>
|
|
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
|
<div
|
|
className="bg-purple-600 h-2.5 rounded-full transition-all"
|
|
style={{ width: `${sections.length > 0 ? (approvedCount / sections.length) * 100 : 0}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Section Viewer */}
|
|
{viewingSection && (
|
|
<SectionViewer
|
|
section={viewingSection}
|
|
onClose={() => setViewingSection(null)}
|
|
onApprove={handleApprove}
|
|
onSave={handleSave}
|
|
/>
|
|
)}
|
|
|
|
{/* Sections List */}
|
|
<div className="space-y-3">
|
|
{sections.map((section) => (
|
|
<div
|
|
key={section.id}
|
|
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4"
|
|
>
|
|
<div className="flex items-center gap-4">
|
|
<span className="text-2xl flex-shrink-0">
|
|
{SECTION_TYPES[section.section_type]?.icon || '📄'}
|
|
</span>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{section.title}</h3>
|
|
<StatusBadge status={section.status} />
|
|
{section.required && (
|
|
<span className="text-xs text-red-500 font-medium">Pflicht</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-gray-500 mt-0.5">
|
|
{SECTION_TYPES[section.section_type]?.description || section.description}
|
|
</p>
|
|
{section.approved_at && (
|
|
<span className="text-xs text-green-600 mt-0.5 block">
|
|
Freigegeben am {new Date(section.approved_at).toLocaleDateString('de-DE')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
{section.content && (
|
|
<button
|
|
onClick={() => setViewingSection(section)}
|
|
className="text-sm px-3 py-1.5 border border-gray-200 text-gray-600 rounded-lg hover:bg-gray-50 transition-colors"
|
|
>
|
|
Anzeigen
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => handleGenerate(section.id)}
|
|
disabled={generatingSection === section.id}
|
|
className="text-sm px-3 py-1.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
|
|
>
|
|
{generatingSection === section.id ? (
|
|
<>
|
|
<div className="animate-spin rounded-full h-3 w-3 border-b-2 border-white" />
|
|
Generiert...
|
|
</>
|
|
) : (
|
|
'Generieren'
|
|
)}
|
|
</button>
|
|
{section.content && section.status !== 'approved' && (
|
|
<button
|
|
onClick={() => handleApprove(section.id)}
|
|
className="text-sm px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
|
>
|
|
Freigeben
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{sections.length === 0 && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
|
<div className="w-16 h-16 mx-auto bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
|
|
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
|
</svg>
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Keine Abschnitte vorhanden</h3>
|
|
<p className="mt-2 text-gray-500 max-w-md mx-auto">
|
|
Die CE-Akte wird automatisch strukturiert, sobald Komponenten und Gefaehrdungen erfasst sind.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|