Files
breakpilot-compliance/admin-compliance/app/sdk/cra/[projectId]/documents/page.tsx
T
Benjamin Admin 27384aea09
CI / detect-changes (push) Successful in 11s
CI / branch-name (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 15s
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / loc-budget (push) Failing after 16s
CI / go-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 3m1s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 39s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
feat(cra): Phase 5 — Technical Doc + DoC Generator (Annex V + VII)
Migration 122: compliance_cra_documents with versioning + approval workflow
- doc_type whitelist: doc_eu_conformity, doc_technical, doc_cvd_policy,
  doc_update_policy, doc_sbom_report
- Status state machine: draft → reviewed → approved (+ superseded)
- Snapshot generation_context for audit trail

New module cra_doc_templates.py — pure-function generators (no DB access):
- doc_eu_conformity: EU DoC structured per CRA Annex VII (all 7 mandatory fields)
- doc_technical: Technische Dokumentation per CRA Annex V
- doc_cvd_policy: ISO/IEC 29147-compliant CVD policy with SLA table
- doc_update_policy: Patch/Update policy with Lifecycle + CSAF reference
- doc_sbom_report: Latest SBOM summary with top-10 components
Returns (title, markdown_content, requirements_coverage) — coverage tracks
how many mandatory fields are filled vs placeholders.

Backend endpoints:
- POST /documents/generate — generates doc, supersedes previous version,
  increments version number atomically
- GET /documents — lists all 5 doc types (also "not_generated" stubs)
- GET /documents/{id} — full content_md
- POST /documents/{id}/approve — set status + signed_by + signed_at

Frontend:
- /documents page: 5 doc-type cards with Generate/Re-Generate buttons,
  inline Markdown preview with .md download, 2-step approval flow
  (reviewed → approved with signature)
- Optional params form: manufacturer, notified_body, security_contact
- Dashboard: +1 button (Dokumente, 7 buttons total)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:10:23 +02:00

310 lines
13 KiB
TypeScript

'use client'
import React, { useEffect, useState, useCallback, use } from 'react'
interface DocItem {
id: string | null
doc_type: string
doc_type_label: string
title: string
content_md: string | null
version: number
requirements_coverage: Record<string, unknown>
status: string
signed_by: string | null
signed_at: string | null
generated_at: string | null
superseded_at: string | null
}
interface DocListResponse {
project_id: string
total: number
items: DocItem[]
}
const STATUS_STYLE: Record<string, string> = {
draft: 'bg-yellow-100 text-yellow-800',
reviewed: 'bg-blue-100 text-blue-800',
approved: 'bg-green-100 text-green-800',
superseded: 'bg-gray-200 text-gray-600',
not_generated: 'bg-gray-100 text-gray-400',
}
const STATUS_LABEL: Record<string, string> = {
draft: 'Entwurf',
reviewed: 'Geprueft',
approved: 'Freigegeben',
superseded: 'Veraltet',
not_generated: 'Nicht erzeugt',
}
export default function DocumentsPage({
params,
}: {
params: Promise<{ projectId: string }>
}) {
const { projectId } = use(params)
const [data, setData] = useState<DocListResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [generating, setGenerating] = useState<string | null>(null)
const [expanded, setExpanded] = useState<string | null>(null)
const [docContent, setDocContent] = useState<Record<string, string>>({})
// Generation params per doc type
const [manufacturer, setManufacturer] = useState('')
const [notifiedBody, setNotifiedBody] = useState('')
const [securityContact, setSecurityContact] = useState('')
// Approval form
const [approving, setApproving] = useState<string | null>(null)
const [signedBy, setSignedBy] = useState('')
const tenant = '00000000-0000-0000-0000-000000000001'
const load = useCallback(async () => {
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/documents`, {
headers: { 'X-Tenant-ID': tenant },
})
if (!res.ok) throw new Error(await res.text())
setData(await res.json())
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [projectId])
useEffect(() => { load() }, [load])
const generate = async (docType: string) => {
setGenerating(docType)
setError('')
try {
const body: Record<string, string> = { doc_type: docType }
if (docType === 'doc_eu_conformity') {
if (manufacturer) body.manufacturer = manufacturer
if (notifiedBody) body.notified_body = notifiedBody
}
if (docType === 'doc_cvd_policy' && securityContact) {
body.security_contact = securityContact
}
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/documents/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenant },
body: JSON.stringify(body),
})
if (!res.ok) throw new Error(await res.text())
const doc = await res.json()
setDocContent(prev => ({ ...prev, [doc.id]: doc.content_md }))
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Generierung fehlgeschlagen')
} finally {
setGenerating(null)
}
}
const loadContent = async (docId: string) => {
if (docContent[docId]) {
setExpanded(expanded === docId ? null : docId)
return
}
try {
const res = await fetch(`/api/sdk/v1/cra/documents/${docId}`, {
headers: { 'X-Tenant-ID': tenant },
})
if (!res.ok) throw new Error(await res.text())
const doc = await res.json()
setDocContent(prev => ({ ...prev, [docId]: doc.content_md }))
setExpanded(docId)
} catch (e) {
setError(e instanceof Error ? e.message : 'Laden fehlgeschlagen')
}
}
const approve = async (docId: string, status: string) => {
if (!signedBy.trim()) {
setError('Bitte Namen zur Freigabe eintragen.')
return
}
setApproving(docId)
setError('')
try {
const res = await fetch(`/api/sdk/v1/cra/documents/${docId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenant },
body: JSON.stringify({ signed_by: signedBy, status }),
})
if (!res.ok) throw new Error(await res.text())
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Freigabe fehlgeschlagen')
} finally {
setApproving(null)
}
}
const download = (doc: DocItem) => {
const content = docContent[doc.id || ''] || doc.content_md || ''
if (!content) return
const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${doc.doc_type}_v${doc.version}_${doc.id?.slice(0, 8)}.md`
a.click()
URL.revokeObjectURL(url)
}
if (loading) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-gray-500">Laedt...</p></div>
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-5xl mx-auto px-4">
<div className="mb-6">
<a href={`/sdk/cra/${projectId}`} className="text-sm text-blue-600 hover:underline">
&larr; Zurueck zum Projekt
</a>
<h1 className="text-2xl font-bold text-gray-900 mt-2">CRA-Dokumente</h1>
<p className="text-sm text-gray-600 mt-1">
DoC (Annex VII), Technische Doku (Annex V), CVD-Policy, Update-Policy, SBOM-Bericht generiert aus aktuellem Projektstand.
</p>
</div>
{error && (
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">
<pre className="whitespace-pre-wrap">{error}</pre>
<button onClick={() => setError('')} className="text-xs underline mt-1">Schliessen</button>
</div>
)}
{/* Generation params */}
<details className="bg-white rounded-xl border border-gray-200 p-4 mb-4">
<summary className="cursor-pointer text-sm font-medium text-gray-700">
Optionale Parameter fuer Generierung (Hersteller, NoBo, Security-Contact)
</summary>
<div className="mt-3 space-y-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Hersteller (fuer DoC)</label>
<input value={manufacturer} onChange={e => setManufacturer(e.target.value)} placeholder="Acme GmbH, Musterstr. 1, 80331 Muenchen" className="w-full px-3 py-2 border rounded text-sm" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Notified Body (falls Modul C)</label>
<input value={notifiedBody} onChange={e => setNotifiedBody(e.target.value)} placeholder="TUEV Nord (NB-0044)" className="w-full px-3 py-2 border rounded text-sm" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Security-Contact (fuer CVD-Policy)</label>
<input type="email" value={securityContact} onChange={e => setSecurityContact(e.target.value)} placeholder="security@example.com" className="w-full px-3 py-2 border rounded text-sm" />
</div>
</div>
</details>
<div className="space-y-3">
{data?.items.map(doc => (
<div key={doc.doc_type} className="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<div className="flex items-start justify-between gap-4 mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-gray-900">{doc.doc_type_label}</h3>
{doc.version > 0 && (
<span className="text-xs text-gray-500">v{doc.version}</span>
)}
<span className={`px-2 py-0.5 text-xs rounded ${STATUS_STYLE[doc.status]}`}>
{STATUS_LABEL[doc.status]}
</span>
</div>
{doc.generated_at && (
<p className="text-xs text-gray-500 mt-1">
Generiert: {new Date(doc.generated_at).toLocaleString('de-DE')}
{doc.signed_by && doc.signed_at && (
<> · Freigegeben von <span className="font-medium">{doc.signed_by}</span> am {new Date(doc.signed_at).toLocaleString('de-DE')}</>
)}
</p>
)}
{doc.requirements_coverage && Object.keys(doc.requirements_coverage).length > 0 && (
<p className="text-xs text-gray-500 mt-1">
Coverage: {String(doc.requirements_coverage.fields_filled || 0)} / {String(doc.requirements_coverage.fields_required || 0)} Pflichtfelder · {String(doc.requirements_coverage.annex_anchor || '')}
</p>
)}
</div>
<div className="flex gap-2 flex-shrink-0">
<button
onClick={() => generate(doc.doc_type)}
disabled={generating === doc.doc_type}
className="px-3 py-1.5 bg-red-600 text-white text-sm rounded hover:bg-red-700 disabled:bg-gray-300"
>
{generating === doc.doc_type ? 'Generiere...' : (doc.version === 0 ? 'Generieren' : 'Neu generieren')}
</button>
{doc.id && (
<button
onClick={() => loadContent(doc.id!)}
className="px-3 py-1.5 bg-gray-100 text-gray-700 text-sm rounded hover:bg-gray-200"
>
{expanded === doc.id ? 'Einklappen' : 'Inhalt'}
</button>
)}
</div>
</div>
{expanded === doc.id && doc.id && docContent[doc.id] && (
<div className="mt-3 border-t border-gray-200 pt-3">
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-gray-500 font-mono">Markdown-Vorschau</p>
<button
onClick={() => download(doc)}
className="text-xs px-2 py-1 bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
>
Download (.md)
</button>
</div>
<pre className="bg-gray-50 rounded p-3 text-xs overflow-x-auto max-h-96 whitespace-pre-wrap font-mono">
{docContent[doc.id]}
</pre>
{doc.status === 'draft' && (
<div className="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded">
<p className="text-xs text-yellow-800 mb-2">
Vor Freigabe pruefen ob alle <code>[zu ergaenzen]</code>-Stellen gefuellt sind.
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={signedBy}
onChange={e => setSignedBy(e.target.value)}
placeholder="Name + Rolle des Freigebenden"
className="flex-1 px-2 py-1 border rounded text-sm"
/>
<button
onClick={() => approve(doc.id!, 'reviewed')}
disabled={approving === doc.id || !signedBy.trim()}
className="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 disabled:bg-gray-300"
>
Als geprueft markieren
</button>
<button
onClick={() => approve(doc.id!, 'approved')}
disabled={approving === doc.id || !signedBy.trim()}
className="px-3 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 disabled:bg-gray-300"
>
Freigeben
</button>
</div>
</div>
)}
</div>
)}
</div>
))}
</div>
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-xl p-4 text-sm text-blue-900">
<strong>Hinweis:</strong> Diese Dokumente sind <em>Skelette</em> aus dem aktuellen Projektstand. Markdown-Format, manuelles Editieren + Unterzeichnung erforderlich vor Inverkehrbringen. PDF-Export folgt in Phase 5.5.
</div>
</div>
</div>
)
}