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
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>
310 lines
13 KiB
TypeScript
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">
|
|
← 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>
|
|
)
|
|
}
|