b19d76407d
CRA frontend pages hardcoded tenant 00000000-…-001 while IACE uses the dev tenant 9282a473-… → a demo customer was split/invisible across modules. Align all app/sdk/cra pages to 9282a473-… so the whole CRA<->IACE journey lives under ONE tenant. Add scripts/seed_demo_customer.py: seeds CompanyProfile + IACE project (components, hazards, mitigations) + CRA project (intake, scope-check, assessment snapshot from faked repo findings + components + safety functions) — the source- repo layer is faked so the full frontend is walkable once. Co-Authored-By: Claude Opus 4.7 <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 = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
|
|
|
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>
|
|
)
|
|
}
|