Files
breakpilot-compliance/admin-compliance/app/sdk/cra/[projectId]/path/page.tsx
T
Benjamin Admin 731076835d fix(cra): Konformitätspfad-Kacheln korrekt benennen + Gating nach CRA Art. 32
(a) Labels: Module korrekt zugeordnet — Modul A = Selbstbewertung, Modul B+C =
    benannte Stelle, EUCC = eigenes Zertifikat (nicht Modul H), "harmonisierte
    Norm" ist kein Modul sondern Konformitätsvermutung. Für den CRA noch KEINE
    harmonisierte Norm veröffentlicht → Kachel als "noch nicht verfügbar"
    (erwartet ~2027), nicht wählbar, mit Hinweis. (page/path/documents-Labels.)
(b) Gating: wichtige Klasse II + kritische Produkte dürfen NICHT selbst bewerten;
    harmonisierte Norm allein genügt dort nicht → ALLOWED_PATHS IMPORTANT_II/
    CRITICAL = {eucc, notified_body}; DEFAULT_FOR II = notified_body. _PATH_HINT
    entsprechend. Regressionstest test_cra_conformity_paths.py.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 13:49:00 +02:00

267 lines
10 KiB
TypeScript

'use client'
import React, { useEffect, useState, useCallback, use } from 'react'
import { useRouter } from 'next/navigation'
import { ClassificationBadge } from '../../_components/ClassificationBadge'
interface CRAProject {
id: string
name: string
cra_classification: string | null
conformity_path: string | null
status: string
}
type PathId = 'self_assessment' | 'harmonized_standard' | 'eucc' | 'notified_body'
interface PathOption {
id: PathId
modul: string
title: string
short: string
details: string[]
available?: boolean // false = rechtlich vorgesehen, aber noch nicht nutzbar
note?: string
}
const PATHS: PathOption[] = [
{
id: 'self_assessment',
modul: 'Modul A',
title: 'Selbstbewertung (interne Kontrolle)',
short: 'Hersteller erklaert die Konformitaet selbst',
details: [
'Hersteller fuehrt die Konformitaetsbewertung selbst durch',
'Geringster externer Aufwand, schnelle Umsetzung',
'Nur fuer Standard- und (mit Norm) wichtige Klasse-I-Produkte',
'Technische Dokumentation + Konformitaetserklaerung bleiben Pflicht',
],
},
{
id: 'harmonized_standard',
modul: 'Konformitaetsvermutung',
title: 'Harmonisierte Normen',
short: 'Vermutungswirkung durch eine harmonisierte EU-Norm',
available: false,
note: 'Fuer den CRA noch keine harmonisierte Norm veroeffentlicht — Entwuerfe erwartet ~Ende 2026, Listung im Amtsblatt voraussichtlich 2027.',
details: [
'Kein eigenes Modul, sondern Grundlage der Konformitaetsvermutung',
'Wer danach baut, gilt als CRA-konform und darf (Standard/Klasse I) selbst bewerten',
'Bis dahin ggf. ueber gemeinsame Spezifikationen der Kommission (Art. 27)',
],
},
{
id: 'eucc',
modul: 'EUCC',
title: 'EU-Cybersicherheitszertifikat',
short: 'Zertifizierung nach EUCC-Schema (Common-Criteria-basiert)',
details: [
'EUCC-Zertifikat (in der Regel Stufe „substanziell")',
'Eigener Weg unter dem Cybersecurity Act, keine benannte Stelle noetig',
'Hohe Anerkennung in EU + Drittstaaten, ITSEF-Pruefung',
'Regulaerer Weg fuer wichtige Klasse II und kritische Produkte',
],
},
{
id: 'notified_body',
modul: 'Modul B+C',
title: 'Benannte Stelle (Baumusterpruefung)',
short: 'Dritte Stelle prueft (EU-Baumusterpruefung + Produktionskontrolle)',
details: [
'EU-Baumusterpruefung (Modul B) durch akkreditierte benannte Stelle',
'gefolgt von Produktionskontrolle (Modul C); alternativ volle QS (Modul H)',
'Pflichtweg fuer wichtige Klasse II und kritische Produkte (Annex IV)',
'Hoechste Auditierbarkeit, hoechste Laufzeit + Kosten',
],
},
]
const ALLOWED: Record<string, PathId[]> = {
STANDARD: ['self_assessment', 'harmonized_standard', 'eucc', 'notified_body'],
IMPORTANT_I: ['self_assessment', 'harmonized_standard', 'eucc', 'notified_body'],
// Klasse II darf nicht selbst bewerten; harmonisierte Norm allein genuegt nicht.
IMPORTANT_II: ['eucc', 'notified_body'],
CRITICAL: ['eucc', 'notified_body'],
}
const DEFAULT_FOR: Record<string, PathId> = {
STANDARD: 'self_assessment',
IMPORTANT_I: 'self_assessment',
IMPORTANT_II: 'notified_body',
CRITICAL: 'notified_body',
}
export default function PathSelectPage({
params,
}: {
params: Promise<{ projectId: string }>
}) {
const { projectId } = use(params)
const router = useRouter()
const [project, setProject] = useState<CRAProject | null>(null)
const [selected, setSelected] = useState<PathId | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const tenant = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
const load = useCallback(async () => {
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}`, {
headers: { 'X-Tenant-ID': tenant },
})
if (!res.ok) throw new Error(await res.text())
const p: CRAProject = await res.json()
setProject(p)
if (p.conformity_path) {
setSelected(p.conformity_path as PathId)
} else if (p.cra_classification && DEFAULT_FOR[p.cra_classification]) {
setSelected(DEFAULT_FOR[p.cra_classification])
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [projectId])
useEffect(() => { load() }, [load])
const submit = async () => {
if (!selected) return
setSaving(true)
setError('')
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/path-select`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenant },
body: JSON.stringify({ conformity_path: selected }),
})
if (!res.ok) throw new Error(await res.text())
router.push(`/sdk/cra/${projectId}`)
} catch (e) {
setError(e instanceof Error ? e.message : 'Speichern fehlgeschlagen')
} finally {
setSaving(false)
}
}
if (loading) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-gray-500">Laedt...</p></div>
if (!project) return null
if (!project.cra_classification) {
return (
<div className="min-h-screen bg-gray-50 p-8">
<div className="max-w-2xl mx-auto bg-yellow-50 border border-yellow-200 rounded-lg p-6">
<p className="text-yellow-800">
Bitte erst den Scope-Check ausfuehren.
<a href={`/sdk/cra/${projectId}/scope`} className="ml-2 underline"> Zum Scope-Check</a>
</p>
</div>
</div>
)
}
const allowedPaths = ALLOWED[project.cra_classification] || []
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl 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">Konformitaetspfad waehlen</h1>
<p className="text-sm text-gray-600 mt-1">
Schritt 3 von 3 basierend auf der Klassifikation siehst du die zulaessigen Pfade.
</p>
<div className="mt-3 flex items-center gap-2">
<span className="text-sm text-gray-600">Klassifikation:</span>
<ClassificationBadge value={project.cra_classification} size="md" />
</div>
</div>
{error && (
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
{PATHS.map(path => {
const allowed = allowedPaths.includes(path.id)
const available = path.available !== false
const selectable = allowed && available
const isSelected = selected === path.id
return (
<button
key={path.id}
onClick={() => selectable && setSelected(path.id)}
disabled={!selectable}
className={`text-left p-5 rounded-xl border-2 transition-all ${
isSelected ? 'border-red-500 bg-red-50' :
selectable ? 'border-gray-200 bg-white hover:border-red-300 hover:shadow-md' :
'border-gray-200 bg-gray-50 opacity-60 cursor-not-allowed'
}`}
>
<div className="flex items-start justify-between mb-2 gap-2">
<div>
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">{path.modul}</span>
<h3 className="text-lg font-semibold text-gray-900">{path.title}</h3>
</div>
{isSelected ? (
<span className="px-2 py-0.5 text-xs bg-red-600 text-white rounded whitespace-nowrap">Gewaehlt</span>
) : !available ? (
<span className="px-2 py-0.5 text-xs bg-amber-100 text-amber-700 rounded whitespace-nowrap">Noch nicht verfügbar</span>
) : !allowed ? (
<span className="px-2 py-0.5 text-xs bg-gray-200 text-gray-600 rounded whitespace-nowrap">Nicht zulaessig</span>
) : null}
</div>
<p className="text-sm text-gray-600 mb-2">{path.short}</p>
{path.note && (
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1.5 mb-2">{path.note}</p>
)}
<ul className="text-xs text-gray-600 space-y-1">
{path.details.map((d, i) => (
<li key={i} className="flex items-start gap-1.5">
<span className="text-gray-400 mt-0.5"></span>
<span>{d}</span>
</li>
))}
</ul>
</button>
)
})}
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4 flex items-center justify-between">
<div className="text-sm text-gray-600">
{selected ? (
<>Ausgewaehlt: <span className="font-medium text-gray-900">
{PATHS.find(p => p.id === selected)?.title}
</span></>
) : (
'Keine Auswahl getroffen'
)}
</div>
<div className="flex gap-3">
<button
onClick={() => router.push(`/sdk/cra/${projectId}/scope`)}
disabled={saving}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Zurueck
</button>
<button
onClick={submit}
disabled={saving || !selected}
className="px-6 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 disabled:bg-gray-300"
>
{saving ? 'Speichert...' : 'Pfad festlegen'}
</button>
</div>
</div>
</div>
</div>
)
}