239702fdca
A focused client page at /sdk/onboarding-advisor that exercises POST /api/compliance/onboarding/ advisor-start through the existing compliance proxy: pick certifications + target + scanner findings (observation / partial / requirement) and render the result — headline, silent-intake summary, auto-detected (green), indications (amber), next-best questions with WHY, inferred (Welt-1) vs rejected assumptions, capability delta, evidence requests, completeness. NOT the regulation gap engine (/sdk/gap-analysis is a different flow). No new backend; calls only the existing endpoint. 195 lines.
196 lines
12 KiB
TypeScript
196 lines
12 KiB
TypeScript
'use client'
|
||
|
||
// ETO / Onboarding-Advisor — thin operator surface over POST /api/compliance/onboarding/advisor-start.
|
||
// Certifications + target + scanner findings -> Silent Pass -> Advisor. NOT the regulation gap engine
|
||
// (/sdk/gap-analysis is a different flow: product -> applicable regulations). This tests the cert->delta
|
||
// case: "TISAX/ISO27001 -> CRA, what is auto-detected, what stays an open question?". No new backend.
|
||
|
||
import React, { useEffect, useState } from 'react'
|
||
|
||
const CERTS = ['ISO27001', 'TISAX', 'ISO9001', 'IEC62443', 'ISO13485', 'ISO14001', 'ASPICE', 'IATF16949']
|
||
|
||
// label -> {signal_id, source_type} — demonstrates all three signal KINDS (observation / partial / requirement)
|
||
const FINDINGS: Array<{ label: string; signal_id: string; source_type: string; kind: string }> = [
|
||
{ label: 'SBOM im Repo (CycloneDX/SPDX)', signal_id: 'cyclonedx_found', source_type: 'repository', kind: 'observation' },
|
||
{ label: 'security.txt / CVD-Policy veröffentlicht', signal_id: 'security_txt', source_type: 'website', kind: 'observation' },
|
||
{ label: 'Signierte Releases', signal_id: 'signed_releases', source_type: 'repository', kind: 'observation' },
|
||
{ label: 'Produkt-Risikobewertung (Dokument)', signal_id: 'risk_assessment_pdf', source_type: 'document', kind: 'observation' },
|
||
{ label: 'CI-Pipeline vorhanden (nur Indikation)', signal_id: 'github_actions_ci', source_type: 'repository', kind: 'partial' },
|
||
{ label: 'Cloud-/vernetztes Produkt', signal_id: 'cloud_hosted', source_type: 'product', kind: 'observation' },
|
||
{ label: 'Ausschreibung FORDERT SBOM (Requirement)', signal_id: 'requires_sbom', source_type: 'tender', kind: 'requirement' },
|
||
{ label: 'OEM FORDERT PSIRT (Requirement)', signal_id: 'supplier_requires_psirt', source_type: 'oem', kind: 'requirement' },
|
||
]
|
||
|
||
interface Question { capability_id: string; question_intent: string; why: string; information_value: number; priority: string }
|
||
interface Inferred { certification: string; capabilities: string[]; statement: string }
|
||
interface Rejected { certification?: string; statement: string; reason: string }
|
||
interface Measure { capability_id: string; leverage: number; closes: string[] }
|
||
interface AdvisorResponse {
|
||
silent_intake_summary: string; headline: string; auto_detected: string[]; indications: string[]
|
||
inferred_assumptions: Inferred[]; rejected_assumptions: Rejected[]; top_5_questions: Question[]
|
||
capability_delta: string[]; top_measures: Measure[]; evidence_requests: string[]
|
||
unsupported_domains: string[]; completeness_summary: string
|
||
}
|
||
|
||
const PROXY = '/api/sdk/v1/compliance/onboarding'
|
||
|
||
function Chips({ items, tone }: { items: string[]; tone: string }) {
|
||
if (!items.length) return <span className="text-gray-400 text-sm">—</span>
|
||
return (
|
||
<div className="flex flex-wrap gap-2">
|
||
{items.map(c => <span key={c} className={`px-2.5 py-1 rounded-full text-xs font-medium ${tone}`}>{c}</span>)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Section({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
|
||
return (
|
||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||
<h3 className="font-semibold text-gray-900">{title}</h3>
|
||
{hint && <p className="text-xs text-gray-500 mt-0.5 mb-2">{hint}</p>}
|
||
<div className="mt-2">{children}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function OnboardingAdvisorPage() {
|
||
const [targets, setTargets] = useState<string[]>([])
|
||
const [company, setCompany] = useState('Beispiel Maschinenbau')
|
||
const [industry, setIndustry] = useState('machine_builder')
|
||
const [certs, setCerts] = useState<string[]>(['ISO27001', 'ISO9001'])
|
||
const [target, setTarget] = useState('CRA')
|
||
const [findings, setFindings] = useState<string[]>(['cyclonedx_found', 'github_actions_ci', 'requires_sbom'])
|
||
const [knownEvidence, setKnownEvidence] = useState('CE-Prozess')
|
||
const [result, setResult] = useState<AdvisorResponse | null>(null)
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState('')
|
||
|
||
useEffect(() => {
|
||
fetch(`${PROXY}/targets`).then(r => r.json()).then(d => {
|
||
if (Array.isArray(d.targets)) { setTargets(d.targets); if (!d.targets.includes('CRA') && d.targets[0]) setTarget(d.targets[0]) }
|
||
}).catch(() => {})
|
||
}, [])
|
||
|
||
const toggle = (list: string[], set: (v: string[]) => void, v: string) =>
|
||
set(list.includes(v) ? list.filter(x => x !== v) : [...list, v])
|
||
|
||
const run = async () => {
|
||
setLoading(true); setError(''); setResult(null)
|
||
try {
|
||
const scanner_findings = FINDINGS.filter(f => findings.includes(f.signal_id))
|
||
.map(f => ({ signal_id: f.signal_id, source_type: f.source_type }))
|
||
const res = await fetch(`${PROXY}/advisor-start`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
company, industry, products: [], markets: ['EU'], certifications: certs,
|
||
known_evidence: knownEvidence ? knownEvidence.split(',').map(s => s.trim()).filter(Boolean) : [],
|
||
target, scanner_findings,
|
||
}),
|
||
})
|
||
if (!res.ok) throw new Error(await res.text())
|
||
setResult(await res.json())
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : 'Advisor fehlgeschlagen')
|
||
} finally { setLoading(false) }
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 py-8">
|
||
<div className="max-w-5xl mx-auto px-4">
|
||
<h1 className="text-3xl font-bold text-gray-900">ETO / Onboarding-Advisor</h1>
|
||
<p className="text-gray-600 mt-2 mb-6">
|
||
Zertifikate + Ziel + Scanner-Signale → Silent Pass → Capability-Delta + nächste beste Fragen.
|
||
Welt-1: ein Zertifikat <em>legt nahe</em>, beweist nichts (Verifikation erforderlich).
|
||
</p>
|
||
|
||
<div className="grid md:grid-cols-2 gap-4 mb-6">
|
||
<Section title="Unternehmen & Ziel">
|
||
<label className="block text-sm text-gray-600">Unternehmen
|
||
<input value={company} onChange={e => setCompany(e.target.value)} className="mt-1 w-full border rounded-lg px-3 py-2" /></label>
|
||
<label className="block text-sm text-gray-600 mt-3">Branche
|
||
<input value={industry} onChange={e => setIndustry(e.target.value)} className="mt-1 w-full border rounded-lg px-3 py-2" /></label>
|
||
<label className="block text-sm text-gray-600 mt-3">Ziel
|
||
<select value={target} onChange={e => setTarget(e.target.value)} className="mt-1 w-full border rounded-lg px-3 py-2">
|
||
{(targets.length ? targets : ['CRA']).map(t => <option key={t} value={t}>{t}</option>)}
|
||
</select></label>
|
||
<label className="block text-sm text-gray-600 mt-3">Vorhandene Nachweise (kommagetrennt)
|
||
<input value={knownEvidence} onChange={e => setKnownEvidence(e.target.value)} className="mt-1 w-full border rounded-lg px-3 py-2" /></label>
|
||
</Section>
|
||
|
||
<Section title="Zertifizierungen">
|
||
<div className="flex flex-wrap gap-2">
|
||
{CERTS.map(c => (
|
||
<button key={c} onClick={() => toggle(certs, setCerts, c)}
|
||
className={`px-3 py-1.5 rounded-lg text-sm border ${certs.includes(c) ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-gray-700 border-gray-300'}`}>{c}</button>
|
||
))}
|
||
</div>
|
||
</Section>
|
||
</div>
|
||
|
||
<Section title="Scanner-Signale (Silent Pass)" hint="observation = gesehen · partial = Indikation · requirement = gefordert (≠ vorhanden)">
|
||
<div className="grid sm:grid-cols-2 gap-2">
|
||
{FINDINGS.map(f => (
|
||
<label key={f.signal_id} className="flex items-center gap-2 text-sm text-gray-700">
|
||
<input type="checkbox" checked={findings.includes(f.signal_id)} onChange={() => toggle(findings, setFindings, f.signal_id)} />
|
||
<span>{f.label}</span>
|
||
<span className={`ml-auto text-[10px] px-1.5 py-0.5 rounded ${f.kind === 'requirement' ? 'bg-purple-100 text-purple-700' : f.kind === 'partial' ? 'bg-amber-100 text-amber-700' : 'bg-emerald-100 text-emerald-700'}`}>{f.kind}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</Section>
|
||
|
||
<button onClick={run} disabled={loading || !certs.length}
|
||
className="mt-6 w-full py-3 bg-blue-600 text-white rounded-xl font-medium hover:bg-blue-700 disabled:opacity-50">
|
||
{loading ? 'Analysiere…' : 'Advisor starten'}
|
||
</button>
|
||
|
||
{error && <div className="mt-6 bg-red-50 border border-red-200 rounded-lg p-4 text-red-700 text-sm whitespace-pre-wrap">{error}</div>}
|
||
|
||
{result && (
|
||
<div className="mt-8 space-y-4">
|
||
<div className="bg-blue-600 text-white rounded-xl p-5">
|
||
<div className="text-lg font-semibold">{result.headline}</div>
|
||
<div className="text-blue-100 text-sm mt-1">{result.silent_intake_summary}</div>
|
||
</div>
|
||
<div className="grid md:grid-cols-2 gap-4">
|
||
<Section title="Automatisch erkannt" hint="konkrete Artefakte – nicht mehr gefragt"><Chips items={result.auto_detected} tone="bg-emerald-100 text-emerald-800" /></Section>
|
||
<Section title="Indikationen" hint="erhöht Annahmestärke – trotzdem gefragt"><Chips items={result.indications} tone="bg-amber-100 text-amber-800" /></Section>
|
||
</div>
|
||
<Section title="Nächste beste Fragen" hint="max 5, jede erklärt sich selbst">
|
||
{result.top_5_questions.length ? (
|
||
<ol className="space-y-3">
|
||
{result.top_5_questions.map((q, i) => (
|
||
<li key={q.capability_id} className="border-l-2 border-blue-300 pl-3">
|
||
<div className="font-medium text-gray-900">{i + 1}. {q.capability_id} <span className="text-xs text-gray-500">({q.question_intent})</span></div>
|
||
<div className="text-sm text-gray-600">{q.why}</div>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
) : <span className="text-gray-400 text-sm">—</span>}
|
||
</Section>
|
||
<div className="grid md:grid-cols-2 gap-4">
|
||
<Section title="Wahrscheinlich abgedeckt (Welt-1)" hint="Zertifikat legt nahe – Verifikation erforderlich">
|
||
{result.inferred_assumptions.length ? result.inferred_assumptions.map(a => (
|
||
<div key={a.certification} className="mb-2"><span className="font-medium">{a.certification}</span>: {a.capabilities.join(', ')}</div>
|
||
)) : <span className="text-gray-400 text-sm">—</span>}
|
||
</Section>
|
||
<Section title="Nicht relevant" hint="relevance(evidence, target) = 0">
|
||
{result.rejected_assumptions.length ? result.rejected_assumptions.map((a, i) => (
|
||
<div key={i} className="mb-1 text-sm text-gray-700">{a.statement}</div>
|
||
)) : <span className="text-gray-400 text-sm">—</span>}
|
||
</Section>
|
||
</div>
|
||
<div className="grid md:grid-cols-2 gap-4">
|
||
<Section title="Offene Lücken (Delta)"><Chips items={result.capability_delta} tone="bg-gray-100 text-gray-700" /></Section>
|
||
<Section title="Geforderte Nachweise"><Chips items={result.evidence_requests} tone="bg-gray-100 text-gray-700" /></Section>
|
||
</div>
|
||
<Section title="Vollständigkeit" hint={result.unsupported_domains.length ? `nicht abgedeckt: ${result.unsupported_domains.join(', ')}` : undefined}>
|
||
<span className="text-sm text-gray-700">{result.completeness_summary || '—'}</span>
|
||
</Section>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|