feat(iace): complete CE risk assessment — LLM tech-file generation, multi-format export, TipTap editor
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 36s
CI/CD / test-python-backend-compliance (push) Successful in 33s
CI/CD / test-python-document-crawler (push) Successful in 24s
CI/CD / test-python-dsms-gateway (push) Successful in 21s
CI/CD / validate-canonical-controls (push) Successful in 13s
CI/CD / Deploy (push) Successful in 2s

Phase 1: Fix completeness gates G23 (require verified/rejected mitigations) and G09 (audit trail check)
Phase 2: LLM-based tech-file section generation with 19 German prompts and RAG enrichment
Phase 3: Multi-format document export (PDF/Excel/DOCX/Markdown/JSON)
Phase 4: Company profile → IACE data flow with auto component/classification creation
Phase 5: TipTap WYSIWYG editor replacing textarea for tech-file sections
Phase 6: User journey tests, developer portal API reference, updated documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-16 12:50:53 +01:00
parent 5adb1c5f16
commit 6d2de9b897
16 changed files with 5828 additions and 161 deletions

View File

@@ -1,7 +1,8 @@
'use client'
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useRef } from 'react'
import { useParams } from 'next/navigation'
import { TechFileEditor } from '@/components/sdk/iace/TechFileEditor'
interface TechFileSection {
id: string
@@ -67,6 +68,14 @@ const STATUS_CONFIG: Record<string, { label: string; color: string; bgColor: str
approved: { label: 'Freigegeben', color: 'text-green-700', bgColor: 'bg-green-100' },
}
const EXPORT_FORMATS: { value: string; label: string; extension: string }[] = [
{ value: 'pdf', label: 'PDF', extension: '.pdf' },
{ value: 'xlsx', label: 'Excel', extension: '.xlsx' },
{ value: 'docx', label: 'Word', extension: '.docx' },
{ value: 'md', label: 'Markdown', extension: '.md' },
{ value: 'json', label: 'JSON', extension: '.json' },
]
function StatusBadge({ status }: { status: string }) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.empty
return (
@@ -87,7 +96,6 @@ function SectionViewer({
onApprove: (id: string) => void
onSave: (id: string, content: string) => void
}) {
const [editedContent, setEditedContent] = useState(section.content || '')
const [editing, setEditing] = useState(false)
return (
@@ -111,13 +119,10 @@ function SectionViewer({
)}
{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"
onClick={() => setEditing(false)}
className="text-sm px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Speichern
Fertig
</button>
)}
{section.status !== 'approved' && section.content && !editing && (
@@ -136,19 +141,19 @@ function SectionViewer({
</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>
{section.content ? (
editing ? (
<TechFileEditor
content={section.content}
onSave={(html) => onSave(section.id, html)}
/>
) : (
<TechFileEditor
content={section.content}
onSave={() => {}}
readOnly
/>
)
) : (
<div className="text-center py-8 text-gray-500">
Kein Inhalt vorhanden. Klicken Sie &quot;Generieren&quot; um den Abschnitt zu erstellen.
@@ -167,6 +172,21 @@ export default function TechFilePage() {
const [generatingSection, setGeneratingSection] = useState<string | null>(null)
const [viewingSection, setViewingSection] = useState<TechFileSection | null>(null)
const [exporting, setExporting] = useState(false)
const [showExportMenu, setShowExportMenu] = useState(false)
const exportMenuRef = useRef<HTMLDivElement>(null)
// Close export menu when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (exportMenuRef.current && !exportMenuRef.current.contains(event.target as Node)) {
setShowExportMenu(false)
}
}
if (showExportMenu) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [showExportMenu])
useEffect(() => {
fetchSections()
@@ -236,18 +256,22 @@ export default function TechFilePage() {
}
}
async function handleExportZip() {
async function handleExport(format: string) {
setExporting(true)
setShowExportMenu(false)
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/tech-file/export`, {
method: 'POST',
})
const res = await fetch(
`/api/sdk/v1/iace/projects/${projectId}/tech-file/export?format=${format}`,
{ method: 'GET' }
)
if (res.ok) {
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const formatConfig = EXPORT_FORMATS.find((f) => f.value === format)
const extension = formatConfig?.extension || `.${format}`
const a = document.createElement('a')
a.href = url
a.download = `CE-Akte-${projectId}.zip`
a.download = `CE-Akte-${projectId}${extension}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
@@ -284,25 +308,45 @@ export default function TechFilePage() {
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" />
{/* Export Dropdown */}
<div className="relative" ref={exportMenuRef}>
<button
onClick={() => setShowExportMenu((prev) => !prev)}
disabled={!allRequiredApproved || exporting}
title={!allRequiredApproved ? 'Alle Pflichtabschnitte muessen freigegeben sein' : 'CE-Akte 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>
)}
Exportieren
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{showExportMenu && allRequiredApproved && !exporting && (
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50">
{EXPORT_FORMATS.map((fmt) => (
<button
key={fmt.value}
onClick={() => handleExport(fmt.value)}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-3"
>
<span className="text-xs font-mono uppercase w-10 text-gray-400">{fmt.extension}</span>
<span>{fmt.label}</span>
</button>
))}
</div>
)}
ZIP exportieren
</button>
</div>
</div>
{/* Progress */}

View File

@@ -0,0 +1,272 @@
'use client'
import React, { useCallback, useEffect, useRef } from 'react'
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Table from '@tiptap/extension-table'
import TableRow from '@tiptap/extension-table-row'
import TableHeader from '@tiptap/extension-table-header'
import TableCell from '@tiptap/extension-table-cell'
import Image from '@tiptap/extension-image'
interface TechFileEditorProps {
content: string
onSave: (content: string) => void
readOnly?: boolean
}
function normalizeContent(content: string): string {
if (!content) return '<p></p>'
const trimmed = content.trim()
// If it looks like JSON array or has no HTML tags, wrap in <p>
if (trimmed.startsWith('[') || !/<[a-z][\s\S]*>/i.test(trimmed)) {
return `<p>${trimmed.replace(/\n/g, '</p><p>')}</p>`
}
return trimmed
}
interface ToolbarButtonProps {
onClick: () => void
isActive?: boolean
disabled?: boolean
title: string
children: React.ReactNode
}
function ToolbarButton({ onClick, isActive, disabled, title, children }: ToolbarButtonProps) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={`p-1.5 rounded text-sm font-medium transition-colors ${
isActive
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'
} disabled:opacity-40 disabled:cursor-not-allowed`}
>
{children}
</button>
)
}
export function TechFileEditor({ content, onSave, readOnly = false }: TechFileEditorProps) {
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const onSaveRef = useRef(onSave)
onSaveRef.current = onSave
const debouncedSave = useCallback((html: string) => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
debounceTimer.current = setTimeout(() => {
onSaveRef.current(html)
}, 3000)
}, [])
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [2, 3, 4] },
}),
Table.configure({
resizable: true,
HTMLAttributes: { class: 'border-collapse border border-gray-300' },
}),
TableRow,
TableHeader,
TableCell,
Image.configure({
HTMLAttributes: { class: 'max-w-full rounded' },
}),
],
content: normalizeContent(content),
editable: !readOnly,
onUpdate: ({ editor: ed }: { editor: Editor }) => {
if (!readOnly) {
debouncedSave(ed.getHTML())
}
},
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none dark:prose-invert focus:outline-none min-h-[300px] px-4 py-3',
},
},
})
// Update content when parent prop changes
useEffect(() => {
if (editor && content) {
const normalized = normalizeContent(content)
const currentHTML = editor.getHTML()
if (normalized !== currentHTML) {
editor.commands.setContent(normalized)
}
}
}, [content, editor])
// Update editable state when readOnly changes
useEffect(() => {
if (editor) {
editor.setEditable(!readOnly)
}
}, [readOnly, editor])
// Cleanup debounce timer
useEffect(() => {
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
}
}, [])
if (!editor) {
return (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 min-h-[300px] flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600" />
</div>
)
}
return (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{/* Toolbar */}
{!readOnly && (
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1.5 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
{/* Text formatting */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
title="Fett (Ctrl+B)"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
title="Kursiv (Ctrl+I)"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z" />
</svg>
</ToolbarButton>
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Headings */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
isActive={editor.isActive('heading', { level: 2 })}
title="Ueberschrift 2"
>
<span className="text-xs font-bold">H2</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
isActive={editor.isActive('heading', { level: 3 })}
title="Ueberschrift 3"
>
<span className="text-xs font-bold">H3</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 4 }).run()}
isActive={editor.isActive('heading', { level: 4 })}
title="Ueberschrift 4"
>
<span className="text-xs font-bold">H4</span>
</ToolbarButton>
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Lists */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
title="Aufzaehlung"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
title="Nummerierte Liste"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" />
</svg>
</ToolbarButton>
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Table */}
<ToolbarButton
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
title="Tabelle einfuegen (3x3)"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="3" y1="15" x2="21" y2="15" />
<line x1="9" y1="3" x2="9" y2="21" />
<line x1="15" y1="3" x2="15" y2="21" />
</svg>
</ToolbarButton>
{/* Blockquote */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')}
title="Zitat"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
</svg>
</ToolbarButton>
{/* Code Block */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
isActive={editor.isActive('codeBlock')}
title="Code-Block"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="16,18 22,12 16,6" />
<polyline points="8,6 2,12 8,18" />
</svg>
</ToolbarButton>
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Undo / Redo */}
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Rueckgaengig (Ctrl+Z)"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Wiederholen (Ctrl+Shift+Z)"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.4 10.6C16.55 8.99 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z" />
</svg>
</ToolbarButton>
</div>
)}
{/* Editor Content */}
<EditorContent editor={editor} />
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,14 @@
"test:all": "vitest run && playwright test --project=chromium"
},
"dependencies": {
"@tiptap/extension-image": "^3.20.2",
"@tiptap/extension-table": "^3.20.2",
"@tiptap/extension-table-cell": "^3.20.2",
"@tiptap/extension-table-header": "^3.20.2",
"@tiptap/extension-table-row": "^3.20.2",
"@tiptap/pm": "^3.20.2",
"@tiptap/react": "^3.20.2",
"@tiptap/starter-kit": "^3.20.2",
"bpmn-js": "^18.0.1",
"jspdf": "^4.1.0",
"jszip": "^3.10.1",