Compare commits

..

66 Commits

Author SHA1 Message Date
Benjamin Admin e536247c20 feat(quaidal): backend API + frontend tab for BSI QUAIDAL data-quality controls
Wire the 195 Clean-Room QUAIDAL controls (from breakpilot-core migration 011)
into the compliance SaaS UI.

Backend:
- GET /api/v1/quaidal/stats           - counts by kind + source provenance
- GET /api/v1/quaidal/controls        - list, optional kind= filter
- GET /api/v1/quaidal/controls/{id}   - single derived control
- GET /api/v1/quaidal/criteria        - 10 QKB criteria
- GET /api/v1/quaidal/criteria/{id}   - QKB with QB/MA/QM tree

Frontend:
- /sdk/quality: new "Trainingsdaten-Qualität (BSI QUAIDAL)" tab with
  10 QKB cards and a drill-down modal showing the full QB→MA→QM tree
  plus original BSI source link and license note.
- /sdk/ai-act: Art. 10 tile on each high-risk/unacceptable result,
  linking to /sdk/quality?category=data_quality.

Pattern matches existing IACE module DIN-reference handling:
own wording, source section + URL preserved for due diligence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:03:54 +02:00
Benjamin Admin 313982c6f1 feat(profile+report): P17 — 4 Polish-Items
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (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 16s
CI / loc-budget (push) Successful in 19s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
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
A) Cookie-Policy-Architecture-Block Fallback auf DSE-Text wenn cookie via
   P15 deduped wurde. Erkennt jetzt auch single-doc Sites (Safetykon-Pattern).

B) Konkrete-Aufgaben-Liste: Per-Doc-Cap (3) entfernt + globaler Cap 10→20.
   Safetykon zeigt jetzt 7 statt 4 Aufgaben.

C) business_type-Klassifizierer: B2B-Service-Cluster aus P14 als Boost.
   Bei 2+ Service-Indikatoren (CE-Zertifizierung/Compliance/Auditierung)
   wird b2b_score angehoben. Safetykon: "B2C consulting" → "B2B (consulting)".

D) Vendor-Extract Fallback auf DSE-Text wenn cookie deduped + keine CMP-
   Payloads. LLM extrahiert dann Vendors aus dem DSE-Text. Safetykon: 0 → 1
   Vendor (Google Analytics aus dem DSE-Text erkannt).

Smoke-Test Safetykon: alle 4 Polish-Items wirken, kein Regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:22:05 +02:00
Benjamin Admin f30a3ce471 Merge branch 'main' of ssh://gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-compliance
CI / nodejs-build (push) Successful in 3m23s
CI / test-go (push) Successful in 1m1s
CI / detect-changes (push) Successful in 9s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (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 18s
CI / loc-budget (push) Successful in 19s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / iace-gt-coverage (push) Successful in 28s
CI / test-python-backend (push) Successful in 45s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-05-19 11:47:45 +02:00
Benjamin Admin 479ce2225b feat(profile): P14+P15+P16 — B2B-Heuristik + Doc-URL-Dedup + Homepage-Profile
P14 — _detect_no_direct_sales erweitert um 3 Cluster:
  A) OEM-Konfigurator (BMW/Audi/Mercedes/VW/Porsche-Markennamen + Vertragshaendler-Pattern)
  B) B2B-Dienstleister (CE-Zertifizierung, Compliance-Beratung, Schulungen, Auditierung, TISAX, ISO-Normen, Arbeitssicherheit, ...)
  C) NGO/Verein/Public (Spendenkonto, Vereinsregister, gemeinnuetzig, ...)
Schwelle: pos >= 2 pro Cluster UND pos > neg. Bisher: nur OEM.

P15 — Doc-URL-Dedup im Worker: wenn mehrere Doc-Types DASSELBE Dokument
referenzieren (Safetykon-Pattern: User gibt /datenschutz fuer dse, cookie
UND widerruf), wird nur dem primaeren Doc-Type (Priority: dse > impressum
> cookie > widerruf > agb > nutzungsbedingungen) der Text gegeben. Andere
landen als "Nicht separat vorhanden — wird im Dokument 'X' mit-geprueft."
Eliminiert die 8+8 systematischen widerruf/cookie False Positives.

P16 — Profile-Detection auch Homepage-Text: Homepage-HTML wird mit kurzem
Fetch (8s timeout) gezogen, getrippt und zum profile_input gemerged. Vor-
her wirkte P14 nur wenn B2B-Indikatoren im DSE/Impressum-Pflichttext
standen — bei Safetykon stehen sie nur im Homepage-Menue.

Plus Bonus: TDM-Override-Submit-Button wird deaktiviert wenn Reason < 10
Zeichen — verhindert dass User wie heute in den Bug rein klickt.

Smoke-Test Safetykon (B2B Compliance-Dienstleister):
  dse                  geprueft (kein err)
  impressum            geprueft (kein err)
  cookie               "Nicht separat vorhanden — wird in DSE mit-geprueft"
  agb                  "Nicht anwendbar — kein Direkt-Kaufvertrag"
  widerruf             "Nicht anwendbar — kein Direkt-Kaufvertrag"
  nutzungsbedingungen  "Nicht anwendbar — kein Direkt-Kaufvertrag"
Vorher: 16 False Positives. Jetzt: 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:46:58 +02:00
Benjamin Admin a1b380e211 fix(iace): getProject scan missed &p.CustomerName — single-project GET 500ed
Migration 031 added customer_name to the SELECT statement in three places
(GetProject, ListProjects, ListVariants), and the per-row Scan needed the
matching destination. The replace_all caught ListProjects + ListVariants
but missed GetProject because of an indentation difference (single tab
vs row-scope indentation). Result: GET /projects/:id returned
  "get project: number of field descriptions must equal number of
   destinations, got 18 and 17"
which the frontend interpreted as "project has no data" and surfaced an
empty UI even though hazards/mitigations/components were intact (118/282/16
on Bremsscheibe).

Single-line fix: add &p.CustomerName to the GetProject scan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:46:34 +02:00
Sharang Parnerkar 077e0f1253 ci: force rebuild all services
CI / detect-changes (push) Successful in 9s
CI / branch-name (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / loc-budget (push) Successful in 15s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m48s
CI / guardrail-integrity (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 / test-python-document-crawler (push) Successful in 28s
CI / test-python-dsms-gateway (push) Successful in 22s
CI / test-go (push) Successful in 53s
CI / iace-gt-coverage (push) Successful in 27s
CI / test-python-backend (push) Successful in 38s
last-build/main tag deleted so detect-changes falls back to
rebuild-all. Exercises the trigger-orca fix end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:39:06 +02:00
Sharang Parnerkar 936c354547 fix(ci): trigger orca on per-job result, not needs.*.result spread
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / detect-changes (push) Successful in 9s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (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 14s
CI / loc-budget (push) Successful in 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Gitea act_runner evaluates contains(needs.*.result, 'success') to false
when most upstream build jobs are skipped, so single-service changes
never fired the orca redeploy.

Gate trigger-orca on explicit needs.build-<service>.result == 'success'
OR across all 8 build jobs. One green build now suffices to deploy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:34:59 +02:00
Benjamin Admin b87c27d104 fix(llm-verify): P13 — Default-Modell auf qwen3:30b-a3b (statt qwen3.5:35b-a3b)
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / loc-budget (push) Successful in 21s
CI / go-lint (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 18s
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 40s
Bug: qwen3.5:35b-a3b liefert mit format='json' + Batch-Prompt leere
Strings zurueck ('LLM batch: empty response from model'). Im echten
Compliance-Check lief der LLM-Verifier deshalb wirkungslos —
False-Positive-Findings wie 'Vorstand nicht erkannt' (BMW: Klammer-
Liste) wurden nicht overturned.

Fix: Default auf qwen3:30b-a3b umgestellt. Verifiziert mit BMW-
Impressum-Text: representative_person wird mit Evidence 'Milan
Nedeljkovic, Vorsitzender' overturned=True markiert.

OLLAMA_VERIFY_MODEL Env-Var bleibt als Override-Moeglichkeit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:11:01 +02:00
Benjamin Admin 78b27d4684 feat(compliance-check): P12 — TDM-Override mit dokumentierter Kunden-Erlaubnis
CI / guardrail-integrity (push) Has been skipped
CI / nodejs-build (push) Successful in 3m5s
CI / test-go (push) Has been skipped
CI / detect-changes (push) Successful in 10s
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 16s
CI / loc-budget (push) Successful in 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 40s
CI / test-python-document-crawler (push) Has been skipped
Backend: ComplianceCheckRequest um tdm_override + tdm_override_reason
erweitert. Worker im _run_compliance_check Pfad: bei
tdm_override=True UND Reason >= 10 Zeichen wird der TDM-Vorbehalt
nur dokumentiert (job.tdm_override.{reason, original_status}) und
NICHT als Abbruch-Grund gewertet. Ohne Reason: Override ignoriert.
Audit-Spur via logger.warning(reason).

Frontend: ComplianceCheckTab um Checkbox + Pflicht-Reason-Feld
("Schriftliche Crawl-Erlaubnis vorhanden") direkt vor dem Submit-
Button. Pflicht: Reason >= 10 Zeichen. Submit sendet die Flags ans
Backend.

Anwendungsfall: Safetykon-Pattern — robots.txt + ai.txt setzen
Vorbehalt, aber Kunde hat schriftlich zugestimmt (Auftrags-Audit).

[guardrail-change] ComplianceCheckTab.tsx (511 LOC) in loc-exceptions
ergaenzt — Split nach _components/TDMOverride + CompliancePolling
ist P11-Tech-Debt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:56:50 +02:00
Benjamin Admin a220f0d0a7 [guardrail-change] LOC-Exceptions: 4 grandfathered files fuer Coolify-Unblocker
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (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 16s
CI / loc-budget (push) Successful in 19s
CI / go-lint (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
Diese 4 Pre-Existing-Files haben den Coolify-Build geblockt (LOC-CI-Step
failed). Splits sind Phase-5+ Tech-Debt-Backlog, bis dahin als Exceptions
getragen damit Production-Deploys nicht ausfallen.

  - cra_routes.py (1714)
  - vendor_redundancy.py (727)
  - cookie_knowledge_db.py (608)
  - cookie-banner-embed.ts (558)

Jede Exception hat einen kurzen Rationale-Kommentar daruber.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 08:34:03 +02:00
Benjamin Admin 28a078ccb4 feat(compliance-check): P10 — Cookie-Policy-Architecture-Detection
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (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 16s
CI / loc-budget (push) Failing after 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 41s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Neuer Service cookie_policy_architecture.detect_architecture(...) prueft
vier Diagnose-Punkte der Cookie-Policy einer Website:

  1. Layer-Trennung: single (BMW-Pattern: Banner + Info in EINER URL)
                   | separate (Best Practice: getrennte Layer)
  2. Versionierung: "Stand vom DD.MM.JJJJ" / "Version X.Y" / ...
  3. Dynamic content: CMP-Capture auf Doc-URL oder Marker-Texte
  4. Vendor-Count im Text: Indikator ob Liste statisch drinsteht

Risiko-Ampel:
  - gruen: separate + versioned + statisch
  - gelb : single+unversioned (BMW) ODER separate+unversioned
  - rot  : weder noch (Pflicht-Info fehlt)

Wire-in im Compliance-Check-Worker: nach Exec-Summary-Block wird der
Architecture-Block gerendert (build_architecture_html) mit konkreter
Empfehlung. Bei BMW-Pattern: "Snapshot der dynamischen Vendor-Tabelle
als versioniertes PDF im Archiv."

Hintergrund: BMW hat eine HTML-Seite die GLEICHZEITIG Banner-Re-Trigger
und Cookie-Richtlinie ist. Mindestanforderung nach §25 TDDDG + Art. 13
DSGVO erfuellt, aber bei einer Aufsichtsbehoerden-Pruefung kann nicht
belegt werden welche Vendor-Liste an einem bestimmten Stichtag aktiv
war. Das ist kein Verstoss aber best-practice-Luecke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:01:48 +02:00
Benjamin Admin 0d37822b7c fix(impressum): P9 — 7 False-Positive-Fixes in Pflichtangaben-Checks
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (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 16s
CI / loc-budget (push) Failing after 16s
CI / go-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 37s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
#1 Name des Anbieters: \b Word-Boundary verhindert "ag" in "samstag",
   plus "aktiengesellschaft" als Volltreffer.
#2 Vertretungsberechtigte: Klammer-Liste-Pattern erkennt jetzt BMW-
   Format "Vorstand (Milan Nedeljkovic, Jochen Goller, ...)" plus
   "Vorsitzender des Aufsichtsrats: Name".
#3 V.i.S.d.P.: war schon INFO, OK.
#4 OS-Plattform/VSBG: bei no_direct_sales=True (OEM-Pattern) jetzt als
   "Nicht anwendbar" skipped statt 0/1 fail. Profile fliesst neu durch
   check_document_completeness -> runner.
#5 Zustaendige Kammer: IHK + Handwerkskammer + Tieraerztekammer in
   Pattern aufgenommen + severity LOW -> INFO (konditional).
#6 Stammkapital: war schon INFO, OK.
#7 Link-Disclaimer: neue Check-Eigenschaft "invert"=True. Anti-Pattern
   ist passed wenn NICHT gefunden, fail wenn gefunden. Vorher feuerte
   das Finding immer, jetzt nur wenn ein illegaler Disclaimer im Text
   ist.

Plus: L2-INFO-Checks (z.B. profession_chamber) zaehlen nicht mehr in
correctness-pct und erzeugen keine DSI-DETAIL-Findings. Konsistent
mit P8-Modell: INFO = "selbst pruefen", nicht "fail".

Verifiziert mit BMW-Impressum-Text — alle 7 Faelle korrekt klassifiziert:
  name=passed, representative_person=passed, profession_chamber=INFO,
  illegal_disclaimer=passed (kein Disclaimer im Text),
  dispute_resolution=skipped (no_direct_sales),
  editorial_visdp=INFO, share_capital=INFO.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:52:03 +02:00
Benjamin Admin 575644c9c5 feat(audit): P8 — MC-Severity raus, Email nur harte Findings, MC-Audit als Checkliste
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (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 17s
CI / loc-budget (push) Failing after 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m48s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 40s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Email-Hardening (mc_scorecard.top_fails):
  Neue _is_hard_finding-Heuristik filtert konditionale MCs ohne
  Negativ-Beleg aus den Top-Auffaelligkeiten. matched_text leer + Label
  enthaelt "falls/sofern/wenn/soweit/ggf." -> raus, landet nur noch im
  MC-Audit als "selbst pruefen". DATA-2066-A05 (kostenfreie Abschaltung
  Standortdaten) ist das prototypische Beispiel.

MC-Audit-Frontend (audit/[checkId]/page.tsx):
  Severity-Spalte (CRITICAL/HIGH/MEDIUM/LOW) entfernt — der MC-Audit
  ist eine Checkliste, keine Severity-Drohung. Stattdessen:
   - Spalte "Prioritaet" mit 3-Tier aus regulation-Mapping:
     Gesetz (DSGVO/ePrivacy/TDDDG/...) / Behoerden-Leitlinie
     (EDPB/DSK/EuGH/...) / Best-Practice (ISO/NIST/BSI)
   - 3-Status: erfuellt (✓) / nicht erfuellt (✗) / selbst pruefen (?)
     / nicht anwendbar (—). rowReviewStatus() leitet "selbst pruefen"
     aus matched_text-leer + konditionalem Label ab.
   - Filter umgebaut auf 5 Stati statt 4
   - Default-Filter "Nicht erfuellt" (vorher "Nur Fail")

Bonus: f.payload.risk_label TS-Cast im FindingsTab clean gemacht
(unknown -> string).

Effekt:
  - Email an die GF zeigt nur noch echte Belege ("DSB fehlt",
    "Gebuehr fuer Widerruf")
  - MC-Audit ist eine sachliche Pruefliste fuer den Compliance-Officer

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:30:04 +02:00
Benjamin Admin 6c223c7c9b feat(compliance-check): exec-summary + voll-audit + TDM-respect + cookie-KB-extended + saving-scan-funnel
CI / detect-changes (push) Successful in 10s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (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 14s
CI / loc-budget (push) Failing after 15s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m43s
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-backend (push) Successful in 37s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
P1 — Exec-Summary oben im Email-Report (4 KPIs + 2 CTAs, dunkler Gradient)
P3 — no_direct_sales-Flag fuer OEM-Konfigurator-Sites; AGB/Widerruf/AGB als
     "NICHT ANWENDBAR" (grau) statt "NICHT GEFUNDEN" (rot)
P5 — Voll-Audit Unification: alle Findings (MC + Pflichtangaben + Vendor +
     Redundanz) in /data/compliance_audits.db.unified_findings; neuer
     /api/compliance/agent/findings/<id> Endpoint + FindingsTab im Audit-UI
     mit Filter + CSV-Export
P7 — Crawl-Hardening: TDM-Reservation-Check (robots.txt / ai.txt / Header /
     Meta) vor jedem Run mit 24h-Cache; HeadlessChrome-UA (Firma noch nicht
     gegruendet — Switch via BREAKPILOT_BRANDED_UA env); per-Domain
     Rate-Limit 1 req/s + max 2 concurrent
P2 — Cookie-Knowledge-DB additiv erweitert (35 -> 74 Cookies): Adobe, Meta,
     Microsoft, LinkedIn, TikTok, HubSpot, Marketo, Salesforce, Hotjar,
     FullStory, Mouseflow, Intercom, Drift, Zendesk, Cloudflare, Stripe,
     OneTrust/Cookiebot/Usercentrics, Matomo, Pinterest, Snapchat, X/Twitter,
     YouTube, Vimeo, Klaviyo, Mailchimp, Mixpanel, Segment, Amplitude,
     Optimizely, Datadog; Wire-in in cookie_function_classifier liefert
     compliance_risk-Label (kritisch/hoch/mittel/gering) pro Vendor
A  — k-Anonymitaets-Helper (benchmark_k_anonymity) fuer P6-Vorbereitung
B  — Cross-Tenant-Domain-Assertion im /findings-Endpoint (expected_domain
     Query-Param -> 403 bei Mismatch)
C  — Saving-Scan-Funnel: /api/compliance/agent/saving-scan/start mit
     Validierung + 24h-Rate-Limit pro Domain + Lead-Persistenz in
     saving_scan_leads + Auto-Discovery via _run_compliance_check; 6 Tests
D  — Risk-Badge im Email-Vendor-Row

Rechtliche Leitplanken (Memory feedback_oem_data_legal.md): nur eigene
Knapp-Bewertungen + Source-Pointer, keine 1:1-Kopien fremder CMP-Texte.
TDM-Opt-Out-Respect nach § 44b UrhG. KEINE Schema-Aenderungen — alles in
Sidecar-SQLite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:48:34 +02:00
Benjamin Admin a616b64273 feat(iace): Customer-Standard-Reuse across customer's prior projects
CI / detect-changes (push) Successful in 10s
CI / guardrail-integrity (push) Has been skipped
CI / branch-name (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / loc-budget (push) Failing after 19s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / test-go (push) Successful in 47s
CI / nodejs-build (push) Successful in 2m46s
CI / iace-gt-coverage (push) Successful in 28s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
[migration-approved]

Task #22. The IACE module is used by a single Maschinenhersteller, but
their plants land at many different end customers. When the safety expert
commissions the second or third plant at the same customer, whole classes
of mitigations (company-wide PPE rules, locked-out energy isolation,
customer-standard signage) are already in place there — but rediscovered
from scratch every project.

Migration 031: iace_projects.customer_name TEXT + partial index.
  The customer is stored as a plain text field rather than a normalised
  iace_customers table (option A from the design discussion). A proper
  customer-management screen can promote this to a FK later without
  data loss.

Backend store_customer_standards.go:
  - ListCustomerStandardSuggestions(projectID, includeVerified) collects
    mitigations from all non-archived prior projects sharing the same
    tenant_id AND case-insensitive customer_name. Aggregates by
    mitigation.name (since same-named measures from different prior
    projects collapse into one suggestion) and surfaces:
      • source_project_count + source_project_names
      • is_customer_standard / has_verified_instances flags
    includeVerified=false → strictly is_customer_standard=true
    includeVerified=true  → also status='verified'
  - ImportCustomerStandardSuggestion(projectID, name): for every prior
    (mitigation.name → hazard.name) pairing, finds matching hazards in
    the current project (by name) and ensures a customer-standard
    mitigation exists. New rows via CreateMitigation (idempotent through
    the UNIQUE(hazard_id, name) from migration 030); existing rows are
    flipped to is_relevant=true + is_customer_standard=true +
    status='verified' via UPDATE.

Routes:
  GET  /api/v1/iace/projects/:id/customer-standards?include_verified=
  POST /api/v1/iace/projects/:id/customer-standards/import   body {name}

Frontend:
  - New page /sdk/iace/[projectId]/customer-standards with:
      • empty-state hint pointing to Auftrag → Kundenname
      • per-suggestion checkbox + per-row Übernehmen button
      • bulk "N übernehmen" button
      • toggle "Auch verifizierte einbeziehen" widening the pool
      • per-suggestion source_project_count + status badges
  - Sidebar item "Kundenstandards" (building icon) placed between
    Verifikation and Nachweise.
  - Order-page now mirrors Auftraggeber.Firmenname into the top-level
    customer_name column on save, so the Reuse feature is fed
    automatically without a separate input field.

The same expert effect from migration 029's is_customer_standard flag —
"I already know it's covered, no evidence needed" — now becomes a
cross-project asset rather than a per-project annotation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:31:30 +02:00
Benjamin Admin 27384aea09 feat(cra): Phase 5 — Technical Doc + DoC Generator (Annex V + VII)
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>
2026-05-18 22:10:23 +02:00
Benjamin Admin cc80e59e5e feat(cra): Phase 4 — Vulnerability Disclosure + Post-Market Monitoring
Migration 121: compliance_cra_vulnerabilities table with full lifecycle tracking
- Status state machine: reported → triaged → patched → disclosed (+ withdrawn)
- CRA Art. 14(2) deadlines tracked: reported_to_enisa_at (24h), detailed_report_at (72h)
- CVE-ID, severity, CVSS, affected_components (JSONB), embargo_until

Backend endpoints in cra_routes.py:
- POST /vulnerabilities — create with validation (severity, CVSS range)
- GET /vulnerabilities — list with deadline-breach summary (24h/72h counters)
- PATCH /vulnerabilities/{id} — update fields + auto-set lifecycle timestamps
- DELETE /vulnerabilities/{id} — soft-delete (withdrawn)
- GET /monitoring — combined view: CRA deadlines + vuln summary + post-market checklist

Frontend:
- /vuln page: intake form, vuln cards with 24h/72h-countdown buttons,
  status-transition flow with auto-timestamps
- /monitoring page: CRA deadlines (11.06.26 / 11.09.26 / 11.12.27), breach banner
  if 24h/72h obligations missed, post-market checklist with deep-links
- Dashboard: +2 buttons (Vulns, Monitoring)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 22:08:49 +02:00
Benjamin Admin 0a64da74bb fix(iace/mitigations): idempotent CreateMitigation + UNIQUE(hazard_id, name)
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Successful in 56s
CI / iace-gt-coverage (push) Successful in 27s
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / detect-changes (push) Successful in 11s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (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 17s
CI / loc-budget (push) Failing after 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
[migration-approved]

The init-handler was non-idempotent. A second click on "Neu initialisieren
in Grenzen" inserted every engine-suggested mitigation a second time —
e.g. the Bremsscheibe project ended up with 5 (hazard_id, name) duplicate
pairs (HMI-Usability-Pruefung, Eindeutiges visuelles Feedback,
Betriebsarten-Anzeige, Sicher begrenzter Bewegungsbereich, …). 45 such
duplicates accumulated across all projects.

Migration 030_iace_mitigation_unique.sql:
  1. Picks one winning row per (hazard_id, name) using a stable rank:
       is_relevant DESC      (expert decision wins over engine default)
       status      DESC      (verified > implemented > planned)
       created_at  DESC      (newest beats older on otherwise-equal rows)
     and deletes the losers (Bremsscheibe: 5 rows; total: 45).
  2. Adds UNIQUE constraint iace_mitigations_hazard_name_uniq
     (hazard_id, name).

Store-Layer (CreateMitigation):
  INSERT … ON CONFLICT (hazard_id, name) DO NOTHING RETURNING id.
  pgx.ErrNoRows from RETURNING → look up the existing row and return that.
  Callers (engine init + manual add) always get a usable Mitigation; the
  second click is silently swallowed instead of failing.

Frontend dedupe in groupByTitle stays — it covers any pre-existing
duplicates that survived the migration in edge cases (multi-row write
in flight, etc.). With the UNIQUE constraint live, the in-memory
dedupe is a belt-and-suspenders safety net rather than the load-bearing
mechanism.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:55:13 +02:00
Benjamin Admin 662327e8b4 feat(compliance-check): MC-Classification + Embedding + Vendor-Redundanz + Action-Recipes + Borlabs-Features
CI / nodejs-build (push) Successful in 2m47s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / detect-changes (push) Successful in 10s
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 16s
CI / loc-budget (push) Failing after 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-python-backend (push) Successful in 42s
CI / test-python-document-crawler (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Massiv-Update auf Basis BMW-Test-Iterationen (v1→v9):

Core Compliance-Check
- Sonnet check_type Klassifikation: text/process/review fuer alle 1874 MCs
  in compliance.doc_check_controls (script + Sidecar /data/mc_classification.db).
  rag_document_checker filtert auf check_type='text' fuer doc_check.
  Plus fits_doc_type-Audit (v2) + ui_only-Audit fuer DSA/E-Commerce-MCs in
  falscher doc_type-Schublade.
- scope_requires-Filter: biometric/ai_decision/child_targeting MCs werden
  per business_profile gefiltert (FRT skipped fuer BMW etc.).
- Embedding-Match (BGE-M3) als Phase-3 nach Regex-Match:
  Per-doc_type-Threshold-Override (impressum 0.50, dse/cookie 0.60),
  Short-Field-Rescue (15-Wort-Chunks) fuer Pflichtfelder im Impressum.
  Title+check_question als Embedding-Input fuer mehr Kontext.
- Cookie-Text-Routing: consent-tester gibt cmp_cookie_text aus dem
  CMP-Reconstruct zurueck, Backend bevorzugt das gegen DOM-Extraction
  wenn richer (BMW 1824 vs 600 Worte).

Vendor-Redundanz + EU-Alternativen + Cost-Saving
- vendor_redundancy.analyze() — funktionale Kategorisierung der CMP-Vendors,
  Detektion von Mehrfach-Anbietern pro Kategorie, EU-Alternative-Lookup
  (Matomo, IONOS, HERE, Friendly Captcha, Smart AdServer, ...).
- vendor_cost_estimator: Tier-Inferenz aus Cookie-Footprint (Cookie-Anzahl
  + Premium-Feature-Cookies + Third-Party-Quote → starter/professional/
  enterprise/premier).
- Self-Service-Werbung (Google/Meta/Pinterest/...) = 0 Lizenz-Kosten
  (nur Media-Spend, separat). DSP-Plattformen behalten enge Range.
- Tier-aware Saving-Range: bei Enterprise/Premier nutzen wir den
  oberen 40-100%-Band der Listpreise, nicht starter→premier.
- Multi-Function-Tools (Matomo Pro, SAP CX, IONOS Cloud, Userlike, Smart
  AdServer, HERE Maps, Vimeo Pro, LamaPoll) — ein Tool ersetzt mehrere
  Kategorien gleichzeitig.

Cookie-Wissens-DB + Funktionale Klassifikation
- cookie_knowledge_db: 50 kuratierte Top-Cookies (Google/Meta/Adobe/MS/...)
  mit vendor, exact_purpose, data_collected, IAB-TCF-IDs, reid_risk,
  schrems_ii_status, EuGH-Urteile, EU-Alternative.
- cookie_function_classifier: pro Cookie funktionale Rolle (tracking_id,
  ad_pixel, session_id, ab_test, csrf, ...) + blocking_impact.

Country-Inferenz aus Rechtsform
- cookie_link_validator: Country-Field wird aus Vendor-Name abgeleitet
  (A/S=DK, GmbH=DE, Inc=US, B.V.=NL, ...) plus Vendor-Lookup-Table.
  Reduziert false-positive no_country-Flags bei eindeutig-EU-Vendors
  (Adform DK, Pinterest IE).

Action-Recipes + Doc-Anchor-Locator
- finding_action_recipes: pro Finding-Typ (no_cookies_listed, no_country,
  broken_opt_out, "Auftragsverarbeiter erwaehnen", "Art. 22 Profiling",
  ...) eine strukturierte Anweisung mit what/why/fix_text/where/example.
  Zum 1:1-Einfuegen in Kunden-Dokumente.
- doc_anchor_locator: Embedding-basiert (BGE-M3 cosine) — sucht den
  passenden Absatz im existierenden Kundendokument fuer jeden Finding.
  Per-Run Thread-Local-Cache. Fallback: keyword-Match.
- Email-Rendering integriert Recipe + Anchor pro Doc-Pruefungs-Fail
  + Vendor-Flag-Liste mit aufklappbarer Action-Liste.
- Score-Erklaerung pro Vendor-Zeile (3/5-Untertitel + Tooltip).

Migration-Pipeline (Compliance-Check -> Customer Banner/Documents)
- migration_to_banner.py: Vendor-Liste -> CookieBannerConfig mit
  4 Kategorien + Review-Flags.
- migration_to_document.py: Vendor-Liste -> Cookie-Policy + VVT-Register
  + Privacy-Policy-Pre-Fills.
- agent_migration_routes: 3 Preview-Endpoints (banner-preview,
  document-preview, summary). Persistierung der cmp_vendors in
  /data/compliance_audits.db check_payloads-Tabelle.

Borlabs-Parity Cookie-Banner-Features
- Consent-Historie im Banner: window.bpShowConsentHistory() + localStorage.
- Content-Blocker: cookie-banner-content-blocker.ts — YouTube/Maps/Video
  Placeholder bis Einwilligung.
- Google Consent Mode v2 erweitert: wait_for_update + region=EEA/CH/GB.
- Consent-Log Export (CSV/JSON) per einwilligungen_export_routes.

Bug-Fixes
- canonical_control_routes: _jsonish-Helper fuer string-typed jsonb,
  similar-controls-Endpoint mit _has_embedding_col()-Cache (kein 500 mehr).
- Control-Library Frontend: defensive .map-Coercer in 2 Detail-Views.
- Embedding-Service-Batching (32er Batches statt 165 in einem Call).
- KeyError 'control_id' in MC-Result-Aggregation (defensive .get).
- Master-Controls-Klick-Through von /sdk/master-controls auf
  /sdk/control-library?control=<id> mit URL-Param-Auto-Open.
- Dockerfile: /data pre-chowned auf appuser (Audit-DB-Schreibrecht).
- Cookie-Text-Routing-Bug (cmp_reconstructed > DOM-extraction).
- doc_type-aware MC-Filter (statt all-text-MCs).
- Master-Contract-Dedup (60 BMW-Internal-Eintraege = 1 Adobe-Vertrag).
- A3-v2-Audit hat 24 UI-Sprache-MCs als 'process' reklassifiziert.

Tests
- test_migration_mappers.py (9 Tests)
- test_migration_endpoints.py (4 Tests)

Skripte (one-shot)
- classify_mc_check_type.py (v1) + _v2 (PK=control_id,doc_type)
- audit_mc_doctype_fit.py (v1 fits) + _v2 (ui_only + scope_requires)

BMW-Run-Bilanz v1 (broken) -> v9 (alle Fixes):
  DSE     7,5% -> 81-83%
  Impressum 4%   -> 100% (6 echte MCs alle erfuellt)
  Cookie  0%    -> 79-83% (CMP-Text-Routing + Embedding)
  Plus: 10 Konsolidierungs-Kategorien, geschaetzte Saving 200k-3M / Jahr
  Plus: Action-Recipes + Doc-Anchors fuer jeden Fail

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:30:08 +02:00
Benjamin Admin 52fb8b91e7 Merge branch 'main' of ssh://gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-compliance
CI / detect-changes (push) Successful in 11s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (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 16s
CI / loc-budget (push) Failing after 15s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Successful in 2m56s
CI / test-go (push) Successful in 58s
CI / iace-gt-coverage (push) Successful in 31s
CI / test-python-backend (push) Successful in 44s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
2026-05-18 18:09:39 +02:00
Benjamin Admin 1cf5de1d45 feat(cra): CRA Compliance module Phase 1+2+3 (intake, scope, path, requirements, backlog, sbom, checks)
Phase 1 — Intake + Scope + Path:
- Migration 119: compliance_cra_projects table (intake + classification + path + status state machine)
- Backend service cra_routes.py: CRUD + scope-check + path-select
- Deterministic Annex III/IV classifier (verbatim mapping from migration 059 wiki)
- Path validation per classification (CRITICAL → notified_body mandatory)
- Frontend: project list, dashboard, 3-step wizard (intake/scope/path)
- Sidebar entry under "CRA Compliance" (red)

Phase 2 — Annex I Requirements + Priorisierungs-Backlog:
- cra_annex_i_data.py: 40 Annex-I requirements (8 categories), 9 measures (M540-M548), 3 CRA deadlines
- Endpoints: /requirements (40 items), /backlog (priority-sorted with deadline pressure)
- Frontend: requirements table with filters + expandable details, backlog with deadline banner + score-ranked table
- Dashboard KPI cards (Critical count, days to CE deadline, etc.) + top-10 backlog snippet

Phase 3 — SBOM Upload + Automated Checks:
- Migration 120: compliance_cra_sboms (versioned uploads, CycloneDX + SPDX)
- SBOM endpoints: POST /sbom/upload (format detection, summary extraction), GET /sboms
- Checks reuse compliance_evidence_checks: init creates 6 default CRA checks, run executes
- Real implementations: cra_security_txt (HTTP + Contact: line) and cra_tls_cert_check (TLS handshake)
- Frontend: SBOM file upload + version list, Checks page with per-check URL input + Run button

Backend-Reuse: gap_projects (intake pre-population), compliance_evidence_checks/_check_results.
Tenant scoping via existing X-Tenant-ID header pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:56:52 +02:00
Benjamin Admin 3faa312b31 feat(iace/verification): derived view on relevant mitigations + 2 actions
Task #21. The verification page used to manage a separate VerificationItem
entity that the expert had to populate by hand — disjoint from the actual
mitigations list. With the is_relevant flag from migration 029, the
verification step has a natural definition: confirm completion for every
mitigation the expert flagged as relevant for this project.

Page is now a derived view on useMitigations(): filter is_relevant=true,
group by title (same dedupe as Massnahmen page), expose two actions per
hazard×mitigation row:

  1. "Kundenstandard" — already implemented at the customer's site, no
     evidence file required. Sets is_customer_standard=true and
     status='verified'.

  2. "Verifizieren…" — opens a modal asking for a textual evidence
     reference (Prüfprotokoll-Nr, audit reference, etc.). Calls the
     existing POST /mitigations/:mid/verify with verification_result.
     File upload is deferred to phase 2 once an object-storage backend
     is in place — the modal explains this.

When a row is verified, a "Zurücksetzen" link reverts status to
'implemented' for accidental confirmations.

Header counters: total relevant / open / verified / Kundenstandard.

Maßnahmen-page polish (same commit):
  - "Lösch."-column header removed — the trash icon is self-explanatory
  - groupByTitle now additionally deduplicates by hazard_id within a
    group (engine occasionally emits duplicate (name, hazard_id) pairs
    when Reinit is clicked twice; a follow-up migration 030 will add
    a UNIQUE constraint to prevent these upstream)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:49:56 +02:00
Benjamin Admin 8f4f59f0e3 feat(iace/mitigations): is_relevant + is_customer_standard flags
[migration-approved]

Expert-driven workflow refinement on the Massnahmen page. The engine seeds
~80 mitigations per project, but for a concrete customer site most need a
relevance decision before they're meaningful in verification:

  status: 'planned' | 'implemented' | 'verified'   (existing — verification track)
  is_relevant          bool   (new)                (does this apply to *this* site?)
  is_customer_standard bool   (new)                (already in place at customer — no evidence)

Decision flow on the Mitigations tab:
  Engine-seeded → is_relevant=false (Default, waiting for expert)
  Expert checks "Relevant" → is_relevant=true → surfaces in verification
  Expert clicks trash       → DELETE (banner warns: do not click Reinit
                                       afterwards or seeds come back)
  In verification, customer_standard=true bypasses evidence upload

is_customer_standard implies is_relevant (DB CHECK constraint).

Migration 029_iace_mitigation_relevance.sql:
  ALTER TABLE iace_mitigations ADD COLUMN is_relevant ..., is_customer_standard ...
  + CHECK constraint + partial index on is_relevant for the verification
    page's filter.

Backend (Go):
  - Mitigation struct gains two bool fields
  - CreateMitigation: defaults to false/false (engine-seeded mitigations
    start unbewertet)
  - UpdateMitigation: new case clauses for both keys; setting
    is_customer_standard=true auto-flips is_relevant=true to satisfy
    the CHECK constraint
  - All three SELECT statements (ListMitigations, ListMitigationsByProject,
    getMitigation) extended with the two new columns

Frontend:
  - Maßnahmen-page columns: [Relev. ☑] [Lösch. 🗑] Title | #Hazards | P·I·V
  - Group-header checkbox shows tri-state (indeterminate when partial),
    flips all instances in the group at once
  - Banner above the table: "Markiere jede Maßnahme als Relevant oder
    lösche sie. Nach Löschen kein Neu initialisieren mehr drücken."
  - Relevant rows tinted emerald, customer-standard label visible
  - Legacy bulk-select state + helpers removed (the Relevant checkbox
    now IS the primary mass action)
  - useMitigations gains handleSetRelevant, handleSetCustomerStandard,
    handleDeleteSilent (for non-confirm bulk deletes)

Future use: is_customer_standard mitigations from a prior project at the
same customer can later be auto-suggested when commissioning the next
plant — turning expert knowledge into reusable customer-profile data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:35:56 +02:00
Benjamin Admin df7d83134b feat(agent): migrate compliance-check results to banner + documents (M1-M5)
After a compliance-check run finishes, the user can now apply the
extracted vendor inventory directly to their own:

  - CookieBanner config (admin /sdk/einwilligungen)
  - Cookie-Policy / VVT-Register / Privacy-Policy templates
    (admin /sdk/document-generator)

Backend:
  - migration_to_banner.py: vendor list -> CookieBannerConfig with
    ESSENTIAL/PERFORMANCE/PERSONALIZATION/EXTERNAL_MEDIA buckets +
    review flags (broken opt-out URLs, missing expiry, no cookies listed)
  - migration_to_document.py: vendor list -> pre-fills for 3 doc
    templates, recipient-type aware (INTERNAL/GROUP/PROCESSOR/CONTROLLER)
  - agent_migration_routes.py: GET /banner-preview, /document-preview,
    /summary keyed on check_id
  - compliance_audit_log: new check_payloads table persists cmp_vendors +
    extracted_profile so the preview survives an app restart
  - tests: 9 mapper units + 4 endpoint integration tests

Frontend:
  - MigrationPanel.tsx: modal showing banner-config diff + document
    pre-fills, plus links into the existing editors
  - ComplianceCheckTab.tsx: replaces standalone audit link with the
    panel; net -3 lines, stays at the 500-cap

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:06:28 +02:00
Benjamin Admin f4c9cea770 feat(iace/mitigations): group measure rows by title, collapse 21x→1 row
The "Maßnahmen" page in the Bremsscheibe project showed a flat list with
heavy redundancy — e.g. "Sicherheitszeichen nach ISO 7010" appeared on 21
separate rows, one per linked hazard. Same for "Gefahrenpiktogramme",
"Flucht- und Rettungswege" etc. The signal got lost in the noise.

This is a presentation-only regrouping. Each Hazard×Mitigation pair stays
a separate DB row with its own status, notes and edit history (option B
from the discussion: instances remain independently editable). The page
now collapses rows that share the same `m.title` into one group row.

Group row shows:
  - title + ISO 12100 sub-category (if encoded in description)
  - count of linked hazards on the right
  - compact status distribution "P · I · V" (Planned/Implemented/Verified)
  - shared checkbox that selects all instances in the group
Click expands the group and reveals the individual hazard×measure rows,
each with its own StatusBadge and detail-expand for MitigationHints.

State additions:
  - expandedGroup: Set<string> with keys `${type}:${title}` so the same
    title across different reduction stages stays independently togglable
  - groupByTitle() helper trims the title, falls back to "(ohne Titel)"
  - statusCounts() helper for the P·I·V breakdown

Pagination semantics swapped from 50 instances/page to 50 groups/page —
makes the list far easier to scan at the ~80-instance scale this project
exhibits.

LOC: 267 → 346 (well under the 500 hard cap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:50:45 +02:00
Benjamin Admin 6ed30dae5b feat(agent): MC scorecard + audit drill-down + tenant trend (A1-A6)
Now that all 1874 MCs run per check (Task #30 cap removal), the report
was about to drown in noise. This commit adds the full aggregation /
persistence / drill-down stack so each MC is actionable, not just
counted.

A1 mc_scorecard.py (new):
  build_scorecard(checks)    -> per-regulation PASS/FAIL/SKIP + severity
  top_fails(checks, n)       -> N most severe failed MCs
  full_audit_records(...)    -> flat rows ready for sidecar SQLite

A2 Email rendering:
  agent_doc_check_scorecard.py (new) builds an HTML scorecard table
  (regulation × passed/failed/HIGH/MEDIUM/score) shown at the top of
  the email. agent_doc_check_report._render_document now collapses
  the 500-MC L2 forest into 'X/Y bestanden (Z Fail)' summary plus
  a top-10 fails block per doc — old verbose render is gone.

A3 compliance_audit_log.py (new) — sidecar SQLite at
  /data/compliance_audits.db (separate from compliance Postgres
  schema to comply with the no-new-migrations rule in CLAUDE.md):
    check_runs(check_id, ts, tenant_id, site_name, base_domain,
               doc_count, scorecard json, vvt_summary json)
    mc_results(check_id, doc_type, mc_id, label, passed, skipped,
               severity, regulation, matched_text, hint)
  Route persists every run after the email is sent.
  docker-compose.yml adds compliance-audit volume + env.

A4 backfill_mc_regulation_llm.py (new) — Qwen-tagged backfill for
  the 1636 MCs the regex pass couldn't classify. Batches of 25,
  format=json, output constrained to the canonical regulation list.
  Run manually: docker exec bp-compliance-backend python3 \
                 /app/scripts/backfill_mc_regulation_llm.py [--dry-run]

A5 Admin audit tab — GET /api/compliance/agent/audit/<check_id>
  proxied via /api/sdk/v1/agent/audit/<id>. New page
  /sdk/agent/audit/[checkId] renders scorecard + filterable MC table
  (status / doc_type / regulation, expandable rows with matched_text
  + hint). ComplianceCheckTab now shows 'Voll-Audit oeffnen' link.

A6 Trend per tenant — GET /api/compliance/agent/audit/tenant/<id>
  returns recent runs. Email scorecard shows per-regulation delta
  badges ('(+12%)', '(-3%)') compared with the previous run for the
  same tenant + base_domain. Lookup is one SQLite query.

Plumbing:
  rag_document_checker.py — SELECT now includes 'article'; MC results
    carry 'regulation' + 'article' through to CheckItem.
  agent_doc_check_routes.CheckItem schema gains regulation + article
    fields (defaults '') so old clients still parse.
  agent_compliance_check_routes — response gains 'check_id' so the
    frontend can build the audit link.
2026-05-17 13:45:58 +02:00
Benjamin Admin 6d29191e9b fix(vvt): score INTERNAL/GROUP without opt-out/privacy penalty
User feedback after BMW test:
- 60 'BMW AG — XYZ' rows were rendered as ✗ for Opt-Out/Privacy and
  scored 38-52%. That's misleading: BMW processing for itself doesn't
  need a separate opt-out URL (cookie-banner is the consent
  mechanism) or a separate privacy policy (main DSI covers it).
- Title 'Anbieter' was wrong for 60 of 90 rows (internal services).

Three orthogonal fixes:

1. score_vendors becomes recipient_type aware:
   - INTERNAL/GROUP_COMPANY: opt_out_url, privacy_policy_url, country
     are NOT required (the user's main DSI + cookie-banner cover them).
     What IS required: name, purpose, cookies disclosed with name +
     expiry. Cookies-disclosure weight raised to 50 (was 15) so the
     VVT-relevant data is the score driver.
   - 'necessary' category: opt-out still skipped (§25 Abs. 2 TDDDG).
   - External (PROCESSOR/CONTROLLER): existing strict scoring stays.

2. _link_status_badge accepts na_label and renders a neutral em-dash
   with explanation tooltip instead of red ✗ when the column doesn't
   apply to that row. _render_vendor_row_full passes na_label based on
   recipient_type:
     - INTERNAL/GROUP -> 'Nicht erforderlich (eigene Verarbeitung)'
     - necessary       -> 'Nicht erforderlich (§25 Abs. 2 TDDDG)'

3. Header + summary clarify the split:
   - h3 changed to 'Verarbeitungstaetigkeiten und Empfaenger aus der
     Cookie-Richtlinie' (was 'Drittanbieter aus Cookie-Richtlinie').
   - Top line: '90 Verarbeitungen erfasst — 60 eigene + 30 externe
     Empfaenger'.
   - Disclaimer below: explains the INTERNAL/GROUP exemption so the
     reader understands why those rows don't show ✗ for missing URLs.
   - Section labels enriched with the relevant DSGVO article:
     'Eigene Verarbeitungstaetigkeiten — fuer das VVT (Art. 30)',
     'Auftragsverarbeiter — AVV erforderlich (Art. 28)',
     'Joint Controller — Vereinbarung pruefen (Art. 26)'.

Expected BMW result after fix: ~85% of the 60 BMW-AG rows jump from
~52% to 90-100% (the real issue, fehlende Cookies-Disclosure, stays
flagged). The only true findings remaining are external links that
return 4xx (e.g. Criteo 403, Teads 404).
2026-05-17 13:15:40 +02:00
Benjamin Admin 8a44e67293 feat(compliance-check): unlock all 1874 MCs + close gap-table items
User: 'wir haben 1800 MCs erstellt um sie zu 10% zu nutzen — das ist
Schwachsinn'. Fixed all 6 gaps from the audit.

#1 max_controls=0 (was 20):
- agent_compliance_check_routes _check_single: passes max_controls=0 to
  check_document_with_controls -> ALL MCs evaluated per doc_type.
- 8 doc_types now use 1874 MCs instead of 160 (10x coverage).
- Regex matching is cheap (<1s per doc); LLM-enrich cap of 10 stays.

#2 LLM-verify fixed:
- llm_verify.py was getting 0/N parsed. Causes: qwen3 thinking-mode
  wrapped output in <think>...</think>, /api/generate doesn't enforce
  JSON, prompt didn't handle code-fence wrappers.
- Now uses /api/chat with format='json' (forces valid JSON).
- _parse_batch_response strips <think> tags, accepts {results:[...]}
  AND bare [...], adds richer regex-fallback parse, logs raw head on
  total parse failure for diagnosis.

#3 Loeschkonzept checklist (new):
- doc_checks/loeschkonzept_checks.py — 9 L1 + 7 L2 checks per DIN 66398
  + Art. 5(1)(e)/17/32 DSGVO: scope+responsibility, data categories,
  retention periods, legal basis refs (HGB/AO/BGB), deletion trigger,
  deletion process+technical+systems, deletion proof, exceptions +
  Art. 18 lock, review cycle, DSGVO references.
- runner.py registered for loeschkonzept/loeschung/loeschfristen.

#4 regulation backfill script:
- backend-compliance/scripts/backfill_mc_regulation.py — regex-detects
  DSGVO/TDDDG/TMG/BGB/HGB/AO/MStV/UWG/VSBG/PAngV/GwG/BDSG/EU-VO
  references in MC title+question+pass_criteria, UPDATEs regulation +
  article fields.
- Idempotent (only NULL rows), --dry-run flag, batched 200/UPDATE.
- Run inside container: docker exec bp-compliance-backend python3 \
    /app/scripts/backfill_mc_regulation.py

#5 MC alias-fallback:
- rag_document_checker._MC_ALIAS_FALLBACK maps doc_types without own
  MCs to a related set: nutzungsbedingungen->agb, social_media->dse,
  sub_processor/scc/tom_annex->avv, loeschfristen->loeschkonzept,
  eu_institution/dsb->dse.
- _load_controls retries with the alias when the primary query
  returns 0 rows.
- 14 additional doc_types now get MC coverage transparently.

#6 cross-domain auto-discovery:
- _autodiscover_missing builds a crawl plan: primary submitted base
  + up to 2 related domains sharing the owner SLD (e.g. BMW Group:
  bmw.de + bmwgroup.com + bmwgroup.jobs).
- Detection: regex over submitted texts for https?://...<owner>...
  hostnames distinct from the primary base.
- Each crawled base contributes documents + cmp_payloads to the
  discovery pool.

Net effect for BMW: 1874 MCs evaluated (90 from cookie alone, was
20), Loeschkonzept Pflichtangaben benoten-bar, LLM overturns false
regex FAILs, Joint-Controller policies on bmwgroup.jobs (Social
Media) jetzt entdeckbar. Same wins will apply to CRA-Compliance check.
2026-05-17 13:07:50 +02:00
Benjamin Admin fab1e35847 feat(vvt): recipient-type classification + 3-section VVT table
Per user request: BMW (and others) put their own services AND external
vendors in the same cookie-policy widget. The VVT-Tabelle now groups
them by Art. 30(1)(d) DSGVO recipient category so the DSB can act on
the right buckets:

  - INTERNAL      — owner processing for itself ('BMW AG — XYZ')
  - GROUP_COMPANY — same brand family, different legal entity ('BMW Bank')
  - PROCESSOR     — Auftragsverarbeiter, AVV-pflichtig (Adobe, Akamai)
  - CONTROLLER    — independent / joint controller (Meta Pixel, Google
                    Ads, LinkedIn — they run their own profiles)
  - AUTHORITY     — government bodies (rare in cookies)
  - OTHER         — fallback

New module vendor_classifier.py:
- owner_from_url(url) — derive site-owner token (bmw.de -> 'BMW',
  mercedes-benz.de -> 'Mercedes-Benz')
- classify(name, category, owner) — strict 5-tier heuristic:
  * INTERNAL: vendor name first-token is '<Owner>' / '<Owner> AG' /
    '<Owner> SE' / '<Owner> GmbH' / '<Owner> AG & Co. KG'
  * GROUP_COMPANY: starts with '<Owner> ' but isn't '<Owner> AG'
  * CONTROLLER: matches a known joint-controller list (Meta, Google
    Ads, YouTube, LinkedIn Insight, TikTok, Pinterest, Taboola,
    Outbrain, Criteo, Twitter, Reddit, ...)
  * PROCESSOR: legal-form suffix in name (GmbH, AG, Inc., A/S,
    B.V., S.A., Ltd., LLC, ...)
  * OTHER: anything else

vendor_extractor.extract_vendors_from_payloads now takes owner_name:
- Passes it through to classify() for every extracted vendor record
- The route derives owner_name via _company_name_from_url(doc_entries)
- LLM-extracted vendors are classified the same way (so V3 fallback
  also produces tagged records)

agent_doc_check_extras.build_vvt_table_html rewritten:
- Buckets vendors by recipient_type
- Renders one section per non-empty bucket, in canonical order
  (RECIPIENT_TYPE_SECTIONS), each with section header + count + bad
  count + nested table
- Within each section: sorted by compliance_score ascending
- Response JSON cmp_vendors includes recipient_type so the frontend
  can later import per-category into the VVT module

Expected BMW result: ~60 INTERNAL rows (BMW AG own services),
~25 PROCESSOR rows (Adobe, Adform, Akamai, AWS, ...), ~5 CONTROLLER
rows (Meta Pixel, Google, LinkedIn, Pinterest, Outbrain, Taboola).
2026-05-17 12:31:49 +02:00
Benjamin Admin 6c7d4c7552 fix(vvt): correct ePaaS schema mapping + category-aware scoring
The first BMW VVT table rendered all 24 providers at 20% score because
the ePaaS extractor was reading the wrong field names. Actual schema is
nested: providers[].processings[].persistences[], NOT providers[] alone.

Correct ePaaS schema (verified against bmw.com/epaas/.../de_DE.epaas.json):
  Provider:    {id, name, description, processings[]}
  Processing:  {id, name, description, categoryId, optOutLink,
                privacyPolicyLink, persistences[]}
  Persistence: {id, name, domain, type, expiry, description}

Two structural changes:

1. One row per processing (not provider). BMW has 26 providers but ~91
   processings spread across them (Adobe alone has ACMProcessing,
   AdobeAnalytics, AdobeCampaign, AdobeTargetAnalytics, AdobeTargetPers.).
   The cookie widget displays each processing separately — VVT now
   mirrors that. Display name format: 'Provider Name — Processing Name'.

2. Read optOutLink/privacyPolicyLink from PROCESSING (where they live),
   not provider. Persistences flatten to cookies[] with name + expiry +
   description.

Plus category mapping:
  advertising -> marketing
  strictlyNecessary -> necessary
  statistics -> statistics
  functional -> functional

Category-aware scoring (cookie_link_validator.score_vendors):
- 'necessary' (technisch erforderliche, §25 Abs. 2 TDDDG): no opt-out
  required, no country required. Score weight shifts to purpose +
  cookie disclosure (essential cookies must list names + expiry).
- All other categories: opt-out URL still mandatory; missing opt-out
  flags 'no_opt_out_url' and zeros that block of points.

Expected BMW result after this fix:
- ~91 rows (Adobe Analytics, Adform Retargeting, Akamai Infrastructure,
  AWS, ..., plus ~60 strictlyNecessary processings)
- Marketing rows with present opt-out → ~75-90%
- Necessary rows with cookie+expiry → ~85-95%
- Rows missing fields → still flagged
2026-05-17 11:19:31 +02:00
Benjamin Admin 189918b043 fix(cmp): stricter heuristic + only replace DOM when CMP is strictly larger
Two bugs observed in BMW BMW test run:

1. Generic JSON heuristic captured /de-de/login/bmw/api/flyout/data (4KB,
   user login fly-out data) and reconstruct_generic produced 56 words of
   noise. The CMP-prefer logic then 'replaced' the 185-word imprint DOM
   extraction with those 56 words because self_wc(185) < 300 — even
   though cmp_wc(56) < self_wc(185).

2. The strict prefilter list was too short. Login/auth/cart endpoints
   often have category-shaped JSON without being cookie policies.

Fixes:
- dsi_discovery: replace DOM with CMP only when cmp_wc > self_wc AND
  meets one of the existing conditions. Tiny captures can no longer
  silently destroy a bigger DOM extraction.
- cmp_extractor: skip non-cookie URLs (/login, /auth, /user, /session,
  /cart, /checkout, /search, /flyout, /menu, /nav, /translation, /i18n,
  /locale, /feature-flag).
- cmp_extractor: require ≥5KB payload size — real CMP policies are
  always larger (BMW ePaaS is ~393KB). Tiny matches drop out before
  reconstruction.
2026-05-17 10:50:19 +02:00
Benjamin Admin 873997c13b feat(vvt): V3 — LLM vendor extraction fallback for unknown CMPs
When the cookie text has no captured CMP payload (long-tail sites that
don't use ePaaS/OneTrust/Cookiebot/etc.) we now fall back to a Qwen → OVH
LLM cascade to extract a structured vendor list from the policy text.

New module backend/compliance/services/vendor_llm_extractor.py:
- extract_vendors_via_llm(cookie_text): runs Qwen first (local Ollama),
  then OVH if Qwen returns nothing usable.
- System prompt instructs the model to return STRICT JSON only:
  {vendors: [{name, country, purpose, category, opt_out_url,
   privacy_policy_url, persistence, cookies: [...]}]}
- Lenient JSON parser tolerates code-fences, prose wrappers, dict vs list.
- _normalize() caps array sizes (80 vendors, 30 cookies each), validates
  URLs (must be http(s)), trims fields to reasonable lengths.

Route integration (agent_compliance_check_routes.py):
- After named-CMP extract: if cmp_vendors is empty AND the cookie text
  has ≥500 words (otherwise it's likely navigation chrome), invoke the
  LLM extractor. Progress message 'Vendor-Liste per LLM extrahieren...'.
- Vendors then run through the same validate_vendor_urls + score_vendors
  pipeline → VVT table rendered identically regardless of source.

docker-compose.yml: backend-compliance gains OLLAMA_URL, CMP_LLM_MODEL,
OVH_LLM_URL/KEY/MODEL env vars (same names as consent-tester so the
configuration is unified).

This closes the 'every site eventually gets a VVT table' goal:
- Known CMP → V1/V2 structured extraction (fast, exact)
- Unknown CMP → V3 LLM extraction (slow, best-effort)
- No text at all → no vendors, but other compliance checks still run.
2026-05-17 09:55:42 +02:00
Benjamin Admin 9c0cc0f59f feat(vvt): V2 — vendor extractors for Cookiebot/Usercentrics/Didomi/TrustArc
Backend vendor_extractor.py gets 4 new per-CMP dispatchers, mirroring the
JSON schemas observed in each platform:

- Cookiebot: 'Categories[*].Cookies[*]' with Vendor/Host, expiry, purpose
- Usercentrics: 'services[*]' with cookieMaxAgeSeconds, processingCompanyCountry
- Didomi: 'app.vendors[*]' with country + policyUrl
- TrustArc: 'vendors[*]' + per-category 'Cookies' with provider

All 6 named CMPs (ePaaS, OneTrust, Cookiebot, Usercentrics, Didomi,
TrustArc) plus the generic-shape fallback are now mapped — every site
hitting Phase B of the cascade gets a structured vendor list, scored
opt-out links, and a VVT-Tabelle in the email.
2026-05-17 09:52:10 +02:00
Benjamin Admin ea4dbb223f feat(vvt): per-vendor extraction + opt-out check + VVT table in email (V1)
When a known CMP (ePaaS, OneTrust) renders the cookie policy, we now
extract structured vendor records, probe their opt-out + privacy URLs,
score each vendor (0-100), and append a 'VVT-Vorschlag' table to the
compliance email — one row per vendor, sortable by compliance score.

consent-tester:
- DSIDiscoveryResult.cmp_payloads: surfaces raw CMP JSON to callers
- DSIDiscoveryResponse: new cmp_payloads field
- discover_dsi_documents sets cmp_payloads from cmp_capture
- cmp_library/{epaas,onetrust}.py: new extract_vendors(d) returning
  list[VendorRecord]

backend:
- _fetch_text() now returns (text, cmp_payloads) tuple
- doc_entries store cmp_payloads per doc (mostly cookie)
- _autodiscover_missing forwards homepage payloads to the cookie entry
- New module vendor_extractor.py: dispatches ePaaS/OneTrust/generic
  schemas; dedupes vendors across multiple payloads
- cookie_link_validator.py extended with validate_vendor_urls(vendors)
  and score_vendors(vendors) — 0-100 score per vendor based on name,
  purpose, country, opt-out reachable, privacy URL reachable, cookies
  with names + expiry
- agent_doc_check_extras.build_vvt_table_html: renders the table
- Route appends VVT HTML after the provider list, before the
  document-by-document report
- Response JSON gains cmp_vendors for future frontend rendering

Example for BMW: ~30 ePaaS providers → table with Name | Kategorie |
Sitz | Cookies | Opt-Out (✓/✗) | Privacy (✓/✗) | Score. Sorted by
score ascending so the worst-compliant vendors are at the top.
2026-05-17 09:50:11 +02:00
Benjamin Admin c9c0fb5965 feat(cookie-check): enhanced patterns + active opt-out link validator
cookie_checks.py:
- cookie_names_listed: now also matches CMP placeholder notation
  (BMW: 'Adfpc###', 'CT###') and 'Diese Datenverarbeitung verwendet die
  folgenden Cookies oder ähnliche Technologien' as list-shape signal.
  Cryptic vendor names like 'audience', 'adformfrpid' are accepted via
  the surrounding markup, not by hard-coding each one.
- cookie_providers_named: new pattern 'Gesetzt von: <Firma>' (BMW/ePaaS
  per-cookie vendor naming) + recognition of full legal-form names
  (Adform A/S, BMW AG, Adobe Systems Software Ireland Limited).
- cookie_duration_values: now matches 'Ablauf: 1 Jahr' / 'Speicherdauer:
  30 Tage' (BMW format) in addition to the legacy '<n> <unit>'.

New L1 + L2 checks for controller in cookie-policy:
- cookie_controller (L1): the cookie policy must name Verantwortlich(er)
- cookie_controller_address (L2): PLZ + Ort or address keywords
- cookie_controller_contact_or_link (L2): email/phone OR link back to
  Datenschutzerklärung (the practical equivalent — BMW does this)

New L2 checks (parented under opt_out):
- cookie_optout_links: detects per-provider opt-out URLs in the text
- cookie_privacy_policy_links: per-provider privacy-policy URLs

New service: cookie_link_validator.py
- extract_links(text): pulls all https?://… URLs that follow 'Opt-Out
  Link:' / 'Link zur Privacy Policy:' (deduped)
- validate_links(links): probes every URL concurrently (HEAD first, GET
  fallback for 405/403). 10 parallel, 8s per request, 60s batch cap.
  Returns reachable=True/False + status + final_url.
- build_check_items(): renders 2 CheckItems (opt-out + privacy-policy),
  each pass if ALL links 2xx/3xx, fail with up-to-5 broken-link examples.

Hook in _check_single: doc_type=='cookie' triggers the validator after
regex+MC checks. Recomputes correctness with the new L2 items.

This addresses two concrete BMW observations:
1. BMW's per-cookie structure (Name + Zweck + Ablauf, Gesetzt von: …,
   Opt-Out Link: …) now recognised → 'Konkrete Cookie-Namen aufgelistet'
   and 'Konkrete Speicherdauern' should pass.
2. Defective opt-out URLs surface as compliance findings rather than
   silently passing — Art. 7(3) DSGVO requires a working withdrawal
   path per provider.
2026-05-17 09:38:32 +02:00
Benjamin Admin 4a5924b8c4 feat(iace): CRA / DIN EN 40000-1-2 cyber-resilience spur
[guardrail-change]

Phase 18 adds an EU Cyber Resilience Act compliance track to IACE:
the engine now fires patterns that surface the manufacturer-side CRA
obligations whenever a project's components carry digital elements.

Patterns (HP1910-HP1918, hazard_patterns_cra.go):
  HP1910  Missing SBOM
  HP1911  Unsigned firmware/software updates
  HP1912  Factory-default credentials still active
  HP1913  No coordinated vulnerability disclosure (CVD) policy
  HP1914  No documented security patch SLA
  HP1915  Missing user-facing hardening guide
  HP1916  No incident-notification process to ENISA / CSIRT
  HP1917  No security assessment prior to placing on market
  HP1918  AI component without cybersecurity risk assessment

Each pattern carries ClarificationQuestionsDE so the operator gets
auditor-grade questions to take back to the Anlagenbauer instead of
the engine inventing prose. PatternMatch carries DefaultAvoidability
(P=1 for all CRA patterns), feeding the PLr graph from Phase 17.

Measures (M540-M548, measures_library_cra.go):
  M540  SBOM (SPDX or CycloneDX) with each machine release
  M541  Signed updates with rollback protection
  M542  Forced default-password change at first boot
  M543  Published CVD policy (security.txt / PSIRT)
  M544  Documented patch SLA with CVSS-tier response times
  M545  User-facing hardening guide in the machine docs
  M546  ENISA incident-notification process (24h/72h/14d)
  M547  Authenticated update channel + integrity check
  M548  Pre-market security assessment / pen-test

The library is urheberrechtlich neutral: identifiers only
(Verordnung (EU) 2024/2847, DIN EN 40000-1-2 Entwurf, IEC 62443,
ETSI EN 303 645, ISO/IEC 5962, ISO/IEC 29147). No normative text
is reproduced — DIN/Beuth proprietary content is referenced by
section number only.

Category-compatibility:
  cyber_resilience pattern category accepts measures with
  HazardCategory cyber_resilience, cyber_network, or
  software_control. Updated in both the runtime helper
  (iace_handler_init_helpers.go) and its test-mirror
  (pattern_coverage_test.go) — both must move in lockstep.

Frontend (clarifications page):
  When at least one clarification references "2024/2847" or
  "40000-1-2" in its norm_references, a blue info-banner is
  rendered at the top of the page:
    "Cyber Resilience Act (CRA) — Hinweis zur Geltung
     Diese Klärungsliste enthält Fragen zur Verordnung (EU)
     2024/2847 (CRA). Die CRA gilt für Produkte mit digitalen
     Elementen, die ab dem 11.12.2027 auf dem EU-Markt bereit-
     gestellt werden. ..."
  Reminds the user that the CRA pflichten are forward-looking
  while still allowing the manufacturer to bake them in now.

LOC exceptions:
  Added three pre-existing files to .claude/rules/loc-exceptions.txt
  (manufacturer_safety_features.go, iace_handler_clarifications.go,
  routes.go). All three grew across Phases 16-17 and are tagged as
  Phase 5+ refactor backlog. [guardrail-change] marker required.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:15:51 +02:00
Benjamin Admin 2afa5a179b feat(iace): Risikograph EN ISO 13849-1 PLr + Methoden-Kopf im Bericht
Phase 17 of the risk-assessment polish. Two pieces:

A) PLr per EN ISO 13849-1 Anhang A (Risikograph)
   - HazardPattern.DefaultAvoidability (1 = P1, 2 = P2). Optional;
     defaults to P1 if unset (conservative — operator can raise after
     review).
   - ComputePLr(s,f,p) implements the canonical 8-leaf binary tree
     (S1F1P1 -> a, ..., S2F2P2 -> e). Pinned by 8 table-driven tests.
   - SeverityToS / ExposureToF map the existing 1-5 fields to the
     binary S/F at the documented threshold (3).
   - At project initialise, every hazard's Description is appended
     with "Risikograph EN ISO 13849-1 (Anhang A): S2 · F1 · P1 -> PLr c"
     so the audit value is visible without leaving the hazard view.
   - PatternMatch carries DefaultAvoidability so the init handler can
     pick it up without a second pattern lookup.

B) Methoden-Kopf am Bericht
   - GET /clarifications.html now opens with a standardised methodology
     block: ISO 12100 Anhang B (hazard ID) + ISO 13849-1 Anhang A
     (PLr graph) + ISO 12100 6.2/6.3/6.4 (reduction hierarchy). Same
     wording on every export, ready for the Anlagenbauer-Uebergabe.
   - Only norm identifiers — no norm text reproduced.

C) ISO12100Section in Hazard Description
   - When a pattern is labeled with ISO12100Section, the hazard
     description gets a "Klassifikation: EN ISO 12100 Anhang B,
     Abschnitt 6.3.5.4" suffix. Provenance for the auditor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:03:10 +02:00
Benjamin Admin 71d31c914b feat(iace): ISO 12100 Anhang B mapping — split noise/vibration + section identifier
Phase 16 of the Klaerungen / risk-assessment polish. Sources from
EN ISO 12100 Anhang B Tabelle B.1 are now first-class:

A) HazardPattern.ISO12100Section identifier (string), persisted only as
   the section number (e.g. "6.3.5.5") — not the norm text. Keeps the
   library urheberrechtlich neutral (DIN/Beuth license). 57 patterns
   labeled today; rest will follow on touch.

B) Category split per ISO 12100 Nr. 4 vs Nr. 5:
   - 16 patterns reclassified noise_vibration -> noise_hazard
   - 7  patterns reclassified noise_vibration -> vibration_hazard
   - 1  pattern (HP228 UV-/Laermexposition) kept multi-cat
   acceptableMeasureCategories now accepts both new aliases plus the
   legacy noise_vibration. Coverage test recognises both as valid.

C) 5 new ISO-12100-Annex-B gap patterns (HP1900-HP1904):
   - HP1900 Vakuum-Verletzung (6.3.5.5)
   - HP1901 Federenergie / elastische Elemente (6.2.10)
   - HP1902 Rutschen/Stolpern auf rauer Oberflaeche (6.3.5.6)
   - HP1903 Hochdruckinjektion (6.3.5.4) — includes clarifying
            "no hand-locating of leaks" question
   - HP1904 Ersticken durch Brustkorbquetschung (6.3.5.2)

The library now mirrors the ISO 12100 Annex B structure for the gaps
the Bremse benchmark surfaced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:59:16 +02:00
Benjamin Admin b090662524 fix(compliance-check): respect auto-discovery 'not found' verdict; DSB not canonical
Two related bugs in the BMW test result:

1. AGB rendered as 'MANGELHAFT 0/13' even though BMW has no public AGB:
   - Auto-discovery correctly returned 'not found' for AGB (no link on
     bmw.de matches AGB keywords).
   - But auto_fill_from_dsi then found the substring 'AGB' in a section
     of the DSI and pseudo-filled the AGB entry with a 264-word DSI
     fragment.
   - cross_search_documents would have done the same.
   - Both now skip entries where discovery_attempted=True AND
     auto_discovered=False — the 'not found' verdict stands.

2. DSB-Kontakt rendered as a separate 100% OK document with 7566 words
   = the entire DSI text:
   - GDPR practice: the DSB is named *inside* the DSI as an email or
     contact block (Art. 13(1)(b)), not as a stand-alone page.
   - cross_search_documents had been assigning the full DSI to the DSB
     row because it matched 'datenschutzbeauftragte' keywords.
   - DSB removed from _ALL_DOC_TYPES — no longer canonical, no longer
     padded as missing, no longer auto-discovered. The frontend row
     remains so a tenant with a separate DSB page can still submit one.

After this fix BMW should render:
- DSE: OK
- Impressum: LUECKENHAFT (unchanged — regex gaps to fix separately)
- Cookie-Richtlinie: OK
- Social Media: NICHT GEFUNDEN (bmw.de does not link to it)
- AGB: NICHT GEFUNDEN (correct — BMW has no public AGB)
- Nutzungsbedingungen: NICHT GEFUNDEN
- Widerruf: NICHT GEFUNDEN
2026-05-17 01:53:09 +02:00
Benjamin Admin c4be077c5d feat(iace): Klaerungen Phase 3 — DB-Tabelle + Multi-User + PDF-Export
[migration-approved]

Three pieces complete the Klaerungen lifecycle:

1. Migration 028: iace_clarifications + iace_clarification_comments +
   iace_clarification_history. Deterministic clarification_key
   (UNIQUE per project) so engine re-inits don't lose answers.
   History table logs every status/answer transition. The previous
   JSONB-in-metadata storage is kept as read-only fallback for
   pre-migration projects until a one-shot upcopy script runs.

2. Multi-User-Workflow:
   - assigned_to field on every clarification (free-text user kuerzel
     for now; an FK to users can be added in a follow-up).
   - Comment thread per clarification (POST .../comment, GET
     .../detail returns the thread).
   - Status-history log written by UpsertClarification when the
     status or answer actually changes.
   - Frontend Modal: Zugewiesen-an + Bearbeiter fields, comment
     thread with inline post, collapsible history section.

3. PDF-Export via print-friendly HTML:
   - GET /clarifications.html returns a standalone A4-styled
     document with status badges, norm references, affected hazards
     and a signature row at the bottom. The Bediener opens the link
     and uses Strg-P / Cmd-P to save as PDF. No server-side PDF
     dependency added.
   - Frontend "PDF / Druck" button next to CSV export.

Backend:
- internal/iace/store_clarifications.go: UpsertClarification,
  ListClarificationsForProject, GetClarificationByKey,
  AddClarificationComment, ListClarificationComments,
  ListClarificationHistory.
- internal/api/handlers/iace_handler_clarifications.go:
  - AnswerClarification now writes the SQL row, falls back to legacy
    JSONB read on list.
  - PostClarificationComment, ListClarificationDetail,
    ExportClarificationsHTML added.

Migration must be applied manually on Mac Mini and prod via
psql -f /migrations/028_iace_clarifications.sql — pattern as in
scripts/apply_*_migration.sh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:39:17 +02:00
Benjamin Admin b2b4d77877 fix(auto-discovery): compute missing against canonical 8 types, not submitted
Frontend filters out empty doc rows -> req.documents only contains the
N submitted entries (3 in BMW case). The old auto-discovery loop
computed 'missing' as 'entries in doc_entries with empty text', which
was always empty for those N entries -> discovery never fired.

Fix:
- missing = _ALL_DOC_TYPES - {canonical doc_types in doc_entries}
- For each missing type, APPEND a new entry to doc_entries with
  discovery_attempted=True. If a discovered doc matched, fill text/url
  and set auto_discovered=True.
- Check loop: skip entries with no URL and no text (let padding label
  them). Entries with URL but no text keep the 'Kein Text' error so the
  user sees fetch failures explicitly.
2026-05-17 01:28:51 +02:00
Benjamin Admin f19a75d83d feat(iace): Klaerungen Phase 2 — Sidebar-Counter + CSV-Export + Hazard-Banner
Three pieces complete the Klaerungen UX:

1. Sidebar-Counter: layout.tsx polls /clarifications and shows a
   colored open-count badge on the "Klaerungen" nav item. Refreshes
   whenever the user changes route.

2. CSV-Export: new backend endpoint
   GET /sdk/v1/iace/projects/:id/clarifications.csv produces a UTF-8-
   BOM-prefixed semicolon-separated CSV (Excel-friendly) with ID,
   Quelle, Kategorie, Frage, Status, Antwort, Begruendung, Bearbeiter,
   answered_at, anzahl Gefaehrdungen, Gefaehrdungs-Namen, Norm-Refs.
   Frontend Klaerungen-Seite bekommt einen "CSV-Export"-Button.

3. Hazard-Banner statt Fragentext im Benchmark-Detail: the previous
   bulleted clarification list was duplicated across 48 hazards for a
   single FANUC question. Phase 2 replaces it with a compact status
   badge — "N offene Klaerung(en) — Klaerungen-Seite oeffnen" (orange)
   or "Alle N Klaerungen beantwortet" (green) with a direct link.

Backend cleanup: iace_handler_init.go no longer appends the "Mit
Anlagenbauer zu klaeren" block to Hazard.Description. The description
stays focused on the scenario; clarifications live in the dedicated
endpoint and answers persist across re-inits via project.metadata.
The aggregated "Referenzierte Normen" line on the hazard is kept.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:25:36 +02:00
Benjamin Admin 525038359a feat(compliance-check): auto-discover missing doc types from homepage
When the user leaves some doc-type rows empty, the tool now actively
searches the website for them — only marks 'not found' as last resort.

Flow:
1. User submits N URLs (e.g. just DSI)
2. For each canonical doc_type with no submitted URL/text, the route
   identifies the most-common base (scheme://netloc) from submitted URLs
3. Calls consent-tester /dsi-discovery on the homepage with
   max_documents=15 (180s timeout)
4. Classifies every discovered doc into a canonical doc_type via
   title/URL keyword rules (_DISCOVERY_RULES — covers cookie/widerruf/
   social_media/agb/nutzungsbedingungen/dsb/impressum/dse)
5. Fills matching empty entries with the discovered text, marks
   auto_discovered=True and discovery_attempted=True

Padding now differentiates:
- 'Auf der Website nicht gefunden' — discovery was attempted, no doc
  matched. Amber badge, friendly hint to add URL manually.
- 'Nicht eingereicht — Quelle nicht angegeben' — user gave NO URLs at
  all, nothing to crawl from. Grey badge.

Email + frontend:
- Status labels: NICHT GEFUNDEN (amber) vs NICHT EINGEREICHT (grey)
- 'Gepruefte Quellen' table tags auto-discovered URLs with a small blue
  'auto-entdeckt' badge so GF sees what tool found vs user submitted.

Implementation only runs when ≥1 URL was submitted (no base to crawl
from otherwise). Adds 30-90s for unsubmitted types but avoids the
'just say nicht gefunden' anti-pattern.
2026-05-17 01:14:05 +02:00
Benjamin Admin 79efa54898 feat(iace): Klaerungen MVP — Phase 1
New page "Klaerungen" between Massnahmen and Verifikation.

Backend:
- internal/iace/clarifications.go: Clarification struct + ClarificationAnswer +
  BuildProjectClarifications() — aggregates pattern-level + manufacturer-
  level questions from collectAllPatterns + GetManufacturerSafetyFeatures.
  Deterministic IDs ("pattern:HP1640:0", "manuf:fanuc:dual-check-safety-dcs:1")
  so persisted answers survive every re-init.
- internal/api/handlers/iace_handler_clarifications.go:
  - GET /projects/:id/clarifications returns aggregated list with affected
    hazard names + persisted answer state, sorted (open first).
  - POST /projects/:id/clarifications/:cid/answer writes status/answer/
    reasoning/answered_by/answered_at to project.metadata.clarification_-
    answers — no DB schema change.

Frontend:
- admin-compliance/app/sdk/iace/layout.tsx: new "Klaerungen" nav item.
- app/sdk/iace/[projectId]/clarifications/page.tsx: table grouped by
  source (FANUC / Pattern HP1640 / …), Filter Offen/Beantwortet/Alle,
  search field, Antwort-Modal with status/answer/Begruendung/Bearbeiter.

A clarification answered once applies to ALL referenced hazards — the
operator no longer has to answer the same FANUC DCS question on 48
mechanical hazards individually.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 01:05:53 +02:00
Benjamin Admin bc21480a2a fix(compliance-check): always render 8 doc types + 4 BMW GT-gap fixes
Always-show-8 (user-requested):
- agent_compliance_check_routes.py: _pad_results_with_missing pads the
  results list to always include all 8 canonical doc_types in canonical
  order. Missing types get a placeholder DocCheckResult with error=
  'Nicht eingereicht' + scenario='missing'.
- agent_doc_check_report.py: NICHT EINGEREICHT status label (neutral),
  friendly grey body block instead of red error.
- ChecklistView.tsx: 'Nicht eingereicht' chip (neutral grey, not red
  'Fehler'); SCENARIO_LABELS adds missing entry + header chip counter.

Impressum-Regression fix (#18):
- _fetch_text(url, doc_type): cookie/dse/social_media -> max_documents=1
  (CMP capture authoritative, sub-pages dilute). Other types -> =3
  (Impressum needs Versicherungsvermittler, Aufsicht, Berufsrecht sub-
  pages). 15s networkidle bail keeps timing safe.

ODR/Verbraucherstreitbeilegung filter (#19):
- _apply_profile_filter: when profile.needs_odr=True (B2C), override the
  check's default B2B-oriented hint with action-oriented B2C guidance
  pointing at Art. 14 EU-VO 524/2013 + §36 VSBG. Previously the check
  contradicted itself: 'profile says B2C' + hint 'only relevant for B2C
  online vendors'.

Registergericht regex (#20):
- impressum_checks.py: accept colon/dot/dash between keyword and city
  (BMW writes 'registergericht: münchen hrb 42243'). Add 'sitz und
  registergericht: X' as separate pattern.

Industry detection (#21):
- business_profiler.py: 'automotive' keywords broadened (antriebs,
  motor, leasing, werkstatt, probefahrt, plus brand names BMW/Mercedes/
  Audi/VW/Porsche/Opel). 'it_services' keywords narrowed — software/
  cloud/hosting are mentioned in every privacy policy and were biasing
  the result toward IT for any tech-aware company.
2026-05-17 01:03:58 +02:00
Benjamin Admin 74f66c4c34 fix(admin/iace/benchmark): show Klaerungsfragen + Normen on Engine column
The Go init handler appends two annotated blocks to Hazard.Description
("Mit Anlagenbauer zu klaeren: ..." and "Referenzierte Normen: ...")
without changing the DB schema. The benchmark detail view only rendered
hazard.scenario || hazard.description, so the appended blocks were
silently hidden because scenario is always populated.

Split the description into three structured pieces:
1. extractScenario() — pure scenario text, stripped of trailing blocks
2. extractClarifications() — bullet list of "Mit Anlagenbauer zu klaeren"
3. extractEngineNorms() — pipe-separated norm references

Each piece is rendered as its own DetailRow. The FANUC DCS clarification
that already lives in the DB (48/115 hazards on the Bremse project) is
now visible in the Engine column.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:42:41 +02:00
Benjamin Admin 5f2da1de88 feat(consent-tester): Phase E — self-improving CMP library
cmp_discovery_log.py:
- sqlite log at /data/cmp_discoveries.db: every LLM-discovered CMP
  pattern recorded with domain, strategy, value, sample text
- Auto-promote (user-chosen 'voll automatisch' mode): when LLM returns
  strategy=url AND extracted text >= 800 words, write a new module
  /data/auto_cmp/auto_<slug>.py with derived regex matcher + reconstruct
- record_discovery() called from dsi_discovery._try_llm_cascade on success

cmp_library/_registry.py:
- Loads both hand-written modules from services/cmp_library/ AND
  auto-promoted modules from /data/auto_cmp/ (CMP_AUTO_DIR env)
- Auto modules use importlib.util.spec_from_file_location, no package
  install needed; restart consent-tester to pick up new ones

dsi_discovery.py:
- _try_llm_cascade now calls record_discovery() on every successful
  LLM analysis (cached AND fresh)

main.py:
- GET /cmp-discoveries — admin endpoint listing all logged discoveries
- DELETE /cmp-discoveries/{id} — rollback (unlinks auto_*.py)

This closes the self-improving loop: first encounter with a new CMP fires
the LLM (cost) → discovery is auto-promoted → all future runs against the
same vendor pattern hit Phase B (Named CMP) at <50ms with no LLM call.
2026-05-16 23:09:23 +02:00
Benjamin Admin 2400aa6a9e feat(consent-tester): Phase C+D — LLM cascade fallback (Qwen → OVH)
New module consent-tester/services/cmp_llm_fallback.py:
- LLMCookieExtractor: single-endpoint adapter (Ollama OR OpenAI-compat)
- LLMCascade: tries Qwen (local Mac Mini Ollama) first; falls through to
  OVH (managed 120B) when Qwen returns no usable strategy
- LLMCascade.from_env(): reads OLLAMA_URL/CMP_LLM_MODEL + OVH_LLM_URL/
  OVH_LLM_KEY/OVH_LLM_MODEL from environment
- LLM returns JSON {strategy: url|selector|text, value: ...}
- Valkey-backed cache per netloc (cmp:hint:<netloc>, 7-day TTL) — next run
  against the same domain skips the LLM entirely

dsi_discovery.py:
- Wired network_log collector (URL/status/content-type/size of every JSON
  response on the page) — passed to LLM prompt as observation
- After Named CMP (Phase B) + Heuristic (Phase A) both fail AND DOM
  < 300 words: invoke LLMCascade.analyze(...)
- _apply_llm_hint executes the LLM's strategy: refetch URL via Playwright
  request context, query DOM selector, or use text directly
- Cache HIT path: apply cached hint, only fall back to LLM if cache is stale

docker-compose.yml:
- consent-tester gets env vars + cmp-data volume (for Phase E)
- All LLM endpoints configurable via env, sensible defaults

consent-tester/requirements.txt:
- redis>=5.0 (asyncio client, Valkey-compatible)
- httpx>=0.27
2026-05-16 23:06:05 +02:00
Benjamin Admin e9002175ac feat(iace): manufacturer safety feature library (Stufe A — 50+ entries)
Adds a curated database of safety-relevant features for the major
manufacturers across mechanical/plant engineering, written entirely in
own words with norm anchors. No verbatim manufacturer texts — therefore
no copyright issue:

- Markennennung (§ 23 MarkenG nominative use) is permitted.
- Fakten ueber Produkt-Sicherheitsfunktionen are not protected by § 2
  UrhG (only Werke, not facts).
- NormReferences contain only the identifiers (e.g. "EN ISO 13849-1
  PLd Kat.3"), never the norm text itself.

Coverage (52 entries across 12 categories):
  Industrieroboter (10): FANUC DCS, KUKA SafeOperation, ABB SafeMove,
    Yaskawa FSU, Staeubli CS9, Kawasaki Cubic-S, Mitsubishi MELFA,
    Universal Robots PolyScope, Doosan PRS, Comau SafeNet
  CNC/WZM (8): DMG MORI, Mazak, TRUMPF, Okuma, Hermle, Heidenhain
    SPLC, GROB, Heller
  Pneumatik (4): Festo, SMC, AVENTICS, Parker
  Hydraulik (3): Bosch Rexroth, HAWE, HYDAC
  Safety-PLC / Sicherheitstechnik (8): PILZ, SICK, Schmersal, Euchner,
    Leuze, Phoenix Contact, Banner, Wieland
  Standard-PLC (5): Siemens, Beckhoff, Rockwell, Schneider, B&R
  Pressen (3): Schuler, Bruderer, AIDA
  Spritzguss (3): Arburg, KraussMaffei, ENGEL
  Verpackung (2): Krones, Bosch Packaging/Syntegon
  Laser/Schweissen (3): Bystronic, Amada, Fronius
  Foerdertechnik (2): Interroll, SEW EURODRIVE

Engine integration:
- LookupManufacturerFeaturesInText() scans the project narrative for
  any of the manufacturer aliases (case-insensitive, umlaut-tolerant).
- Init-Handler appends matched feature clarifications to the relevant
  hazard's "Mit Anlagenbauer zu klaeren:" block — for the right
  HazardCategory only (e.g. FANUC DCS only on mechanical_hazard).
- For a Bremse project narrative mentioning "Fanuc Robodrill", the
  engine now adds clarification questions like "Ist DCS am Roboter
  konfiguriert?" to relevant mechanical hazards automatically.

Tests: 7 new pin tests — manufacturer count, norm prefixes, FANUC/KUKA
detection in narrative, umlaut robustness (Staeubli vs Staubli).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:04:56 +02:00
Benjamin Admin 7e426c31f1 feat(consent-tester): Phase B — named CMP library + plugin architecture
cmp_extractor.py refactored to thin coordinator (123 LOC, was 223).
Discovers all CMP modules via cmp_library/_registry.py:load_all() at
import time. Restart consent-tester to pick up new modules.

New cmp_library/ folder:
- _registry.py: auto-discovers all modules with MATCHER + reconstruct()
- epaas.py:     BMW Group ePaaS (extracted from cmp_extractor)
- onetrust.py:  cdn.cookielaw.org Groups/Cookies schema
- cookiebot.py: consent.cookiebot.com Categories schema
- usercentrics.py: api.usercentrics.eu services schema
- didomi.py:    sdk.privacy-center.org notice + vendors + purposes
- trustarc.py:  consent.trustarc.com categories + vendors

Each module:
- MATCHER: re.Pattern matching the CMP JSON endpoint URL
- reconstruct(d: dict) -> str: builds German Markdown cookie-policy text

Phase E (self-improving) will write auto_*.py files into the same folder;
_registry already picks those up via pkgutil.iter_modules.
2026-05-16 22:59:48 +02:00
Benjamin Admin 4f19310130 fix(iace): HP1654 Greifer durchschlaegt Zaun — DCS-Bezug
GT 1.8 fordert konkret den 'sicher begrenzten Bewegungsbereich (Dual
Check Safety)'. HP1654 hatte nur M061 'Feste trennende Schutzeinrich-
tung' als Mitigation. Ergaenzt um M494 (Safe Limited Position/Space mit
DCS-Erlaeuterung), M501 (Schutzzaun-Lastbemessung) und M502 (Greifer-
Fail-Safe). Klaerungsfragen verweisen explizit auf DCS bei FANUC,
SafeMove bei ABB, SafeOperation bei KUKA und die EN ISO 13849-1 PLd/
Kat.3-Validierung.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:56:40 +02:00
Benjamin Admin 8283483909 feat(consent-tester): Phase A — generic JSON cookie-policy heuristic
New module cmp_heuristic.py with:
- looks_like_cookie_policy(data): shape-based classifier (top-level keys
  cookies/categories/providers/vendors/purposes/cookieList/etc. + at
  least 2 name+description objects, or IAB TCF v2 vendors[]+purposes[])
- reconstruct_generic(data): walks JSON, extracts name + description
  fields + standalone prologue/dataController/persistence fields,
  emits flat German Markdown text (max 5000 words, dedup)

cmp_extractor.py wired so that AFTER named CMP matchers (epaas,
onetrust) fail, every JSON response on the page is tested for the
heuristic. If matched, payload is captured as '_heuristic' kind and
reconstructed via the generic walker.

This is Phase A of the 4-stage cascade (B-D follow). Unknown CMPs that
return JSON now work without hand-coding each one.

Pre-filter: skips response paths /api/config, /beacon, /track,
/analytics, /fonts/, /log/, /heartbeat/, /.well-known/ to avoid
spamming the heuristic on every Playwright load.
2026-05-16 22:56:20 +02:00
Benjamin Admin 9814b56f2f fix(cookie-extract): max_documents=1 + faster networkidle bail (Phase 0 fix)
Root cause of the recurring 603-word BMW result:
- DSI discovery for cookie-policy URL was hitting 4x networkidle timeouts
  (60s each = ~240s total).
- Backend httpx timeout (180s after the previous fix) gave up before the
  consent-tester finished, falling through to the raw HTTP fetch which
  returned BMWs SSR navigation chrome (603 words) as the 'cookie policy'.

Two orthogonal fixes:
1. _fetch_text now passes max_documents=1 for user-specified URLs. We only
   want self-extraction of THAT page; link-following is unnecessary noise.
2. networkidle wait_until window dropped 60s -> 15s. SPAs like BMW/Daimler
   never reach networkidle anyway; the 60s wait was pure latency. Falls
   through to domcontentloaded+5s render-wait, same as before.
2026-05-16 22:53:23 +02:00
Benjamin Admin 69729ef6ac feat(iace): norm references in mitigations + aggregated norm panel per hazard
Library measures carry NormReferences (EN/IEC/ISO/DIN/TRBS/TRGS Ziff./Kap./
Pos.) but they were dropped on persist: CreateMitigationRequest only
wrote Name + Description. The Fachmann benchmark file lists Normen for
34 of 60 hazards — the engine had this data already but lost it on the
way to the UI.

Fix without DB schema change:
- Mitigation.Description gets a "Normen: EN 60204-1 Ziff. 6.2 | EN 61140"
  line appended when the measure has NormReferences. Pipe separator keeps
  the inline panel short and grep-friendly.
- After all mitigations land, the aggregated dedup'd norm list for the
  hazard is appended to Hazard.Description as a single "Referenzierte
  Normen: ..." line so the UI can show one panel per hazard without
  scanning every mitigation.

Audit of library coverage (per-pattern) showed GT-Bremse Normen are
generally present and richer:
- HP1640 covers GT 2.2 (EN 60204-1 Ziff. 6.2, Ziff. 8.2.3, EN 61140 +)
- HP1641 covers GT 2.4 (EN 60204-1 Ziff. 8.2.6 +)
- HP1605 covers GT 1.7 (ISO 10218-1 Ziff. 5.6.2, 5.8.3 — Ziff. 5.7.3 fehlt)
- HP1671 covers GT 1.30 (EN 12417 — Pos. detail fehlt)

Followup: 2 fine-grained sub-paragraph references (5.7.3, Pos. 1.1.4)
can be added later as measure-text updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:51:50 +02:00
Benjamin Admin 35d6422247 fix(iace): HP1632 Bersten-Pattern eindeutige Zone fuer Dedup
ZoneDE 'Pneumatikkomponenten der Anlage' kollidiert nach normalizeZoneKey
mit HP1630 'Pneumatikschlaeuche der Automation' im 3-signifikante-Wort-
Vergleich. Neue Zone 'Berstgefaehrdete Druckwandungen Pneumatik (Leitungs-
wand, Dichtung, Verschraubung)' hat semantisch eigenstaendige Schluessel-
woerter — Dedup mergt nicht mehr in HP1630.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:34:51 +02:00
Benjamin Admin 5ea68ebea4 feat(iace): clarification questions + HP1632 Bersten + HP1637 KSS-Aerosol fix
Drei nachhaltige Verbesserungen, getrieben durch die Bremse-Benchmark-
Faelle GT 1.4, GT 1.30 und GT 7.4. Die Engine erfindet weiterhin
keine Fachmann-Kommentare — Kommentare bleiben aus, weil sie ein
Verstaendnis der konkreten Anlage erfordern, das die Engine nicht
hat. Statt dessen liefert die Engine norm-basierte Klaerungsfragen
und ein praeziseres Pattern-Vokabular.

A) HazardPattern.ClarificationQuestionsDE — neues optionales Feld:
   - Pattern hinterlegt prueffaehige Fragen, die der Bediener mit dem
     Anlagenbauer abklaert. Beispiele:
     - HP1640: "Liegt ein Pruefprotokoll nach EN 60204-1 vor?"
     - HP1666: "Ist die WZM als CE-konformes Subsystem integriert?"
     - HP1604: "Ist DCS am Roboter konfiguriert und validiert?"
   - Init-Handler haengt die Fragen an Hazard.Description an mit dem
     Marker "Mit Anlagenbauer zu klaeren:". Kein DB-Schema-Aenderungs-
     bedarf.
   - 11 Patterns mit Klaerungsfragen versehen (HP1602, HP1604, HP1611,
     HP1612, HP1620, HP1622, HP1637, HP1640, HP1641, HP1666, HP1685).

B) HP1632 "Bersten druckbeaufschlagter Pneumatik-Komponente" — neues
   Pattern, semantisch DISTINKT zu HP1630 "Abspringen":
   - Bersten = Material-/Druckversagen der Komponente, Mediumaustritt
   - Abspringen = Verbindung loest sich, Peitscheneffekt
   Bremse-Benchmark GT 1.4 sprach von Bersten, HP1630 nur von
   Abspringen — ein 66%-Frontend-Match war eine Sackgasse. Mit
   HP1632 feuert die Engine ein eigenes Hazard, das auf GT 1.4
   einen sauberen Volltreffer liefert.

C) HP1637 "Einatmen von KSS-Aerosolen" — Massnahmen vervollstaendigt:
   Vorher nur M141 (Sicherheitszeichen), neu zusaetzlich M405 (KSS-
   Aerosolabsaugung), M418 (AGW-Ueberwachung), M526 (WZM-Tueren
   geschlossen waehrend Bearbeitung), M408 (Hautschutzplan).
   Klaerungsfrage: "Wurde die Aerosolkonzentration nach Bearbeitungs-
   ende messtechnisch ermittelt und mit dem AGW verglichen?"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:23:56 +02:00
Benjamin Admin 41023f6343 fix(iace): HP1671 Druckluft-Verletzung — 4 zusaetzliche GT-1.30 Massnahmen
HP1671 "Druckluft-Verletzung in Bearbeitungszelle" matched zwar das
GT-1.30 Szenario "Einstich, Augenverletzung in Bearbeitungszelle" exakt
nach Name und Scenario, hatte aber nur eine einzige Massnahme M061
"Feste trennende Schutzeinrichtung". Die drei spezifischen Massnahmen
des Fachmanns (Reinigungsduese in Zelle integriert / Druckluft bei
Tueroeffnung aus / Einhausung-Lastbemessung) blieben unsichtbar, weil
mein neuer GT-Bremse-Pattern HP1712 zwar diese Massnahmen kennt, aber
durch RequiredEnergyTags=["pneumatic"] in diesem Projekt nicht feuert.

Fix: HP1671 SuggestedMeasureIDs ["M061"] -> ["M504", "M505", "M501",
"M061", "M141"]. EN 12417 Kap. 5.2 / Pos. 1.1.4 ist jetzt durch
M504/M505 abgedeckt. HP1712 bleibt als Backup-Pattern fuer Projekte
mit explizitem pneumatic-Tag bestehen.

Followup: HP1671 und HP1712 sind semantisch redundant — Konsolidierung
ist Teil der naechsten Pattern-Hygiene-Iteration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:08:05 +02:00
Benjamin Admin 6689b37f95 fix(agent): bump _fetch_text timeout 60s->180s
The dsi-discovery in consent-tester does self-extraction + follows up to
3 sub-links + waits for CMP JSON payloads. On big SPAs (BMW, Daimler)
this routinely exceeds 60s. When it timed out, the HTTP fallback returned
the SSR shell as text — for the BMW cookie page that's 603 words of site
navigation, which then registered as 'Cookie-Richtlinie nicht im
eingereichten Text' (33%). With 180s the consent-tester finishes cleanly
and we get the CMP-captured 1824 words of real policy.
2026-05-16 22:00:42 +02:00
Benjamin Admin 80d62a0c5f fix(iace): rename 58 duplicate HP-IDs in extended.go/extended2.go
Background: hazard_patterns_extended.go (HP045-074) and _extended2.go
(HP074-102) shared their entire ID range with the semantically-different
patterns in hazard_patterns_cobot.go, hazard_patterns_press.go,
hazard_patterns_operational.go and hazard_patterns_extended_dguv.go.
The collision had lived unnoticed because TestGetBuiltinHazardPatterns_-
UniqueIDs only checks the 44 builtin patterns (HP001-HP044).

Examples of the collision:
- HP059 = "Kollision Mensch-Roboter" (cobot.go) vs "Kupplung — mechanisch" (extended.go)
- HP060 = "Quetschen durch Werkzeug am Cobot" (cobot.go) vs "Diagnosemodul — Software" (extended.go)
- HP073 = "Wartung ohne LOTO" (operational.go) vs "Hydraulikventil — hydraulisch" (extended.go)

At runtime collectAllPatterns() returned both patterns under the same ID
which made downstream lookups (e.g. hazardPatternMeasures map keyed by
pattern_id) non-deterministic — last-loaded wins, dropping the other
pattern's mitigation set silently.

Rename strategy (no deletes — both patterns are real and earn their
SuggestedMeasureIDs after the category-filter work):
  extended.go  HP045..HP073 -> HP1800..HP1828 (29 IDs)
  extended2.go HP074..HP102 -> HP1830..HP1858 (29 IDs)

cobot/press/operational/extended_dguv keep their original IDs because:
- compliance_triggers.go references HP059/HP060 with the cobot meaning
- pattern_engine_test.go references HP073 with the LOTO/maintenance meaning
- phase3_4_test.go references HP073 the same way

New regression test:
- TestAllPatterns_UniqueIDs runs over collectAllPatterns() and fails if
  ANY pattern in the runtime set duplicates an ID. The old
  TestGetBuiltinHazardPatterns_UniqueIDs stays for the builtin subset.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:00:06 +02:00
Benjamin Admin 6a3e96d54c fix(iace): set-based measure-category filter + 235 pattern-author fixes
Two-part nachhaltiger fix replacing the previous "fill to 5 mitigations
no matter what" behavior that the GT-Bremse benchmark proved
unfaithful (e.g. HP1625 "scharfe Kanten" returning M005 "Rotations-
bewegung vermeiden" via category fallback; HP1651 "Wiederanlauf
Roboter" returning M054 "Sichere thermische Auslegung" via
mismatched pattern reference).

PART A — Set-based category filter (handlers package):
- acceptableMeasureCategories: replaces 1:1 patternCatToMeasureCat
  with a curated set per pattern category, so e.g.
  safety_function_failure now accepts software_control measures
  (watchdogs, plausibility checks) and emc_hazard accepts both
  electrical and software_control measures
- isCategoryCompatible: gate every measure id against the accepted
  set before creating a mitigation; mismatches log MEASURE-SKIP
- The old category fallback is REMOVED. A hazard whose pattern has
  no category-compatible measure is now created with zero mitigations
  and logged as COVERAGE-GAP — the operator must consult an expert.
  No more silent invention of generic defaults.

PART B — 235 pattern author-error fixes across 26 files:
- HP040-HP044 (AI): M101/M102/M103 (Auffangwanne/Absauganlage) ->
  M133 Anomalieerkennung + M214 Plausibilitaet + M213 Sensor-Redundanz
  + M044 Zweikanalige Steuerung + others
- HP011-HP015, HP104-HP109, HP1085-HP1095, HP1281-HP1334 (electrical):
  M001-M005/M054/M061 placeholders -> M481/M482 Isolation +
  M511-M522 PE/Schutzleiter/RCD/Hauptschalter
- HP110-HP1331 (material_environmental): M101-M103 -> M384-M395
  Brandschutz/Laserschutz + M533/M408 SDB/PSA
- HP800-HP858, HP1178-HP1264 (software/sensor/hmi):
  M101/M104 -> M105/M106/M107/M214 SPS/Watchdog/Plausibilitaet
- HP026, HP611-HP1690 (ergonomic): M001/M082 -> M353-M360 +
  M530-M532 Hebehilfe/ergonomische Hoehe
- HP201-HP1697 (mechanical): M054/M051 -> M002/M008/M061/M141 +
  M487/M488 Tueroeffnung-Stillsetzung/Wiederanlauf
- Plus EMF/Strahlung/Brand/Lärm/Vibration/Kommunikation/Cyber

Coverage shift (Pattern-Author-Fehler bei aktiviertem Set-Filter):
   start:         237 patterns with zero category-compatible measures
   after Stufe 1A:   5 (AI)
   after Stufe 1B:  20 (mechanical Bestand)
   after Stufe 1C:  35 (electrical Bestand)
   after Stufe 1D:  29 (material_environmental)
   after Stufe 1E:  29 (software/sensor/hmi)
   after Stufe 1F:  20 (ergonomic)
   after Stufe 1G:  80 (thermal/comm/radiation/fire/safety)
   final:           0  (28 extended.go/extended2.go duplicates fixed)

New regression tests:
- TestEveryPattern_HasCategoryCompatibleMeasure: every pattern in
  collectAllPatterns() must reference at least one category-compatible
  measure; gaps must be explicitly listed in AllowlistKnownGaps
  (currently empty). Fails CI for any new pattern that drifts.
- TestAcceptableMeasureCategories: pins the set-mapping for the
  7 most-bug-prone pattern categories.
- TestIsCategoryCompatible_EmptyMeasureCat: protects legacy entries.

A separate task #11 tracks 58 HP-ID duplicates between
extended.go/extended2.go and cobot.go/press.go/operational.go —
patterns are semantically different and TestGetBuiltinHazardPatterns_-
UniqueIDs misses them because it only checks HP001-HP044.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:11:02 +02:00
Benjamin Admin 938f9a6c51 fix(cmp): tolerate variable URL segments in ePaaS policy pattern
BMW ePaaS URLs use 3 segments between /policypage/ and .epaas.json:
  /epaas/prod/policypage/<tenant>/<config-hash>/<locale>.epaas.json
The old pattern only matched 2 segments. Switch to a tolerant pattern
that matches any path before .epaas.json (anchored at .epaas.json end).
2026-05-16 20:58:48 +02:00
Benjamin Admin 17a93bc694 fix(consent-tester): prefer CMP-JSON over thin DOM extraction
Previous threshold (DOM < 300 words) missed the BMW case where Playwright
extracted 346 words of pure site navigation. The CMP JSON had 1673 words
of real policy content but was discarded.

New heuristic: prefer CMP when ANY of:
  - DOM < 300 words (existing)
  - CMP text >= 1000 words (authoritative at scale)
  - CMP text >1.5x longer than DOM
2026-05-16 20:56:11 +02:00
Benjamin Admin 1792c6f896 fix(consent-tester): capture CMP JSON to extract dynamically-loaded cookie policies
BMW (and other big enterprise sites) do NOT render cookie policies as
static HTML. Their widget loads structured data from a JSON endpoint
(BMW: ePaaS at /epaas/prod/policypage/.../<locale>.epaas.json) and
renders it client-side after consent. Our DOM extraction therefore only
captured site navigation (603 words of header/footer chrome), not the
actual policy.

New module consent-tester/services/cmp_extractor.py:
- CMPCapture: response listener that catches policy JSON during navigation
- Reconstructors for ePaaS (BMW) + OneTrust placeholder
- Returns Cookie-Richtlinie text built from policyPageMetadata +
  categories + providers (BMW: 1673 words reconstructed vs. 603 noise)

dsi_discovery.py:
- Attach CMPCapture before page.goto
- After self-extraction: if rendered DOM < 300 words AND CMP captured a
  payload, prefer the CMP-reconstructed text. This bypasses the empty
  '.cookie-policy' div problem entirely.
2026-05-16 20:50:15 +02:00
Benjamin Admin e61e9d9e2a feat(agent): progress_pct + 6 BMW-Run Verbesserungen
Backend (agent_compliance_check_routes.py):
- progress_pct (0-100%) im Job-State, ueber alle Phasen verteilt
  (Laden 0-30, Profil 35-40, Pruefen 40-80, Banner 80-92, Report 95-100)
- Status-Texte vereinheitlicht ("Texte laden X/N", "Pruefen X/N")
- Firmenname fuer Email-Subject jetzt aus URL abgeleitet
  (bmw.de -> "BMW", mercedes-benz.de -> "Mercedes-Benz") statt
  unzuverlaessigem extracted_profile.companyName (matchte oft juris.de)
- E-Mail-Report enthaelt jetzt Banner+TCF-Vendor-Liste (build_provider_list_html)

Backend (agent_doc_check_extras.py — neu):
- build_scanned_urls_html: gepruefte URLs als Tabelle oben im Report
  (transparent fuer GF, welche Quellen wirklich gezogen wurden)
- Cross-Domain-Hinweis bei >1 netloc (BMW: bmw.de / bmwgroup.com /
  bmwgroup.jobs — Auffindbarkeit nach Art. 12 DSGVO)
- build_provider_list_html: Banner-Box + TCF-Vendor-Tabelle mit Spalten
  Name | Kategorie | Zweck | Drittland | Rechtsgrundlage

Backend (business_profiler.py):
- §34d-GewO Versicherungsvermittler-Hinweise zaehlen nicht mehr als
  "finance"-Industrie (BMW wurde dadurch falsch als B2B/finance erkannt)
- Neue Industry "automotive" (Fahrzeug/KFZ/Konfigurator/Modellpalette)
- B2B-Keywords: generische Begriffe wie "unternehmen", "beratung",
  "consulting" entfernt (matchten in jedem Konzerntext)
- B2C-Fallback: bei Verbraucher-Signalen ("widerruf", "kunde",
  redaktioneller Inhalt) tendiert auf b2c statt b2b

Frontend (ComplianceCheckTab.tsx):
- Progress-Balken mit Width-% und XX%-Anzeige rechts
- liest data.progress_pct aus Polling-Response

Consent-Tester (dsi_discovery.py):
- Cookie-Policy-Extraktion kritisch fixt: wait_for_function bis
  body.innerText > 500 chars (BMW SPA-Rendering brauchte mehr Zeit)
- _extract_text_robust: 3-Strategien-Extraktion (Selektoren -> Body-
  Cleanup -> P/LI/TD-Tags)
- _extract_text_from_iframes: liest OneTrust/Sourcepoint/Usercentrics
  Iframe-Inhalte (manche Cookie-Policies leben dort)

Adressiert alle Findings aus dem BMW-Ground-Truth-Vergleich.
2026-05-16 17:53:14 +02:00
Benjamin Admin 4d1e0a7f8e feat(iace): GT-Bremse coverage — 59 expert measures + 7 hazard patterns
Systematic gap analysis of the Bremse ground-truth file (60 entries,
100 unique expert measures) revealed only ~5% library coverage. This
commit closes the documented gaps with concrete, norm-anchored
mitigations.

Library additions (M481-M539, 59 entries):
- M481-M482  Low-voltage isolation (>= 2,0 / 2x1,0 / 1,0 MOhm +
             IP2X/IPXXB per EN 60204-1 Ziff. 6.2/8.2.3) — primary
             trigger of this work
- M483-M485  Pneumatic safety (component pressure rating, hose
             retention, depressurization per EN ISO 4414)
- M486-M490  Robot-cell access (tool-secured fence, dual-channel
             door monitor, intentional restart, anti-trap inside
             opening, HMI sight line per ISO 10218-2)
- M491-M493  Teach mode (key/password mode selector, safe reduced
             speed <= 250 mm/s, hold-to-run with 3-stage enabler
             per ISO 10218-1)
- M494-M500  Geometry constants (Safe Limited Position, reach-over
             250 mm @ 2250 mm fence, conveyor opening >= 850 mm,
             25 mm finger gap, band speed <= 100 mm/s per
             EN ISO 13857 / EN 619)
- M501-M507  Enclosure load rating, gripper fail-safe, centring
             gripper stop on door, MWF nozzle integration, floor
             load capacity per DIN 1055-3
- M508-M517  Electrical cabling + PE protection (environment-rated,
             drag chain, strain relief, 10 mm² Cu PE, dual PE,
             monitoring, continuity check, class-II equipment,
             SELV/PELV per EN 60204-1)
- M518-M522  RCD, cable cross-section, overcurrent in each active
             conductor, IP22 water ingress, lockable main switch
- M523-M539  Teach-locked door, WZM door interlock, dual-channel
             door switch, machining-doors-closed for aerosol
             retention, post-NOTHALT release, >25 kg lifting aid
             (DGUV 208-016), 95-120 cm control height, ergonomic
             conveyor height, SDS/PSA reference, BA instructions
             for depressurization/clamp release/max weight/pinch
             warning/slip warning/dead-state cleaning

New hazard patterns (HP1710-HP1717):
floor overload, gripper failure throw, compressed-air injury in
machining cell, manual handling load + awkward posture, MWF skin
contact, live-cabinet cleaning short, pneumatic stored-energy.

Existing patterns rewired to the new measures: HP1600, HP1602-1606,
HP1610-1612, HP1620-1622, HP1630/1631/1633, HP1640/1641, HP1660/1661,
HP1675, HP1685, HP1688, HP1689, HP1698-1704.

Tooling:
- scripts/gt_measure_gap_analysis.py: 4-signal fuzzy matcher
  (Jaccard, token recall, substring containment, norm-reference
  overlap). Outputs markdown + JSON.
- gt_coverage_test.go: 23 expert-validated (GT-Nr, pattern, measure)
  triples + a norm-reference presence test for every new expert
  measure (no generic 'do X safely' entries allowed).
- .gitea/workflows/ci.yaml: new iace-gt-coverage job enforces
  MIN_COVERAGE_PCT (70%) on Strong+Weak GT coverage; never lower
  without explicit decision.

Coverage shift: 5% Strong -> 30% Strong, 0% -> 72% Strong+Weak.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:08:52 +02:00
Sharang Parnerkar 3784988d00 chore: bump next 15.1.0 → 15.5.16 (CVE-2026-44578)
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 1m37s
CI / detect-changes (push) Successful in 1m6s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / loc-budget (push) Successful in 21s
CI / nodejs-build (push) Successful in 4m16s
CI / test-go (push) Has been skipped
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
Patches unauthenticated SSRF in WebSocket upgrade handler.
Applies to admin-compliance, developer-portal.

Compliance-SDK admin-dashboard skipped — has a pre-existing TS
type mismatch that blocks the build regardless of Next version.
Needs separate migration work.

GHSA-c4j6-fc7j-m34r.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 18:48:36 +02:00
223 changed files with 30051 additions and 1008 deletions
+32
View File
@@ -150,3 +150,35 @@ admin-compliance/app/sdk/compliance-scope/page.tsx
# --- zeroclaw: ground-truth corpus (test fixture data, not source) ---
zeroclaw/docs/ground-truth/06-spiegel-dsi-fulltext.txt
# --- IACE data tables and orchestration files (Phase 16-18 refactor backlog) ---
# Each file grew during the IACE polish phases (Stufe-A manufacturer library,
# Klärungen Phase 3 PDF export + methodology, app routes). Phase 5+ split
# targets — splitting now would fragment unrelated cohesive logic.
ai-compliance-sdk/internal/iace/manufacturer_safety_features.go
ai-compliance-sdk/internal/api/handlers/iace_handler_clarifications.go
ai-compliance-sdk/internal/app/routes.go
# --- 2026-05-19 Coolify-Unblocker: 4 grandfathered files ---
# Diese 4 Dateien sind Pre-Existing-Tech-Debt und blockierten den
# Coolify-Build. Splits sind als P9.5 Tech-Debt-Sprint geplant, bis
# dahin als Exceptions getragen damit Deploy laeuft.
#
# cra_routes.py (1714): CRA-Phase-5-Router mit Annex-V/VII Generator —
# Split nach Endpoint-Gruppen (vuln/post-market/tech-doc/doc) sinnvoll.
backend-compliance/compliance/api/cra_routes.py
# vendor_redundancy.py (727): Cost-Lookup-Tabellen (DSP/SaaS/Self-Service)
# + Multi-Function-Tools + Engine. Tabellen-Splits nach Lookup-Klasse.
backend-compliance/compliance/services/vendor_redundancy.py
# cookie_knowledge_db.py (608): Basis-KB — Ergaenzung via
# cookie_knowledge_extended.py + Facade laeuft bereits (P2). Split der
# Base-KB nach Vendor-Familie ist Phase-2-Ziel.
backend-compliance/compliance/services/cookie_knowledge_db.py
# cookie-banner-embed.ts (558): Banner-Embed-Bundle fuer CDN-Auslieferung
# — selbst-kontainierter Code-Generator, Split wuerde Generator-Logik
# fragmentieren ohne Nutzen.
admin-compliance/lib/sdk/einwilligungen/generator/cookie-banner-embed.ts
# ComplianceCheckTab.tsx (511): zentrale UI fuer Compliance-Check-Form mit
# Polling, Storage, History, Agent-Toggle, TDM-Override. Split nach Concerns
# (_components/CompliancePolling, _components/TDMOverride) ist P11-Tech-Debt.
admin-compliance/app/sdk/agent/_components/ComplianceCheckTab.tsx
+17 -7
View File
@@ -313,10 +313,13 @@ jobs:
git push --force "$PUSH_URL" "refs/tags/last-build/main"
echo "Tag last-build/main now at ${SHA}"
# ── orca redeploy — runs only if at least one build succeeded ─────────────
# `always()` lets this run when some builds are skipped (unchanged services).
# The contains() checks ensure we only redeploy when something actually built
# and no build failed.
# ── orca redeploy — runs if at least one build was triggered AND green ────
# Per-job `result == 'success'` is true only when the job actually ran and
# passed; skipped/failed/cancelled jobs return their own status string and
# fail the OR. This avoids Gitea's quirky evaluation of `contains(needs.*
# .result, 'success')` when most upstreams are skipped (root cause of
# trigger-orca being skipped on single-service changes).
# `always()` is required so the job is evaluated when upstreams skip.
trigger-orca:
runs-on: docker
@@ -332,9 +335,16 @@ jobs:
- build-dsms-node
if: |
always() &&
contains(needs.*.result, 'success') &&
!contains(needs.*.result, 'failure') &&
!contains(needs.*.result, 'cancelled')
(
needs.build-admin-compliance.result == 'success' ||
needs.build-backend-compliance.result == 'success' ||
needs.build-ai-sdk.result == 'success' ||
needs.build-developer-portal.result == 'success' ||
needs.build-tts.result == 'success' ||
needs.build-document-crawler.result == 'success' ||
needs.build-dsms-gateway.result == 'success' ||
needs.build-dsms-node.result == 'success'
)
steps:
- name: Checkout (for SHA)
run: |
+34
View File
@@ -314,6 +314,40 @@ jobs:
go test -v -coverprofile=coverage.out ./...
go tool cover -func=coverage.out | tail -1
iace-gt-coverage:
runs-on: docker
container: python:3.12-slim
needs: detect-changes
if: needs.detect-changes.outputs.sdk == 'true'
env:
# Lower bound on Strong+Weak GT-Bremse coverage. Raise this number when
# coverage improves; never lower it without an explicit decision.
MIN_COVERAGE_PCT: "70"
steps:
- name: Checkout
run: |
apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
- name: GT-Bremse measure-coverage report
run: |
python3 scripts/gt_measure_gap_analysis.py --json /tmp/gt_gap_report.json > /tmp/gt_gap_report.md
echo "--- summary ---"
head -8 /tmp/gt_gap_report.md
- name: Enforce coverage threshold
run: |
python3 - <<'PY'
import json, os, sys
d = json.load(open('/tmp/gt_gap_report.json'))
total = d['total']
covered = d['ok_count'] + d['weak_count']
pct = covered * 100 / total if total else 0.0
threshold = float(os.environ['MIN_COVERAGE_PCT'])
print(f"GT coverage (strong+weak): {covered}/{total} = {pct:.1f}% (threshold {threshold}%)")
if pct < threshold:
print(f"::error::GT-Bremse coverage regression — {pct:.1f}% < {threshold}%")
sys.exit(1)
PY
test-python-backend:
runs-on: docker
container: python:3.12-slim
@@ -0,0 +1,28 @@
/**
* Proxy: GET /api/sdk/v1/agent/audit/<checkId>
* -> backend GET /api/compliance/agent/audit/<checkId>
*
* Forwards optional query params (doc_type, regulation, only_failed).
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
export async function GET(
request: NextRequest,
{ params }: { params: { checkId: string } },
) {
const checkId = params.checkId
const qs = request.nextUrl.searchParams.toString()
const url = `${BACKEND_URL}/api/compliance/agent/audit/${checkId}${qs ? `?${qs}` : ''}`
try {
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) })
const data = await resp.json()
return NextResponse.json(data, { status: resp.status })
} catch {
return NextResponse.json(
{ error: 'Audit-Abfrage fehlgeschlagen' },
{ status: 503 },
)
}
}
@@ -0,0 +1,28 @@
/**
* Proxy: GET /api/sdk/v1/agent/findings/<checkId>
* -> backend GET /api/compliance/agent/findings/<checkId>
*
* Forwards all query params (source, severity, doc_type, status, q, limit).
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
export async function GET(
request: NextRequest,
{ params }: { params: { checkId: string } },
) {
const checkId = params.checkId
const qs = request.nextUrl.searchParams.toString()
const url = `${BACKEND_URL}/api/compliance/agent/findings/${checkId}${qs ? `?${qs}` : ''}`
try {
const resp = await fetch(url, { signal: AbortSignal.timeout(20000) })
const data = await resp.json()
return NextResponse.json(data, { status: resp.status })
} catch {
return NextResponse.json(
{ error: 'Findings-Abfrage fehlgeschlagen' },
{ status: 503 },
)
}
}
@@ -0,0 +1,25 @@
/**
* Proxy: GET /api/sdk/v1/agent/migration/<checkId>/banner-preview
* -> backend GET /api/compliance/agent/migration/<checkId>/banner-preview
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
export async function GET(
request: NextRequest,
{ params }: { params: { checkId: string } },
) {
const qs = request.nextUrl.searchParams.toString()
const url = `${BACKEND_URL}/api/compliance/agent/migration/${params.checkId}/banner-preview${qs ? `?${qs}` : ''}`
try {
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) })
const data = await resp.json()
return NextResponse.json(data, { status: resp.status })
} catch {
return NextResponse.json(
{ error: 'Banner-Preview fehlgeschlagen' },
{ status: 503 },
)
}
}
@@ -0,0 +1,24 @@
/**
* Proxy: GET /api/sdk/v1/agent/migration/<checkId>/document-preview
* -> backend GET /api/compliance/agent/migration/<checkId>/document-preview
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
export async function GET(
_request: NextRequest,
{ params }: { params: { checkId: string } },
) {
const url = `${BACKEND_URL}/api/compliance/agent/migration/${params.checkId}/document-preview`
try {
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) })
const data = await resp.json()
return NextResponse.json(data, { status: resp.status })
} catch {
return NextResponse.json(
{ error: 'Dokument-Preview fehlgeschlagen' },
{ status: 503 },
)
}
}
@@ -0,0 +1,24 @@
/**
* Proxy: GET /api/sdk/v1/agent/migration/<checkId>/summary
* -> backend GET /api/compliance/agent/migration/<checkId>/summary
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
export async function GET(
_request: NextRequest,
{ params }: { params: { checkId: string } },
) {
const url = `${BACKEND_URL}/api/compliance/agent/migration/${params.checkId}/summary`
try {
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) })
const data = await resp.json()
return NextResponse.json(data, { status: resp.status })
} catch {
return NextResponse.json(
{ error: 'Migrations-Summary fehlgeschlagen' },
{ status: 503 },
)
}
}
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
export async function POST(request: NextRequest, ctx: { params: Promise<{ checkId: string }> }) {
const { checkId } = await ctx.params
const tenantId = request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
const body = await request.text()
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/checks/${checkId}/run`, {
method: 'POST',
headers: { 'X-Tenant-ID': tenantId, 'Content-Type': 'application/json' },
body: body || '{}',
})
const text = await resp.text()
return new NextResponse(text, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
}
}
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
export async function POST(request: NextRequest, ctx: { params: Promise<{ docId: string }> }) {
const { docId } = await ctx.params
const tenantId = request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
const body = await request.text()
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/documents/${docId}/approve`, {
method: 'POST',
headers: { 'X-Tenant-ID': tenantId, 'Content-Type': 'application/json' },
body,
})
const text = await resp.text()
return new NextResponse(text, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
}
}
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
function tenant(req: NextRequest) {
return req.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
}
export async function GET(request: NextRequest, ctx: { params: Promise<{ docId: string }> }) {
const { docId } = await ctx.params
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/documents/${docId}`, {
headers: { 'X-Tenant-ID': tenant(request) },
})
const text = await resp.text()
return new NextResponse(text, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
}
}
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
export async function GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
const tenantId = request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/backlog`, {
headers: { 'X-Tenant-ID': tenantId },
})
const text = await resp.text()
return new NextResponse(text, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
}
}
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
function tenant(req: NextRequest) {
return req.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
}
export async function GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/checks`, {
headers: { 'X-Tenant-ID': tenant(request) },
})
const text = await resp.text()
return new NextResponse(text, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
}
}
/** POST /checks (no body) -> backend /checks/init creates default checks */
export async function POST(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/checks/init`, {
method: 'POST',
headers: { 'X-Tenant-ID': tenant(request) },
})
const text = await resp.text()
return new NextResponse(text, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
}
}
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
export async function POST(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
const tenantId = request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
const body = await request.text()
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/documents/generate`, {
method: 'POST',
headers: { 'X-Tenant-ID': tenantId, 'Content-Type': 'application/json' },
body,
})
const text = await resp.text()
return new NextResponse(text, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
}
}
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
function tenant(req: NextRequest) {
return req.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
}
export async function GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
const { searchParams } = new URL(request.url)
const qs = searchParams.toString()
try {
const resp = await fetch(
`${BACKEND_URL}/api/v1/cra/projects/${id}/documents${qs ? `?${qs}` : ''}`,
{ headers: { 'X-Tenant-ID': tenant(request) } }
)
const text = await resp.text()
return new NextResponse(text, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
}
}
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
export async function GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
const tenantId = request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/monitoring`, {
headers: { 'X-Tenant-ID': tenantId },
})
const text = await resp.text()
return new NextResponse(text, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
}
}
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
export async function POST(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
const tenantId = request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
const body = await request.text()
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/path-select`, {
method: 'POST',
headers: {
'X-Tenant-ID': tenantId,
'Content-Type': 'application/json',
},
body,
})
const text = await resp.text()
return new NextResponse(text, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json(
{ error: 'Backend unreachable', details: String(err) },
{ status: 502 }
)
}
}
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
export async function GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
const tenantId = request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/requirements`, {
headers: { 'X-Tenant-ID': tenantId },
})
const text = await resp.text()
return new NextResponse(text, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
}
}
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
function tenantHeader(request: NextRequest): string {
return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
}
async function proxy(request: NextRequest, method: string, id: string, body?: string) {
const tenantId = tenantHeader(request)
const init: RequestInit = {
method,
headers: { 'X-Tenant-ID': tenantId, 'Content-Type': 'application/json' },
}
if (body !== undefined) init.body = body
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}`, init)
const text = await resp.text()
return new NextResponse(text, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json(
{ error: 'Backend unreachable', details: String(err) },
{ status: 502 }
)
}
}
export async function GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
return proxy(request, 'GET', id)
}
export async function PATCH(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
const body = await request.text()
return proxy(request, 'PATCH', id, body)
}
export async function DELETE(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
return proxy(request, 'DELETE', id)
}
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
function tenant(req: NextRequest) {
return req.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
}
/** GET /sbom -> List uploads. We map this to the backend /sboms endpoint. */
export async function GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/sboms`, {
headers: { 'X-Tenant-ID': tenant(request) },
})
const text = await resp.text()
return new NextResponse(text, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
}
}
/** POST /sbom -> multipart upload to backend /sbom/upload */
export async function POST(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
try {
const formData = await request.formData()
const upstreamForm = new FormData()
for (const [key, value] of formData.entries()) {
upstreamForm.append(key, value)
}
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/sbom/upload`, {
method: 'POST',
headers: { 'X-Tenant-ID': tenant(request) },
body: upstreamForm as unknown as BodyInit,
})
const text = await resp.text()
return new NextResponse(text, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
}
}
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
export async function POST(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
const tenantId = request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/scope-check`, {
method: 'POST',
headers: { 'X-Tenant-ID': tenantId },
})
const text = await resp.text()
return new NextResponse(text, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json(
{ error: 'Backend unreachable', details: String(err) },
{ status: 502 }
)
}
}
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
function tenant(req: NextRequest) {
return req.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
}
export async function GET(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/vulnerabilities`, {
headers: { 'X-Tenant-ID': tenant(request) },
})
const text = await resp.text()
return new NextResponse(text, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
}
}
export async function POST(request: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const { id } = await ctx.params
const body = await request.text()
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/${id}/vulnerabilities`, {
method: 'POST',
headers: { 'X-Tenant-ID': tenant(request), 'Content-Type': 'application/json' },
body,
})
const text = await resp.text()
return new NextResponse(text, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
}
}
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
function tenantHeader(request: NextRequest): string {
return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
}
/** GET /api/sdk/v1/cra/projects -> Backend list */
export async function GET(request: NextRequest) {
const tenantId = tenantHeader(request)
const { searchParams } = new URL(request.url)
const qs = searchParams.toString()
try {
const resp = await fetch(
`${BACKEND_URL}/api/v1/cra/projects${qs ? `?${qs}` : ''}`,
{ headers: { 'X-Tenant-ID': tenantId } }
)
const body = await resp.text()
return new NextResponse(body, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json(
{ error: 'Backend unreachable', details: String(err) },
{ status: 502 }
)
}
}
/** POST /api/sdk/v1/cra/projects -> Backend create */
export async function POST(request: NextRequest) {
const tenantId = tenantHeader(request)
const body = await request.text()
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects`, {
method: 'POST',
headers: {
'X-Tenant-ID': tenantId,
'Content-Type': 'application/json',
},
body,
})
const text = await resp.text()
return new NextResponse(text, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json(
{ error: 'Backend unreachable', details: String(err) },
{ status: 502 }
)
}
}
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
function tenant(req: NextRequest) {
return req.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
}
export async function PATCH(request: NextRequest, ctx: { params: Promise<{ vulnId: string }> }) {
const { vulnId } = await ctx.params
const body = await request.text()
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/vulnerabilities/${vulnId}`, {
method: 'PATCH',
headers: { 'X-Tenant-ID': tenant(request), 'Content-Type': 'application/json' },
body,
})
const text = await resp.text()
return new NextResponse(text, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
}
}
export async function DELETE(request: NextRequest, ctx: { params: Promise<{ vulnId: string }> }) {
const { vulnId } = await ctx.params
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/cra/projects/vulnerabilities/${vulnId}`, {
method: 'DELETE',
headers: { 'X-Tenant-ID': tenant(request) },
})
const text = await resp.text()
return new NextResponse(text, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
}
}
@@ -0,0 +1,55 @@
/**
* Proxy: GET /api/sdk/v1/einwilligungen/export?format=csv|json&kind=consents|history
* -> backend /api/compliance/einwilligungen/export/<file>
*
* Streams the backend response straight through (CSV or JSON download).
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
function getTenantHeader(request: NextRequest): HeadersInit {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
const clientTenantId = request.headers.get('x-tenant-id') || request.headers.get('X-Tenant-ID')
const tenantId = (clientTenantId && uuidRegex.test(clientTenantId))
? clientTenantId
: (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
return { 'X-Tenant-ID': tenantId }
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const fmt = (searchParams.get('format') || 'csv').toLowerCase()
const kind = (searchParams.get('kind') || 'consents').toLowerCase()
const filename = `${kind}.${fmt === 'json' ? 'json' : 'csv'}`
const upstreamPath = `/api/compliance/einwilligungen/export/${filename}`
const passthroughParams = new URLSearchParams()
for (const k of ['user_id', 'granted', 'since', 'consent_id']) {
const v = searchParams.get(k)
if (v) passthroughParams.set(k, v)
}
const qs = passthroughParams.toString()
const url = `${BACKEND_URL}${upstreamPath}${qs ? `?${qs}` : ''}`
try {
const r = await fetch(url, { headers: getTenantHeader(request) })
if (!r.ok) {
const text = await r.text()
return NextResponse.json({ error: text || `HTTP ${r.status}` }, { status: r.status })
}
return new NextResponse(r.body, {
status: 200,
headers: {
'Content-Type': r.headers.get('content-type') || 'application/octet-stream',
'Content-Disposition': r.headers.get('content-disposition') || `attachment; filename=${filename}`,
},
})
} catch (e) {
return NextResponse.json(
{ error: 'Export-Proxy fehlgeschlagen', detail: String(e) },
{ status: 503 },
)
}
}
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
function tenantHeader(request: NextRequest): string {
return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ derived_id: string }> }
) {
const { derived_id } = await params
try {
const resp = await fetch(
`${BACKEND_URL}/api/v1/quaidal/controls/${encodeURIComponent(derived_id)}`,
{ headers: { 'X-Tenant-ID': tenantHeader(request) }, cache: 'no-store' }
)
const body = await resp.text()
return new NextResponse(body, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
}
}
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
function tenantHeader(request: NextRequest): string {
return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const qs = searchParams.toString()
try {
const resp = await fetch(
`${BACKEND_URL}/api/v1/quaidal/controls${qs ? `?${qs}` : ''}`,
{ headers: { 'X-Tenant-ID': tenantHeader(request) }, cache: 'no-store' }
)
const body = await resp.text()
return new NextResponse(body, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
}
}
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
function tenantHeader(request: NextRequest): string {
return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ section_id: string }> }
) {
const { section_id } = await params
try {
const resp = await fetch(
`${BACKEND_URL}/api/v1/quaidal/criteria/${encodeURIComponent(section_id)}`,
{ headers: { 'X-Tenant-ID': tenantHeader(request) }, cache: 'no-store' }
)
const body = await resp.text()
return new NextResponse(body, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
}
}
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
function tenantHeader(request: NextRequest): string {
return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
}
export async function GET(request: NextRequest) {
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/quaidal/criteria`, {
headers: { 'X-Tenant-ID': tenantHeader(request) },
cache: 'no-store',
})
const body = await resp.text()
return new NextResponse(body, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
}
}
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
function tenantHeader(request: NextRequest): string {
return request.headers.get('x-tenant-id') || '00000000-0000-0000-0000-000000000001'
}
export async function GET(request: NextRequest) {
try {
const resp = await fetch(`${BACKEND_URL}/api/v1/quaidal/stats`, {
headers: { 'X-Tenant-ID': tenantHeader(request) },
cache: 'no-store',
})
const body = await resp.text()
return new NextResponse(body, {
status: resp.status,
headers: { 'Content-Type': resp.headers.get('Content-Type') || 'application/json' },
})
} catch (err) {
return NextResponse.json({ error: 'Backend unreachable', details: String(err) }, { status: 502 })
}
}
@@ -31,6 +31,7 @@ const SCENARIO_LABELS: Record<string, { label: string; color: string; bg: string
regenerate: { label: 'Neugenerierung', color: 'text-red-700', bg: 'bg-red-100' },
fix: { label: 'Korrekturen', color: 'text-amber-700', bg: 'bg-amber-100' },
import: { label: 'Konform', color: 'text-green-700', bg: 'bg-green-100' },
missing: { label: 'Fehlt', color: 'text-gray-600', bg: 'bg-gray-100' },
}
const DOC_TYPE_LABELS: Record<string, string> = {
@@ -102,6 +103,7 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
regenerate: results.filter(r => r.scenario === 'regenerate').length,
fix: results.filter(r => r.scenario === 'fix').length,
import: results.filter(r => r.scenario === 'import').length,
missing: results.filter(r => r.scenario === 'missing').length,
}
return (
@@ -114,6 +116,7 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
{scenarioCounts.import > 0 && <span className="bg-green-100 text-green-700 px-2 py-0.5 rounded-full">{scenarioCounts.import} konform</span>}
{scenarioCounts.fix > 0 && <span className="bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full">{scenarioCounts.fix} Korrekturen</span>}
{scenarioCounts.regenerate > 0 && <span className="bg-red-100 text-red-700 px-2 py-0.5 rounded-full">{scenarioCounts.regenerate} Neugenerierung</span>}
{scenarioCounts.missing > 0 && <span className="bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">{scenarioCounts.missing} fehlt</span>}
</div>
</div>
@@ -164,7 +167,15 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
</div>
</div>
<div className="flex items-center gap-3 shrink-0 ml-3">
{r.error ? (
{r.error && r.error.startsWith("Auf der Website nicht gefunden") ? (
<span className="text-xs text-amber-700 font-medium px-2 py-0.5 bg-amber-100 rounded-full whitespace-nowrap">
Nicht gefunden
</span>
) : r.error && r.error.startsWith("Nicht eingereicht") ? (
<span className="text-xs text-gray-500 font-medium px-2 py-0.5 bg-gray-100 rounded-full whitespace-nowrap">
Nicht eingereicht
</span>
) : r.error ? (
<span className="text-xs text-red-600 font-medium">Fehler</span>
) : (
<div className="flex flex-col gap-1">
@@ -3,6 +3,7 @@
import React, { useState, useCallback } from 'react'
import { ChecklistView } from './ChecklistView'
import { DocumentRow } from './DocumentRow'
import { MigrationPanel } from './MigrationPanel'
const DOCUMENT_TYPES = [
{ id: 'dse', label: 'DSI (Datenschutzinformation)', required: true },
@@ -66,13 +67,17 @@ interface HistoryEntry {
docCount: number
findings: number
resultKey: string
checkId?: string
}
export function ComplianceCheckTab() {
const [docs, setDocs] = useState<DocsState>(initState)
const [useAgent, setUseAgent] = useState(false)
const [tdmOverride, setTdmOverride] = useState(false)
const [tdmOverrideReason, setTdmOverrideReason] = useState('')
const [loading, setLoading] = useState(false)
const [progress, setProgress] = useState('')
const [progressPct, setProgressPct] = useState(0)
const [results, setResults] = useState<any>(() => {
if (typeof window === 'undefined') return null
try { const s = localStorage.getItem(STORAGE_KEY_RESULTS); return s ? JSON.parse(s) : null } catch { return null }
@@ -109,17 +114,16 @@ export function ComplianceCheckTab() {
if (!res.ok) continue
const data = await res.json()
if (data.progress) setProgress(data.progress)
if (typeof data.progress_pct === 'number') setProgressPct(data.progress_pct)
if (data.status === 'completed' && data.result) {
setResults(data.result); setProgress(''); setLoading(false)
setResults(data.result); setProgress(''); setProgressPct(0); setLoading(false)
localStorage.setItem(STORAGE_KEY_RESULTS, JSON.stringify(data.result))
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
return
}
if (data.status === 'failed' || data.status === 'not_found') {
if (data.status === 'failed') setError(data.error || 'Pruefung fehlgeschlagen')
setProgress(''); setLoading(false)
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
return
if (['failed', 'not_found', 'skipped_tdm'].includes(data.status)) {
if (data.status !== 'not_found') setError(data.error || (data.status === 'skipped_tdm' ? 'TDM-Vorbehalt erkannt — Crawl uebersprungen' : 'Pruefung fehlgeschlagen'))
setProgress(''); setProgressPct(0); setLoading(false); localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId(''); return
}
} catch { /* retry */ }
}
@@ -177,6 +181,7 @@ export function ComplianceCheckTab() {
setError(null)
setResults(null)
setProgress('Compliance-Check wird gestartet...')
setProgressPct(0)
try {
const entries = DOCUMENT_TYPES
@@ -194,6 +199,8 @@ export function ComplianceCheckTab() {
body: JSON.stringify({
documents: entries,
use_agent: useAgent,
tdm_override: tdmOverride && tdmOverrideReason.trim().length >= 10,
tdm_override_reason: tdmOverrideReason.trim(),
}),
})
if (!startRes.ok) throw new Error(`Pruefung konnte nicht gestartet werden: ${startRes.status}`)
@@ -210,9 +217,11 @@ export function ComplianceCheckTab() {
if (!pollRes.ok) { attempts++; continue }
const pollData = await pollRes.json()
if (pollData.progress) setProgress(pollData.progress)
if (typeof pollData.progress_pct === 'number') setProgressPct(pollData.progress_pct)
if (pollData.status === 'completed' && pollData.result) {
setResults(pollData.result)
setProgress('')
setProgressPct(0)
localStorage.setItem(STORAGE_KEY_RESULTS, JSON.stringify(pollData.result))
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
@@ -229,9 +238,9 @@ export function ComplianceCheckTab() {
localStorage.setItem(STORAGE_KEY_HISTORY, JSON.stringify(updated))
break
}
if (pollData.status === 'failed') {
if (['failed', 'skipped_tdm'].includes(pollData.status)) {
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
throw new Error(pollData.error || 'Pruefung fehlgeschlagen')
throw new Error(pollData.error || (pollData.status === 'skipped_tdm' ? 'TDM-Vorbehalt' : 'Pruefung fehlgeschlagen'))
}
attempts++
}
@@ -242,6 +251,7 @@ export function ComplianceCheckTab() {
} catch (e) {
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
setProgress('')
setProgressPct(0)
} finally {
setLoading(false)
}
@@ -313,10 +323,15 @@ export function ComplianceCheckTab() {
</span>
</div>
<div className="bg-amber-50/60 border border-amber-200 rounded-lg p-3 space-y-2">
<label className="flex items-start gap-2 cursor-pointer"><input type="checkbox" checked={tdmOverride} onChange={e => setTdmOverride(e.target.checked)} className="mt-0.5 accent-amber-600" /><span className="text-xs text-amber-900"><strong>Schriftliche Crawl-Erlaubnis vorhanden</strong> uebergeht TDM-Vorbehalte (robots.txt / ai.txt)</span></label>
{tdmOverride && <input type="text" value={tdmOverrideReason} onChange={e => setTdmOverrideReason(e.target.value)} placeholder="z.B. Auftragsbeziehung Safetykon GmbH, Email Hr. X vom 18.05.2026" className="w-full px-3 py-2 text-xs border border-amber-300 rounded bg-white" />}
{tdmOverride && tdmOverrideReason.trim().length < 10 && <p className="text-[10px] text-amber-700">Pflicht: Reason mit min. 10 Zeichen (Audit-Spur).</p>}
</div>
{/* Submit button */}
<button
onClick={handleSubmit}
disabled={loading || filledCount === 0}
disabled={loading || filledCount === 0 || (tdmOverride && tdmOverrideReason.trim().length < 10)}
className="w-full px-4 py-3 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50 transition-colors text-sm flex items-center justify-center gap-2"
>
{loading ? (
@@ -334,12 +349,21 @@ export function ComplianceCheckTab() {
{/* Progress */}
{progress && (
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm text-purple-700 flex items-center gap-3">
<svg className="animate-spin w-4 h-4 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{progress}
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm text-purple-700 space-y-2">
<div className="flex items-center gap-3">
<svg className="animate-spin w-4 h-4 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<span className="flex-1">{progress}</span>
<span className="text-xs font-mono text-purple-600 tabular-nums">{progressPct}%</span>
</div>
<div className="h-1.5 bg-purple-100 rounded-full overflow-hidden">
<div
className="h-full bg-purple-500 rounded-full transition-all duration-500 ease-out"
style={{ width: `${Math.max(2, progressPct)}%` }}
/>
</div>
</div>
)}
@@ -439,13 +463,14 @@ export function ComplianceCheckTab() {
<ChecklistView results={results.results} />
{/* Email status */}
{/* Email + Migration + Full-audit */}
{results.email_status && (
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${results.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status}
</div>
)}
{results.check_id && <MigrationPanel checkId={results.check_id} />}
</div>
)}
@@ -0,0 +1,194 @@
'use client'
import { useState } from 'react'
interface BannerFlag {
level: 'ERROR' | 'WARNING' | 'INFO'
vendor: string
issue: string
message: string
}
interface BannerPreview {
config: { categories: { id: string; cookies: { name: string }[] }[] }
flags: BannerFlag[]
summary: {
vendors_total: number
vendors_with_no_cookies: number
cookies_total: number
categories: Record<string, number>
flags_error: number
flags_warning: number
flags_info: number
}
}
interface DocumentPreview {
check_id: string
vendor_count: number
templates: Record<string, {
templateType: string
initialContent: string
suggested_template_search?: string
}>
}
type Mode = 'banner' | 'documents'
export function MigrationPanel({ checkId }: { checkId: string }) {
const [open, setOpen] = useState(false)
const [mode, setMode] = useState<Mode>('banner')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [banner, setBanner] = useState<BannerPreview | null>(null)
const [docs, setDocs] = useState<DocumentPreview | null>(null)
async function loadPreview(next: Mode) {
setMode(next)
setOpen(true)
setError(null)
setLoading(true)
try {
const path = next === 'banner'
? `/api/sdk/v1/agent/migration/${checkId}/banner-preview`
: `/api/sdk/v1/agent/migration/${checkId}/document-preview`
const r = await fetch(path)
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const data = await r.json()
if (next === 'banner') setBanner(data)
else setDocs(data)
} catch (e) {
setError(e instanceof Error ? e.message : 'Preview-Ladefehler')
} finally {
setLoading(false)
}
}
return (
<>
<div className="mt-3 flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<button onClick={() => loadPreview('banner')}
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-purple-50 text-purple-700 border border-purple-200 hover:bg-purple-100">
Cookie-Banner uebernehmen
</button>
<button onClick={() => loadPreview('documents')}
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-amber-50 text-amber-700 border border-amber-200 hover:bg-amber-100">
Dokumente vorbefuellen
</button>
</div>
<a href={`/sdk/agent/audit/${checkId}`} target="_blank" rel="noopener"
className="text-xs text-blue-700 hover:text-blue-900 underline">
Voll-Audit oeffnen (alle MCs) &rarr;
</a>
</div>
{open && (
<div className="fixed inset-0 z-50 bg-black/40 flex items-start justify-center p-6 overflow-y-auto">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl p-6 mt-12">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">
{mode === 'banner' ? 'Cookie-Banner Migration' : 'Dokument-Vorbefuellung'}
</h3>
<button onClick={() => setOpen(false)}
className="text-gray-400 hover:text-gray-600 text-xl leading-none">&times;</button>
</div>
{loading && <div className="text-sm text-gray-500">Lade Preview ...</div>}
{error && <div className="text-sm text-red-600">Fehler: {error}</div>}
{!loading && !error && mode === 'banner' && banner && (
<BannerPreviewBody data={banner} />
)}
{!loading && !error && mode === 'documents' && docs && (
<DocumentPreviewBody data={docs} />
)}
<div className="mt-5 flex justify-end gap-2">
<button onClick={() => setOpen(false)}
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 hover:bg-gray-50">
Schliessen
</button>
<a href={mode === 'banner' ? '/sdk/einwilligungen' : '/sdk/document-generator'}
className="px-3 py-1.5 text-sm rounded-lg bg-purple-600 text-white hover:bg-purple-700">
Im Editor oeffnen
</a>
</div>
</div>
</div>
)}
</>
)
}
function BannerPreviewBody({ data }: { data: BannerPreview }) {
const { summary, flags, config } = data
return (
<div className="space-y-4 text-sm">
<div className="grid grid-cols-3 gap-3">
<Stat label="Anbieter" value={summary.vendors_total} />
<Stat label="Cookies" value={summary.cookies_total} />
<Stat label="Kategorien" value={Object.values(summary.categories).filter(n => n > 0).length} />
</div>
<div className="grid grid-cols-3 gap-3">
<Stat label="Fehler" value={summary.flags_error} tone="red" />
<Stat label="Warnungen" value={summary.flags_warning} tone="amber" />
<Stat label="Hinweise" value={summary.flags_info} tone="gray" />
</div>
<div>
<h4 className="font-medium text-gray-700 mb-1">Kategorien</h4>
<ul className="text-xs text-gray-600 space-y-0.5">
{config.categories.map(c => (
<li key={c.id}>{c.id}: {c.cookies.length} Cookie(s)</li>
))}
</ul>
</div>
{flags.length > 0 && (
<div>
<h4 className="font-medium text-gray-700 mb-1">Pruefpunkte</h4>
<ul className="text-xs space-y-0.5 max-h-48 overflow-y-auto">
{flags.map((f, i) => (
<li key={i} className={f.level === 'ERROR' ? 'text-red-700' : f.level === 'WARNING' ? 'text-amber-700' : 'text-gray-600'}>
[{f.level}] {f.vendor}: {f.message}
</li>
))}
</ul>
</div>
)}
</div>
)
}
function DocumentPreviewBody({ data }: { data: DocumentPreview }) {
return (
<div className="space-y-4 text-sm">
<div className="text-xs text-gray-600">
{data.vendor_count} Anbieter werden in {Object.keys(data.templates).length} Vorlagen eingespielt.
</div>
{Object.entries(data.templates).map(([key, tpl]) => (
<div key={key} className="border border-gray-200 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-gray-800">{tpl.templateType}</h4>
{tpl.suggested_template_search && (
<span className="text-xs text-gray-500">Vorschlag: {tpl.suggested_template_search}</span>
)}
</div>
<pre className="text-xs bg-gray-50 rounded p-2 max-h-48 overflow-auto whitespace-pre-wrap">
{tpl.initialContent.slice(0, 1200)}{tpl.initialContent.length > 1200 ? '\n…' : ''}
</pre>
</div>
))}
</div>
)
}
function Stat({ label, value, tone = 'gray' }: { label: string; value: number; tone?: 'red' | 'amber' | 'gray' }) {
const color = tone === 'red' ? 'text-red-700' : tone === 'amber' ? 'text-amber-700' : 'text-gray-800'
return (
<div className="border border-gray-200 rounded-lg p-2 text-center">
<div className={`text-lg font-semibold ${color}`}>{value}</div>
<div className="text-xs text-gray-500">{label}</div>
</div>
)
}
@@ -0,0 +1,275 @@
'use client'
import React, { useEffect, useMemo, useState } from 'react'
type Finding = {
id: number
source_type: string
doc_type: string
severity: string
status: string
regulation: string
label: string
hint: string
action_recipe: Record<string, string>
anchor_excerpt: string
anchor_conf: number
vendor_name: string
category: string
payload: Record<string, unknown>
}
type Summary = {
total: number
by_source: Record<string, number>
by_severity: Record<string, number>
by_status: Record<string, number>
by_doc_type: Record<string, number>
}
type Resp = {
found: boolean
summary: Summary
count: number
findings: Finding[]
}
const SOURCE_LABEL: Record<string, string> = {
all: 'Alle Quellen',
mc: 'Master-Controls',
pflichtangabe: 'Pflichtangaben',
vendor: 'Vendor-Findings',
redundanz: 'Redundanzen',
}
const SEVERITY_COLOR: Record<string, string> = {
CRITICAL: 'bg-red-600 text-white',
HIGH: 'bg-red-100 text-red-800',
MEDIUM: 'bg-amber-100 text-amber-800',
LOW: 'bg-blue-100 text-blue-800',
INFO: 'bg-gray-100 text-gray-600',
}
const STATUS_LABEL: Record<string, string> = {
failed: 'Fail',
passed: 'Pass',
skipped: 'Skip',
na: 'N/A',
info: 'Info',
}
const SEVERITY_OPTS = ['all', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO']
const STATUS_OPTS = ['all', 'failed', 'passed', 'skipped', 'na', 'info']
export default function FindingsTab({ checkId }: { checkId: string }) {
const [data, setData] = useState<Resp | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [source, setSource] = useState('all')
const [severity, setSeverity] = useState('all')
const [docType, setDocType] = useState('all')
const [status, setStatus] = useState('failed')
const [q, setQ] = useState('')
const [expanded, setExpanded] = useState<number | null>(null)
useEffect(() => {
let cancelled = false
setLoading(true)
const qs = new URLSearchParams({
source, severity, doc_type: docType, status, q, limit: '1500',
}).toString()
fetch(`/api/sdk/v1/agent/findings/${checkId}?${qs}`)
.then(r => r.json())
.then(d => { if (!cancelled) setData(d) })
.catch(e => { if (!cancelled) setError(String(e)) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [checkId, source, severity, docType, status, q])
const docTypes = useMemo(
() => Object.keys(data?.summary?.by_doc_type ?? {}).filter(d => d !== '-').sort(),
[data],
)
const csvExport = () => {
const rows = data?.findings ?? []
const head = ['Quelle', 'Doc', 'Severity', 'Status', 'Regulation', 'Label', 'Vendor', 'Hint']
const lines = [head.join(',')]
for (const r of rows) {
const cells = [
r.source_type, r.doc_type, r.severity, r.status,
r.regulation, r.label, r.vendor_name, r.hint,
].map(c => `"${String(c ?? '').replace(/"/g, '""').replace(/\n/g, ' ')}"`)
lines.push(cells.join(','))
}
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `findings-${checkId}.csv`
a.click()
URL.revokeObjectURL(url)
}
if (loading && !data) return <div className="p-6 text-sm text-gray-500">Lade Voll-Audit</div>
if (error) return <div className="p-6 text-sm text-red-600">Fehler: {error}</div>
if (!data?.found) {
return (
<div className="p-6 text-sm text-gray-500">
Keine unified findings für diesen Run gespeichert (alter Run vor P5?).
</div>
)
}
const sum = data.summary
const findings = data.findings
return (
<div className="space-y-4">
{/* Summary Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
{Object.entries(SOURCE_LABEL).filter(([k]) => k !== 'all').map(([k, label]) => {
const count = sum.by_source?.[k] ?? 0
return (
<button key={k}
onClick={() => setSource(source === k ? 'all' : k)}
className={`text-left rounded-lg border px-3 py-2 transition ${
source === k
? 'border-blue-500 bg-blue-50 text-blue-900'
: 'border-gray-200 hover:border-gray-300 bg-white'
}`}>
<div className="text-[10px] uppercase tracking-wide text-gray-500">{label}</div>
<div className="text-lg font-semibold">{count}</div>
</button>
)
})}
</div>
{/* Filter row */}
<div className="flex flex-wrap gap-2 items-center text-xs">
<select value={severity} onChange={e => setSeverity(e.target.value)}
className="border border-gray-200 rounded px-2 py-1">
{SEVERITY_OPTS.map(s => (
<option key={s} value={s}>
{s === 'all' ? 'Alle Severities' : s}
{s !== 'all' && sum.by_severity?.[s] != null ? ` (${sum.by_severity[s]})` : ''}
</option>
))}
</select>
<select value={status} onChange={e => setStatus(e.target.value)}
className="border border-gray-200 rounded px-2 py-1">
{STATUS_OPTS.map(s => (
<option key={s} value={s}>
{s === 'all' ? 'Alle Status' : STATUS_LABEL[s] ?? s}
{s !== 'all' && sum.by_status?.[s] != null ? ` (${sum.by_status[s]})` : ''}
</option>
))}
</select>
<select value={docType} onChange={e => setDocType(e.target.value)}
className="border border-gray-200 rounded px-2 py-1">
<option value="all">Alle Doc-Types</option>
{docTypes.map(d => (
<option key={d} value={d}>{d} ({sum.by_doc_type?.[d] ?? 0})</option>
))}
</select>
<input value={q} onChange={e => setQ(e.target.value)}
placeholder="Suche Label / Anbieter…"
className="border border-gray-200 rounded px-2 py-1 min-w-[180px]" />
<button onClick={csvExport}
className="ml-auto border border-gray-200 hover:border-gray-300 rounded px-2 py-1">
CSV exportieren
</button>
<span className="text-gray-500">{data.count} Treffer</span>
</div>
{/* Findings table */}
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-gray-50 text-gray-600">
<tr>
<th className="px-3 py-2 text-left">Quelle</th>
<th className="px-3 py-2 text-left">Doc</th>
<th className="px-3 py-2 text-left">Sev</th>
<th className="px-3 py-2 text-left">Status</th>
<th className="px-3 py-2 text-left">Finding</th>
</tr>
</thead>
<tbody>
{findings.map(f => (
<React.Fragment key={f.id}>
<tr className="border-t cursor-pointer hover:bg-gray-50"
onClick={() => setExpanded(expanded === f.id ? null : f.id)}>
<td className="px-3 py-2 text-gray-500 capitalize">{f.source_type}</td>
<td className="px-3 py-2 text-gray-700">{f.doc_type === '-' ? '—' : f.doc_type}</td>
<td className="px-3 py-2">
<span className={`px-2 py-0.5 rounded text-[10px] font-medium ${
SEVERITY_COLOR[f.severity] || 'bg-gray-100'
}`}>{f.severity}</span>
</td>
<td className="px-3 py-2 text-gray-600">{STATUS_LABEL[f.status] ?? f.status}</td>
<td className="px-3 py-2 text-gray-900">
{f.label}
{f.vendor_name && (
<span className="ml-2 text-[10px] text-gray-400">
· {f.vendor_name}
</span>
)}
{(() => {
const rl = String(f.payload?.risk_label ?? '')
if (!rl) return null
const cls = rl === 'kritisch' ? 'bg-red-600 text-white' :
rl === 'hoch' ? 'bg-red-100 text-red-800' :
rl === 'mittel' ? 'bg-amber-100 text-amber-800' :
rl === 'gering' ? 'bg-green-50 text-green-700' :
'bg-gray-100 text-gray-500'
return <span className={`ml-2 px-1.5 py-0.5 rounded text-[10px] font-medium ${cls}`}>Risk: {rl}</span>
})()}
</td>
</tr>
{expanded === f.id && (
<tr className="bg-gray-50/50">
<td colSpan={5} className="px-3 py-3 text-xs space-y-2">
{f.hint && (
<div className="text-gray-700">{f.hint}</div>
)}
{f.action_recipe?.fix_text && (
<div className="bg-amber-50 border-l-2 border-amber-300 pl-3 py-2">
<div className="font-medium text-amber-800 mb-1">Empfehlung</div>
<div className="whitespace-pre-line text-amber-900">
{f.action_recipe.fix_text}
</div>
{f.action_recipe.where && (
<div className="text-[10px] text-amber-700 mt-1">
Einfuegen in: {f.action_recipe.where}
</div>
)}
</div>
)}
{f.anchor_excerpt && (
<div className="bg-blue-50 border-l-2 border-blue-300 pl-3 py-2">
<div className="font-medium text-blue-800 mb-1">
Fundstelle im Dokument (Konfidenz {Math.round((f.anchor_conf || 0) * 100)}%)
</div>
<div className="italic text-blue-900">"{f.anchor_excerpt}"</div>
</div>
)}
<div className="text-[10px] text-gray-400">
Source: {f.source_type} · Regulation: {f.regulation || '—'}
{f.category && ` · Kategorie: ${f.category}`}
</div>
</td>
</tr>
)}
</React.Fragment>
))}
{findings.length === 0 && (
<tr><td colSpan={5} className="px-3 py-6 text-center text-gray-400">
Keine Findings fuer die aktuellen Filter.
</td></tr>
)}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,326 @@
'use client'
import React, { useEffect, useState, useMemo } from 'react'
import { use as useUnwrap } from 'react'
import FindingsTab from './FindingsTab'
type MCRow = {
id: number
doc_type: string
mc_id: string
label: string
passed: number
skipped: number
severity: string
regulation: string
matched_text: string
hint: string
}
type ScorecardRow = {
regulation: string
total: number
passed: number
failed: number
skipped: number
pct: number
severity: Record<string, number>
}
type AuditResponse = {
found: boolean
run?: {
check_id: string
ts: string
site_name: string
base_domain: string
doc_count: number
scorecard: { by_regulation: ScorecardRow[]; totals: any }
vvt_summary: { total?: number; internal?: number; external?: number }
}
mc_count?: number
results?: MCRow[]
}
// P8: MC-Audit ist eine Checkliste, KEINE Severity-Drohung. Statt
// rotem HIGH-Badge zeigen wir die Quellen-Prioritaet (Gesetz vs.
// Behoerden-Leitlinie vs. Best-Practice) und einen 3-Tier-Status
// (erfuellt / nicht erfuellt / selbst pruefen).
const PRIORITY_BADGE: Record<string, string> = {
Gesetz: 'bg-slate-800 text-white',
'Behoerden-Leitlinie': 'bg-blue-100 text-blue-800',
'Best-Practice': 'bg-gray-100 text-gray-600',
'—': 'bg-gray-50 text-gray-400',
}
function regulationToPriority(reg: string): keyof typeof PRIORITY_BADGE {
const r = (reg || '').toLowerCase()
if (/dsgvo|gdpr|eprivacy|tdddg|tkg|bdsg|ttdsg/.test(r)) return 'Gesetz'
if (/edpb|dsk|cnil|lfdi|eugh|orientierungshilfe|leitlinie|guideline/.test(r))
return 'Behoerden-Leitlinie'
if (/iso|nist|bsi|cobit|sox/.test(r)) return 'Best-Practice'
return '—'
}
const _CONDITIONAL_RE = /\b(falls|sofern|wenn|soweit|ggf\.|gegebenenfalls)\b/i
function rowReviewStatus(r: MCRow): 'pass' | 'fail' | 'review' | 'na' {
if (r.passed) return 'pass'
if (r.skipped) return 'na'
// failed: harter Fail nur bei matched_text-Beleg ODER nicht-konditionalem Label
if (!r.matched_text && _CONDITIONAL_RE.test(r.label || '')) return 'review'
return 'fail'
}
const STATUS_FILTERS = [
{ value: 'all', label: 'Alle' },
{ value: 'fail', label: 'Nicht erfuellt' },
{ value: 'review', label: 'Selbst pruefen' },
{ value: 'pass', label: 'Erfuellt' },
{ value: 'na', label: 'Nicht anwendbar' },
] as const
export default function AuditPage(
{ params }: { params: Promise<{ checkId: string }> },
) {
const { checkId } = useUnwrap(params)
const [data, setData] = useState<AuditResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filterStatus, setFilterStatus] = useState<typeof STATUS_FILTERS[number]['value']>('fail')
const [filterReg, setFilterReg] = useState<string>('')
const [filterDoc, setFilterDoc] = useState<string>('')
const [expanded, setExpanded] = useState<number | null>(null)
const [tab, setTab] = useState<'mc' | 'all'>('all')
useEffect(() => {
let cancelled = false
setLoading(true)
fetch(`/api/sdk/v1/agent/audit/${checkId}`)
.then(r => r.json())
.then(d => { if (!cancelled) setData(d) })
.catch(e => { if (!cancelled) setError(String(e)) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [checkId])
const allRows = data?.results ?? []
const docTypes = useMemo(
() => Array.from(new Set(allRows.map(r => r.doc_type))).sort(),
[allRows],
)
const regulations = useMemo(
() => Array.from(new Set(allRows.map(r => r.regulation).filter(Boolean))).sort(),
[allRows],
)
const filtered = allRows.filter(r => {
if (filterStatus !== 'all' && rowReviewStatus(r) !== filterStatus) return false
if (filterReg && r.regulation !== filterReg) return false
if (filterDoc && r.doc_type !== filterDoc) return false
return true
})
if (loading) {
return <div className="p-6 text-sm text-gray-500">Lade Audit</div>
}
if (error || !data?.found) {
return (
<div className="p-6 text-sm text-red-600">
Audit nicht gefunden{error ? `: ${error}` : ''}.
</div>
)
}
const run = data.run!
const scorecard = run.scorecard?.by_regulation ?? []
const totals = run.scorecard?.totals ?? { total: 0, passed: 0, failed: 0, pct: 0 }
return (
<div className="space-y-6 p-6 max-w-6xl">
{/* Header */}
<div>
<h1 className="text-xl font-semibold text-gray-900">
MC-Audit: {run.site_name}
</h1>
<p className="text-xs text-gray-500 mt-1">
check_id <code className="bg-gray-100 px-1 rounded">{checkId}</code> ·{' '}
{new Date(run.ts).toLocaleString('de-DE')} · {run.doc_count} Dokumente ·{' '}
{data.mc_count} MC-Eintraege
</p>
</div>
{/* Tab switcher */}
<div className="flex gap-2 border-b border-gray-200">
{([
{ key: 'all', label: 'Voll-Audit (alle Findings)' },
{ key: 'mc', label: 'Nur MC-Scorecard' },
] as const).map(t => (
<button key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2 text-sm border-b-2 -mb-px transition ${
tab === t.key
? 'border-blue-600 text-blue-700 font-medium'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}>{t.label}</button>
))}
</div>
{tab === 'all' && <FindingsTab checkId={checkId} />}
{tab === 'mc' && <>
{/* Scorecard */}
<div className="border rounded-lg overflow-hidden">
<div className="px-4 py-3 bg-blue-50 border-b border-blue-100">
<h2 className="text-sm font-medium text-blue-900">
Compliance-Scorecard nach Regulation
<span className="ml-2 text-blue-700 font-semibold text-base">
{totals.pct}%
</span>
<span className="ml-2 text-xs text-blue-600">
({totals.passed} bestanden, {totals.failed} Fail,{' '}
{totals.skipped} skipped {totals.total} gesamt)
</span>
</h2>
</div>
<table className="w-full text-xs">
<thead className="bg-gray-50 text-gray-600">
<tr>
<th className="px-3 py-2 text-left">Regulation</th>
<th className="px-3 py-2 text-center">Passed</th>
<th className="px-3 py-2 text-center">Failed</th>
<th className="px-3 py-2 text-center">HIGH</th>
<th className="px-3 py-2 text-center">MEDIUM</th>
<th className="px-3 py-2 text-right">Score</th>
</tr>
</thead>
<tbody>
{scorecard.map(row => (
<tr key={row.regulation} className="border-t hover:bg-blue-50/30 cursor-pointer"
onClick={() => setFilterReg(row.regulation === filterReg ? '' : row.regulation)}>
<td className="px-3 py-2 font-medium">{row.regulation}</td>
<td className="px-3 py-2 text-center text-green-700">{row.passed}</td>
<td className="px-3 py-2 text-center text-red-700">{row.failed}</td>
<td className="px-3 py-2 text-center text-red-700">
{(row.severity.HIGH || 0) + (row.severity.CRITICAL || 0)}
</td>
<td className="px-3 py-2 text-center text-amber-700">
{row.severity.MEDIUM || 0}
</td>
<td className={`px-3 py-2 text-right font-semibold ${
row.pct >= 80 ? 'text-green-700' :
row.pct >= 50 ? 'text-amber-700' : 'text-red-700'
}`}>{row.pct}%</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3 items-center text-xs">
<div className="flex gap-1">
{STATUS_FILTERS.map(f => (
<button key={f.value}
onClick={() => setFilterStatus(f.value)}
className={`px-2.5 py-1 rounded-full border ${
filterStatus === f.value
? 'bg-blue-600 text-white border-blue-600'
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
}`}>{f.label}</button>
))}
</div>
<select value={filterDoc} onChange={e => setFilterDoc(e.target.value)}
className="border border-gray-200 rounded px-2 py-1">
<option value="">Alle Doc-Types</option>
{docTypes.map(d => <option key={d} value={d}>{d}</option>)}
</select>
<select value={filterReg} onChange={e => setFilterReg(e.target.value)}
className="border border-gray-200 rounded px-2 py-1">
<option value="">Alle Regulations</option>
{regulations.map(r => <option key={r} value={r}>{r}</option>)}
</select>
<span className="text-gray-500">
{filtered.length} von {allRows.length}
</span>
</div>
{/* Results */}
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-gray-50 text-gray-600">
<tr>
<th className="px-3 py-2 text-left">Status</th>
<th className="px-3 py-2 text-left">Doc</th>
<th className="px-3 py-2 text-left">Regulation</th>
<th className="px-3 py-2 text-left">MC</th>
<th className="px-3 py-2 text-left">Prioritaet</th>
</tr>
</thead>
<tbody>
{filtered.map(row => (
<React.Fragment key={row.id}>
<tr className="border-t cursor-pointer hover:bg-gray-50"
onClick={() => setExpanded(expanded === row.id ? null : row.id)}>
<td className="px-3 py-2">
{(() => {
const st = rowReviewStatus(row)
if (st === 'pass') return <span className="text-green-600" title="Erfuellt"></span>
if (st === 'na') return <span className="text-gray-400" title="Nicht anwendbar"></span>
if (st === 'review') return <span className="text-amber-600" title="Selbst pruefen">?</span>
return <span className="text-red-600" title="Nicht erfuellt"></span>
})()}
</td>
<td className="px-3 py-2 text-gray-700">{row.doc_type}</td>
<td className="px-3 py-2 text-gray-500">{row.regulation || '—'}</td>
<td className="px-3 py-2 text-gray-900">{row.label}</td>
<td className="px-3 py-2">
{(() => {
const prio = regulationToPriority(row.regulation)
return (
<span className={`px-2 py-0.5 rounded text-[10px] font-medium ${PRIORITY_BADGE[prio]}`}>
{prio}
</span>
)
})()}
</td>
</tr>
{expanded === row.id && (
<tr className="bg-gray-50/50">
<td colSpan={5} className="px-3 py-3 text-xs">
<div className="text-gray-500 mb-1">
MC-ID: <code>{row.mc_id}</code>
</div>
{row.matched_text && (
<div className="mb-2">
<span className="text-green-700 font-medium">Treffer: </span>
<span className="font-mono text-gray-700">
"{row.matched_text}"
</span>
</div>
)}
{row.hint && (
<div className="text-amber-700 bg-amber-50 border-l-2 border-amber-200 pl-2 py-1">
{row.hint}
</div>
)}
</td>
</tr>
)}
</React.Fragment>
))}
{filtered.length === 0 && (
<tr>
<td colSpan={5} className="px-3 py-6 text-center text-gray-400">
Keine MCs entsprechen den aktuellen Filtern.
</td>
</tr>
)}
</tbody>
</table>
</div>
</>}
</div>
)
}
@@ -0,0 +1,45 @@
'use client'
import Link from 'next/link'
interface Props {
/** Risk classification of the AI system. Tile is only rendered for high_risk / unacceptable. */
riskLevel: string
}
/**
* Renders a tile pointing to the BSI QUAIDAL-based data-quality control tab.
* AI Act Article 10 obligations (training-data quality) apply only to high-risk
* systems, so the tile is skipped for limited / minimal / not-applicable classes.
*/
export function Art10Tile({ riskLevel }: Props) {
if (riskLevel !== 'high_risk' && riskLevel !== 'unacceptable') return null
return (
<Link
href="/sdk/quality?category=data_quality"
className="block mt-3 p-3 rounded-lg border border-purple-200 bg-purple-50 hover:bg-purple-100 transition-colors"
>
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-full bg-purple-200 text-purple-700 flex items-center justify-center shrink-0">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V7M3 7l9 6 9-6M3 7l9-4 9 4" />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-purple-900">
Art. 10 Datenqualität (Hochrisiko-KI)
</div>
<div className="text-xs text-purple-700 mt-0.5">
BSI QUAIDAL Controls: 10 Kriterien, 15 Bausteine, 30 Maßnahmen, 140 Metriken.
Klicken zum Öffnen des Trainingsdaten-Qualität-Moduls.
</div>
</div>
<svg className="w-4 h-4 text-purple-500 shrink-0 mt-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</Link>
)
}
+2
View File
@@ -9,6 +9,7 @@ import { RiskPyramid } from './_components/RiskPyramid'
import { AddSystemForm } from './_components/AddSystemForm'
import { AISystemCard } from './_components/AISystemCard'
import DecisionTreeWizard from '@/components/sdk/ai-act/DecisionTreeWizard'
import { Art10Tile } from './_components/Art10Tile'
type TabId = 'overview' | 'decision-tree' | 'results'
@@ -136,6 +137,7 @@ function SavedResultsTab() {
Löschen
</button>
</div>
<Art10Tile riskLevel={r.high_risk_result} />
</div>
))}
</div>
@@ -8,6 +8,23 @@ import type { CanonicalControl } from '../_types'
import { EFFORT_LABELS } from '../_types'
import { SeverityBadge, StateBadge, LicenseRuleBadge } from './Badges'
// Defensive coercers: backend has rows where evidence/requirements/test_procedure/open_anchors
// are JSON-encoded strings instead of arrays. .map() on a string throws — coerce here.
function asArray<T = unknown>(v: unknown): T[] {
if (Array.isArray(v)) return v as T[]
if (typeof v === 'string' && v.trim().startsWith('[')) {
try { const p = JSON.parse(v); return Array.isArray(p) ? p : [] } catch { return [] }
}
return []
}
function asStringArray(v: unknown): string[] {
return asArray(v).map(x => typeof x === 'string' ? x : JSON.stringify(x))
}
type EvidenceItem = string | { type?: string; description?: string }
function asEvidenceArray(v: unknown): EvidenceItem[] {
return asArray<EvidenceItem>(v)
}
export function ControlDetailView({
ctrl,
onBack,
@@ -72,31 +89,31 @@ export function ControlDetailView({
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Geltungsbereich</h3>
<div className="grid grid-cols-3 gap-4">
{ctrl.scope.platforms && ctrl.scope.platforms.length > 0 && (
{asStringArray(ctrl.scope?.platforms).length > 0 && (
<div>
<p className="text-xs font-medium text-gray-500 mb-1">Plattformen</p>
<div className="flex flex-wrap gap-1">
{ctrl.scope.platforms.map(p => (
{asStringArray(ctrl.scope?.platforms).map(p => (
<span key={p} className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs">{p}</span>
))}
</div>
</div>
)}
{ctrl.scope.components && ctrl.scope.components.length > 0 && (
{asStringArray(ctrl.scope?.components).length > 0 && (
<div>
<p className="text-xs font-medium text-gray-500 mb-1">Komponenten</p>
<div className="flex flex-wrap gap-1">
{ctrl.scope.components.map(c => (
{asStringArray(ctrl.scope?.components).map(c => (
<span key={c} className="px-2 py-0.5 bg-purple-50 text-purple-700 rounded text-xs">{c}</span>
))}
</div>
</div>
)}
{ctrl.scope.data_classes && ctrl.scope.data_classes.length > 0 && (
{asStringArray(ctrl.scope?.data_classes).length > 0 && (
<div>
<p className="text-xs font-medium text-gray-500 mb-1">Datenklassen</p>
<div className="flex flex-wrap gap-1">
{ctrl.scope.data_classes.map(d => (
{asStringArray(ctrl.scope?.data_classes).map(d => (
<span key={d} className="px-2 py-0.5 bg-amber-50 text-amber-700 rounded text-xs">{d}</span>
))}
</div>
@@ -109,7 +126,7 @@ export function ControlDetailView({
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
<ol className="space-y-2">
{ctrl.requirements.map((req, i) => (
{asStringArray(ctrl.requirements).map((req, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-gray-700">
<span className="flex-shrink-0 w-5 h-5 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center text-xs font-medium mt-0.5">{i + 1}</span>
{req}
@@ -122,7 +139,7 @@ export function ControlDetailView({
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
<ol className="space-y-2">
{ctrl.test_procedure.map((step, i) => (
{asStringArray(ctrl.test_procedure).map((step, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-gray-700">
<CheckCircle2 className="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" />
{step}
@@ -135,12 +152,18 @@ export function ControlDetailView({
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Nachweisanforderungen</h3>
<div className="space-y-2">
{ctrl.evidence.map((ev, i) => (
{asEvidenceArray(ctrl.evidence).map((ev, i) => (
<div key={i} className="flex items-start gap-2 p-3 bg-gray-50 rounded-lg">
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
<div>
<span className="text-xs font-medium text-gray-500 uppercase">{ev.type}</span>
<p className="text-sm text-gray-700">{ev.description}</p>
{typeof ev === 'string' ? (
<p className="text-sm text-gray-700">{ev}</p>
) : (
<>
{ev.type && <span className="text-xs font-medium text-gray-500 uppercase">{ev.type}</span>}
<p className="text-sm text-gray-700">{ev.description ?? JSON.stringify(ev)}</p>
</>
)}
</div>
</div>
))}
@@ -152,13 +175,13 @@ export function ControlDetailView({
<div className="flex items-center gap-2 mb-3">
<BookOpen className="w-4 h-4 text-green-700" />
<h3 className="text-sm font-semibold text-green-900">Open-Source-Referenzen</h3>
<span className="text-xs text-green-600">({ctrl.open_anchors.length} Quellen)</span>
<span className="text-xs text-green-600">({asArray(ctrl.open_anchors).length} Quellen)</span>
</div>
<p className="text-xs text-green-700 mb-3">
Dieses Control basiert auf frei verfuegbarem Wissen. Alle Referenzen sind offen und oeffentlich zugaenglich.
</p>
<div className="space-y-2">
{ctrl.open_anchors.map((anchor, i) => (
{asArray<{ framework?: string; ref?: string; url?: string }>(ctrl.open_anchors).map((anchor, i) => (
<div key={i} className="flex items-start gap-3 p-2 bg-white rounded border border-green-100">
<Scale className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
@@ -180,11 +203,11 @@ export function ControlDetailView({
</section>
{/* Tags */}
{ctrl.tags.length > 0 && (
{asStringArray(ctrl.tags).length > 0 && (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Tags</h3>
<div className="flex flex-wrap gap-1.5">
{ctrl.tags.map(tag => (
{asStringArray(ctrl.tags).map(tag => (
<span key={tag} className="px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs">{tag}</span>
))}
</div>
@@ -18,6 +18,16 @@ import { ControlRegulatorySection } from './ControlRegulatorySection'
import { ControlSimilarControls } from './ControlSimilarControls'
import { ControlReviewActions } from './ControlReviewActions'
// Defensive coercer: some canonical_controls rows have evidence/tags/etc.
// as JSON-encoded strings instead of arrays. .map() on a string throws.
function toArray<T = unknown>(v: unknown): T[] {
if (Array.isArray(v)) return v as T[]
if (typeof v === 'string' && v.trim().startsWith('[')) {
try { const p = JSON.parse(v); return Array.isArray(p) ? p : [] } catch { return [] }
}
return []
}
interface SimilarControl {
control_id: string; title: string; severity: string; release_state: string;
tags: string[]; license_rule: number | null; verification_method: string | null;
@@ -186,7 +196,7 @@ export function ControlDetail({
<ControlTraceability ctrl={ctrl} traceability={traceability} loadingTrace={loadingTrace}
onNavigateToControl={onNavigateToControl} />
{!ctrl.source_citation && ctrl.open_anchors.length > 0 && (
{!ctrl.source_citation && toArray(ctrl.open_anchors).length > 0 && (
<section className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<div className="flex items-center gap-2">
<Scale className="w-4 h-4 text-amber-600" />
@@ -201,36 +211,36 @@ export function ControlDetail({
</section>
)}
{(ctrl.scope.platforms?.length || ctrl.scope.components?.length || ctrl.scope.data_classes?.length) ? (
{(toArray(ctrl.scope?.platforms).length || toArray(ctrl.scope?.components).length || toArray(ctrl.scope?.data_classes).length) ? (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Geltungsbereich</h3>
<div className="grid grid-cols-3 gap-4 text-xs">
{ctrl.scope.platforms?.length ? <div><span className="text-gray-500">Plattformen:</span> <span className="text-gray-700">{ctrl.scope.platforms.join(', ')}</span></div> : null}
{ctrl.scope.components?.length ? <div><span className="text-gray-500">Komponenten:</span> <span className="text-gray-700">{ctrl.scope.components.join(', ')}</span></div> : null}
{ctrl.scope.data_classes?.length ? <div><span className="text-gray-500">Datenklassen:</span> <span className="text-gray-700">{ctrl.scope.data_classes.join(', ')}</span></div> : null}
{toArray<string>(ctrl.scope?.platforms).length ? <div><span className="text-gray-500">Plattformen:</span> <span className="text-gray-700">{toArray<string>(ctrl.scope?.platforms).join(', ')}</span></div> : null}
{toArray<string>(ctrl.scope?.components).length ? <div><span className="text-gray-500">Komponenten:</span> <span className="text-gray-700">{toArray<string>(ctrl.scope?.components).join(', ')}</span></div> : null}
{toArray<string>(ctrl.scope?.data_classes).length ? <div><span className="text-gray-500">Datenklassen:</span> <span className="text-gray-700">{toArray<string>(ctrl.scope?.data_classes).join(', ')}</span></div> : null}
</div>
</section>
) : null}
{Array.isArray(ctrl.requirements) && ctrl.requirements.length > 0 && (
{toArray<string>(ctrl.requirements).length > 0 && (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
<ol className="list-decimal list-inside space-y-1">{ctrl.requirements.map((r, i) => <li key={i} className="text-sm text-gray-700">{r}</li>)}</ol>
<ol className="list-decimal list-inside space-y-1">{toArray<string>(ctrl.requirements).map((r, i) => <li key={i} className="text-sm text-gray-700">{r}</li>)}</ol>
</section>
)}
{Array.isArray(ctrl.test_procedure) && ctrl.test_procedure.length > 0 && (
{toArray<string>(ctrl.test_procedure).length > 0 && (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
<ol className="list-decimal list-inside space-y-1">{ctrl.test_procedure.map((s, i) => <li key={i} className="text-sm text-gray-700">{s}</li>)}</ol>
<ol className="list-decimal list-inside space-y-1">{toArray<string>(ctrl.test_procedure).map((s, i) => <li key={i} className="text-sm text-gray-700">{s}</li>)}</ol>
</section>
)}
{ctrl.evidence.length > 0 && (
{toArray(ctrl.evidence).length > 0 && (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Nachweise</h3>
<div className="space-y-2">
{ctrl.evidence.map((ev, i) => (
{toArray<string | { type?: string; description?: string }>(ctrl.evidence).map((ev, i) => (
<div key={i} className="flex items-start gap-2 text-sm text-gray-700">
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
{typeof ev === 'string' ? <div>{ev}</div> : <div><span className="font-medium">{ev.type}:</span> {ev.description}</div>}
@@ -243,9 +253,9 @@ export function ControlDetail({
<section className="grid grid-cols-3 gap-4 text-xs text-gray-500">
{ctrl.risk_score !== null && <div>Risiko-Score: <span className="text-gray-700 font-medium">{ctrl.risk_score}</span></div>}
{ctrl.implementation_effort && <div>Aufwand: <span className="text-gray-700 font-medium">{EFFORT_LABELS[ctrl.implementation_effort] || ctrl.implementation_effort}</span></div>}
{ctrl.tags.length > 0 && (
{toArray<string>(ctrl.tags).length > 0 && (
<div className="col-span-3 flex items-center gap-1 flex-wrap">
{ctrl.tags.map(t => <span key={t} className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">{t}</span>)}
{toArray<string>(ctrl.tags).map(t => <span key={t} className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">{t}</span>)}
</div>
)}
</section>
@@ -253,11 +263,11 @@ export function ControlDetail({
<section className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<BookOpen className="w-4 h-4 text-green-700" />
<h3 className="text-sm font-semibold text-green-900">Open-Source-Referenzen ({ctrl.open_anchors.length})</h3>
<h3 className="text-sm font-semibold text-green-900">Open-Source-Referenzen ({toArray(ctrl.open_anchors).length})</h3>
</div>
{ctrl.open_anchors.length > 0 ? (
{toArray(ctrl.open_anchors).length > 0 ? (
<div className="space-y-2">
{ctrl.open_anchors.map((anchor, i) => (
{toArray<{ framework?: string; ref?: string; url?: string }>(ctrl.open_anchors).map((anchor, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<ExternalLink className="w-3.5 h-3.5 text-green-600 flex-shrink-0" />
<span className="font-medium text-green-800">{anchor.framework}</span>
@@ -1,5 +1,7 @@
'use client'
import { useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { EMPTY_CONTROL } from './components/helpers'
import { ControlForm } from './components/ControlForm'
import { ControlDetail } from './components/ControlDetail'
@@ -12,6 +14,24 @@ import { BACKEND_URL } from './components/helpers'
export default function ControlLibraryPage() {
const state = useControlLibraryState()
const searchParams = useSearchParams()
// Deep-link via /sdk/control-library?control=<id>
// — e.g. from /sdk/master-controls member list.
useEffect(() => {
const cid = searchParams?.get('control')
if (!cid || state.selectedControl?.control_id === cid) return
fetch(`${BACKEND_URL}?endpoint=control&id=${encodeURIComponent(cid)}`)
.then(r => r.ok ? r.json() : null)
.then(ctrl => {
if (ctrl?.control_id) {
state.setSelectedControl(ctrl)
state.setMode('detail')
}
})
.catch(() => { /* user just sees the list */ })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams])
const {
handleCreate, handleUpdate, handleDelete, handleReview, handleBulkReject,
@@ -0,0 +1,155 @@
'use client'
import React, { useEffect, useState, useCallback, use } from 'react'
import { SeverityBadge } from '../../_components/SeverityBadge'
interface BacklogItem {
rank: number
req_id: string
title: string
category: string
severity: string
annex_anchor: string
description: string
effort_days: number
mapped_measure_names: { id: string; name: string }[]
status: string
priority_score: number
}
interface BacklogResponse {
project_id: string
classification: string | null
days_to_ce_deadline: number
deadlines: { date: string; label: string }[]
total: number
items: BacklogItem[]
}
export default function BacklogPage({
params,
}: {
params: Promise<{ projectId: string }>
}) {
const { projectId } = use(params)
const [data, setData] = useState<BacklogResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const load = useCallback(async () => {
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/backlog`, {
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000001' },
})
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])
if (loading) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-gray-500">Laedt...</p></div>
if (error) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-red-600">{error}</p></div>
if (!data) return null
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-6xl 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">Prioritaeten-Backlog</h1>
<p className="text-sm text-gray-600 mt-1">
Sortiert nach Severity × Deadline-Druck × Effort. Was du heute tust, was naechsten Sprint, was vor 11.12.2027.
</p>
</div>
{/* Deadline-Banner */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-6">
{data.deadlines.map(d => {
const days = Math.max(0, Math.round((new Date(d.date).getTime() - Date.now()) / 86400000))
const isPast = new Date(d.date).getTime() < Date.now()
return (
<div
key={d.date}
className={`rounded-xl border p-4 ${
isPast ? 'bg-gray-100 border-gray-200' :
days < 90 ? 'bg-red-50 border-red-200' :
days < 365 ? 'bg-orange-50 border-orange-200' :
'bg-blue-50 border-blue-200'
}`}
>
<div className="text-xs text-gray-500">{d.date}</div>
<div className="font-semibold text-gray-900 text-sm mt-0.5">{d.label}</div>
<div className={`text-xs mt-1 ${isPast ? 'text-gray-500' : 'text-gray-700'}`}>
{isPast ? 'bereits abgelaufen' : `noch ${days} Tage`}
</div>
</div>
)
})}
</div>
{/* Backlog */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Rang</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Anforderung</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Severity</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Score</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aufwand</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Massnahme</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{data.items.map(item => (
<tr key={item.req_id} className="hover:bg-gray-50">
<td className="px-3 py-3 text-sm font-bold text-gray-700">{item.rank}</td>
<td className="px-3 py-3">
<div className="text-sm font-medium text-gray-900">{item.title}</div>
<div className="text-xs text-gray-500">{item.category} · {item.annex_anchor}</div>
</td>
<td className="px-3 py-3"><SeverityBadge value={item.severity} /></td>
<td className="px-3 py-3 text-sm font-mono text-gray-700">{item.priority_score}</td>
<td className="px-3 py-3 text-sm text-gray-600">{item.effort_days} PT</td>
<td className="px-3 py-3 text-xs text-gray-600">
{item.mapped_measure_names.length > 0 ? (
<div className="space-y-0.5">
{item.mapped_measure_names.map(m => (
<div key={m.id} title={m.name}>
<span className="font-mono text-gray-400">{m.id}:</span> {m.name.length > 50 ? m.name.slice(0, 50) + '...' : m.name}
</div>
))}
</div>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-3 py-3">
<button
className="px-2 py-1 text-xs bg-purple-100 text-purple-800 rounded hover:bg-purple-200"
onClick={() => alert(`Jira-Export fuer ${item.req_id} — Phase-4-Feature`)}
>
Jira
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-xs text-gray-500 mt-4 text-center">
Tage bis CE-Marking-Pflicht (11.12.2027): <span className="font-semibold">{data.days_to_ce_deadline}</span>
</p>
</div>
</div>
)
}
@@ -0,0 +1,195 @@
'use client'
import React, { useEffect, useState, useCallback, use } from 'react'
interface CheckItem {
id: string
check_code: string
title: string
description: string
check_type: string
target_url: string | null
linked_req_ids: string[]
last_run_at: string | null
is_active: boolean
latest_result: { status: string; message: string; ran_at: string } | null
}
interface ChecksResponse {
project_id: string
total: number
items: CheckItem[]
}
const STATUS_STYLE: Record<string, string> = {
pass: 'bg-green-100 text-green-800',
fail: 'bg-red-100 text-red-800',
manual_review_required: 'bg-yellow-100 text-yellow-800',
}
export default function ChecksPage({
params,
}: {
params: Promise<{ projectId: string }>
}) {
const { projectId } = use(params)
const [data, setData] = useState<ChecksResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [running, setRunning] = useState<string | null>(null)
const [urlInputs, setUrlInputs] = useState<Record<string, string>>({})
const tenant = '00000000-0000-0000-0000-000000000001'
const load = useCallback(async () => {
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/checks`, {
headers: { 'X-Tenant-ID': tenant },
})
if (!res.ok) throw new Error(await res.text())
const json: ChecksResponse = await res.json()
setData(json)
const u: Record<string, string> = {}
for (const c of json.items) u[c.id] = c.target_url || ''
setUrlInputs(u)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [projectId])
useEffect(() => { load() }, [load])
const initChecks = async () => {
setError('')
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/checks`, {
method: 'POST',
headers: { 'X-Tenant-ID': tenant },
})
if (!res.ok) throw new Error(await res.text())
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Init fehlgeschlagen')
}
}
const runCheck = async (checkId: string) => {
setRunning(checkId)
setError('')
try {
const res = await fetch(`/api/sdk/v1/cra/checks/${checkId}/run`, {
method: 'POST',
headers: { 'X-Tenant-ID': tenant, 'Content-Type': 'application/json' },
body: JSON.stringify({ target_url: urlInputs[checkId] || null }),
})
if (!res.ok) throw new Error(await res.text())
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Run fehlgeschlagen')
} finally {
setRunning(null)
}
}
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">Automatisierte Checks</h1>
<p className="text-sm text-gray-600 mt-1">
CRA-typische Online-Pruefungen: security.txt, Update-Policy, TLS-Konfiguration, Vuln-Disclosure.
</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>
)}
{data && data.items.length === 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<p className="text-gray-600 mb-3">Noch keine Checks fuer dieses Projekt konfiguriert.</p>
<button
onClick={initChecks}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm font-medium"
>
Standard-CRA-Checks erstellen (6 Stueck)
</button>
</div>
)}
{data && data.items.length > 0 && (
<div className="space-y-3">
{data.items.map(c => (
<div key={c.id} 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">
<h3 className="font-semibold text-gray-900">{c.title}</h3>
<span className="text-xs text-gray-400">{c.check_code}</span>
</div>
<p className="text-sm text-gray-600 mt-1">{c.description}</p>
{c.linked_req_ids.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{c.linked_req_ids.map(r => (
<span key={r} className="px-2 py-0.5 text-xs rounded bg-blue-100 text-blue-700">{r}</span>
))}
</div>
)}
</div>
{c.latest_result && (
<span className={`px-2 py-1 text-xs rounded-full font-medium ${STATUS_STYLE[c.latest_result.status] || 'bg-gray-100 text-gray-600'}`}>
{c.latest_result.status}
</span>
)}
</div>
{(c.check_type === 'url_probe' || c.check_type === 'tls_probe' || c.check_type === 'manual_review') && (
<div className="flex items-center gap-2 mb-2">
<input
type="url"
placeholder={c.check_type === 'tls_probe' ? 'https://product.example.com' : 'https://your-product.com'}
value={urlInputs[c.id] ?? ''}
onChange={e => setUrlInputs({ ...urlInputs, [c.id]: e.target.value })}
className="flex-1 px-3 py-1.5 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-red-500"
/>
<button
onClick={() => runCheck(c.id)}
disabled={running === c.id}
className="px-3 py-1.5 bg-red-600 text-white text-sm rounded hover:bg-red-700 disabled:bg-gray-300"
>
{running === c.id ? 'Laeuft...' : 'Run'}
</button>
</div>
)}
{c.latest_result && (
<div className="mt-2 text-xs text-gray-600 bg-gray-50 rounded p-2 font-mono">
{c.latest_result.message}
<div className="text-gray-400 mt-1 text-[10px]">
Geprueft: {new Date(c.latest_result.ran_at).toLocaleString('de-DE')}
</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> Aktuell implementiert: <code>cra_security_txt</code> (HTTP) und <code>cra_tls_cert_check</code> (TLS-Handshake).
Andere Check-Typen sind als <code>manual_review_required</code> markiert der Pruefer beantwortet sie manuell.
</div>
</div>
</div>
)
}
@@ -0,0 +1,309 @@
'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>
)
}
@@ -0,0 +1,240 @@
'use client'
import React, { useEffect, useState, useCallback, use } from 'react'
import { useRouter } from 'next/navigation'
const LANGUAGES = [
{ value: '', label: '— bitte waehlen —' },
{ value: 'js', label: 'JavaScript / TypeScript' },
{ value: 'python', label: 'Python' },
{ value: 'go', label: 'Go' },
{ value: 'rust', label: 'Rust' },
{ value: 'java', label: 'Java / Kotlin' },
{ value: 'csharp', label: 'C# / .NET' },
{ value: 'cpp', label: 'C / C++' },
{ value: 'swift', label: 'Swift' },
{ value: 'mixed', label: 'Mehrere Sprachen' },
{ value: 'other', label: 'Andere' },
]
interface CRAProject {
id: string
name: string
description: string
repo_url: string | null
primary_language: string | null
has_firmware: boolean
connected_to_internet: boolean
has_software_updates: boolean
processes_personal_data: boolean
is_critical_infra_supplier: boolean
intended_use: string
}
export default function IntakePage({
params,
}: {
params: Promise<{ projectId: string }>
}) {
const { projectId } = use(params)
const router = useRouter()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [repoUrl, setRepoUrl] = useState('')
const [primaryLanguage, setPrimaryLanguage] = useState('')
const [hasFirmware, setHasFirmware] = useState(false)
const [connectedInternet, setConnectedInternet] = useState(false)
const [hasUpdates, setHasUpdates] = useState(false)
const [processesPersonal, setProcessesPersonal] = useState(false)
const [isCriticalInfra, setIsCriticalInfra] = useState(false)
const [intendedUse, setIntendedUse] = useState('')
const tenant = '00000000-0000-0000-0000-000000000001'
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()
setName(p.name)
setDescription(p.description || '')
setRepoUrl(p.repo_url || '')
setPrimaryLanguage(p.primary_language || '')
setHasFirmware(p.has_firmware)
setConnectedInternet(p.connected_to_internet)
setHasUpdates(p.has_software_updates)
setProcessesPersonal(p.processes_personal_data)
setIsCriticalInfra(p.is_critical_infra_supplier)
setIntendedUse(p.intended_use || '')
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [projectId])
useEffect(() => { load() }, [load])
const save = async () => {
setSaving(true)
setError('')
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenant },
body: JSON.stringify({
name,
description,
repo_url: repoUrl || null,
primary_language: primaryLanguage || null,
has_firmware: hasFirmware,
connected_to_internet: connectedInternet,
has_software_updates: hasUpdates,
processes_personal_data: processesPersonal,
is_critical_infra_supplier: isCriticalInfra,
intended_use: intendedUse,
status: 'scoped',
}),
})
if (!res.ok) throw new Error(await res.text())
router.push(`/sdk/cra/${projectId}/scope`)
} 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>
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-3xl 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">Intake Software-Profil</h1>
<p className="text-sm text-gray-600 mt-1">
Schritt 1 von 3 Beschreibe Software, Firmware und Connectivity. Daraus leiten wir die CRA-Klassifikation ab.
</p>
</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="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Produktname *</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
placeholder="z.B. SmartHome Gateway v3"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kurzbeschreibung</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Intended Use Zweck und Anwendungsbereich
</label>
<textarea
value={intendedUse}
onChange={e => setIntendedUse(e.target.value)}
rows={3}
placeholder="z.B. Mobile App fuer Industrieanlagen-Monitoring, oder: Password Manager fuer KMU, oder: VPN-Software fuer Mitarbeiter-Geraete"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
/>
<p className="text-xs text-gray-500 mt-1">
Wichtig fuer die Klassifikation. Erwaehne konkrete Funktionen (z.B. &quot;Firewall&quot;, &quot;Betriebssystem&quot;) wenn zutreffend.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Repo-URL (optional)</label>
<input
type="url"
value={repoUrl}
onChange={e => setRepoUrl(e.target.value)}
placeholder="https://github.com/..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Primaere Programmiersprache</label>
<select
value={primaryLanguage}
onChange={e => setPrimaryLanguage(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
>
{LANGUAGES.map(l => (
<option key={l.value} value={l.value}>{l.label}</option>
))}
</select>
</div>
</div>
<div className="border-t border-gray-200 pt-5">
<h3 className="text-sm font-medium text-gray-700 mb-3">Eigenschaften des Produkts</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{[
['hasFirmware', 'Enthaelt Firmware (Embedded/IoT)', hasFirmware, setHasFirmware],
['connectedInternet', 'Mit dem Internet verbunden', connectedInternet, setConnectedInternet],
['hasUpdates', 'Hat Software-/Firmware-Updates', hasUpdates, setHasUpdates],
['processesPersonal', 'Verarbeitet personenbezogene Daten', processesPersonal, setProcessesPersonal],
['isCriticalInfra', 'Zulieferer fuer kritische Infrastruktur', isCriticalInfra, setIsCriticalInfra],
].map(([key, label, value, setter]) => (
<label key={key as string} className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={value as boolean}
onChange={e => (setter as (b: boolean) => void)(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-red-600 focus:ring-red-500"
/>
<span className="text-sm text-gray-700">{label as string}</span>
</label>
))}
</div>
</div>
<div className="flex gap-3 pt-3">
<button
onClick={() => router.push(`/sdk/cra/${projectId}`)}
disabled={saving}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Abbrechen
</button>
<button
onClick={save}
disabled={saving || !name.trim()}
className="flex-1 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 disabled:bg-gray-300"
>
{saving ? 'Speichert...' : 'Weiter zum Scope-Check →'}
</button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,168 @@
'use client'
import React, { useEffect, useState, useCallback, use } from 'react'
interface MonitoringData {
project_id: string
deadlines: { date: string; label: string }[]
summary: {
active_vulns: number
critical_vulns: number
high_vulns: number
breached_24h_reporting: number
breached_72h_reporting: number
sbom_versions: number
configured_checks: number
}
post_market_checklist: { item: string; done: boolean; href_suffix: string }[]
}
export default function MonitoringPage({
params,
}: {
params: Promise<{ projectId: string }>
}) {
const { projectId } = use(params)
const [data, setData] = useState<MonitoringData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const load = useCallback(async () => {
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/monitoring`, {
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000001' },
})
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])
if (loading) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-gray-500">Laedt...</p></div>
if (error) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-red-600">{error}</p></div>
if (!data) return null
const completeness = data.post_market_checklist.filter(c => c.done).length
const totalChecks = data.post_market_checklist.length
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">Post-Market Monitoring</h1>
<p className="text-sm text-gray-600 mt-1">
CRA-Stichtage + Vuln-Reporting-Compliance + Post-Market-Pflichten.
</p>
</div>
{/* CRA-Stichtage */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-6">
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">CRA-Stichtage</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{data.deadlines.map(d => {
const target = new Date(d.date).getTime()
const days = Math.round((target - Date.now()) / 86400000)
const isPast = days < 0
const isSoon = days >= 0 && days < 90
const styles = isPast ? 'bg-gray-100 border-gray-200' :
isSoon ? 'bg-red-50 border-red-200' :
days < 365 ? 'bg-orange-50 border-orange-200' :
'bg-blue-50 border-blue-200'
return (
<div key={d.date} className={`rounded-lg border p-4 ${styles}`}>
<div className="text-xs text-gray-500">{d.date}</div>
<div className="font-semibold text-gray-900 text-sm mt-0.5">{d.label}</div>
<div className="text-xs mt-1 text-gray-700">
{isPast ? `vor ${-days} Tagen` : `noch ${days} Tage`}
</div>
</div>
)
})}
</div>
</div>
{/* Vuln-Reporting Compliance Banner */}
{(data.summary.breached_24h_reporting > 0 || data.summary.breached_72h_reporting > 0) && (
<div className="bg-red-50 border-2 border-red-300 rounded-xl p-5 mb-6">
<h3 className="text-sm font-bold text-red-900 uppercase tracking-wide mb-2"> CRA-Pflichten verletzt</h3>
{data.summary.breached_24h_reporting > 0 && (
<p className="text-sm text-red-800">
<span className="font-semibold">{data.summary.breached_24h_reporting}</span> Schwachstelle(n) ohne 24h-Fruehwarnung an ENISA Art. 14(2)(a) CRA.
</p>
)}
{data.summary.breached_72h_reporting > 0 && (
<p className="text-sm text-red-800 mt-1">
<span className="font-semibold">{data.summary.breached_72h_reporting}</span> Schwachstelle(n) ohne 72h-Detailbericht Art. 14(2)(b) CRA.
</p>
)}
<a href={`/sdk/cra/${projectId}/vuln`} className="inline-block mt-2 text-sm text-red-700 underline font-medium">
Zu den Schwachstellen
</a>
</div>
)}
{/* Summary Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<SummaryCard label="Aktive Vulns" value={data.summary.active_vulns} subtitle={`${data.summary.critical_vulns} Critical · ${data.summary.high_vulns} High`} color="blue" />
<SummaryCard label="SBOM-Versionen" value={data.summary.sbom_versions} subtitle={data.summary.sbom_versions === 0 ? 'noch keine' : 'hochgeladen'} color={data.summary.sbom_versions > 0 ? 'green' : 'gray'} />
<SummaryCard label="Aktive Checks" value={data.summary.configured_checks} subtitle={data.summary.configured_checks === 0 ? 'init noetig' : 'konfiguriert'} color={data.summary.configured_checks > 0 ? 'green' : 'gray'} />
<SummaryCard label="Post-Market" value={`${completeness}/${totalChecks}`} subtitle="erfuellt" color={completeness === totalChecks ? 'green' : 'orange'} />
</div>
{/* Post-Market Checklist */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-6">
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Post-Market-Pflichten</h3>
<ul className="space-y-2">
{data.post_market_checklist.map((c, i) => (
<li key={i} className="flex items-center gap-3">
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
c.done ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-400'
}`}>
{c.done ? '✓' : '○'}
</span>
<span className={`text-sm ${c.done ? 'text-gray-700' : 'text-gray-900 font-medium'}`}>{c.item}</span>
{!c.done && (
<a
href={`/sdk/cra/${projectId}/${c.href_suffix}`}
className="ml-auto text-xs text-blue-600 hover:underline"
>
Erledigen
</a>
)}
</li>
))}
</ul>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 text-sm text-blue-900">
<strong>Hinweis:</strong> Diese Seite aggregiert CRA-Pflichten aus SBOM, Checks und Vulnerability-Tracker. Die Reporting-Pflichten 24h/72h gelten ab CRA Art. 14(2) verletzte Fristen erscheinen als rotes Banner.
</div>
</div>
</div>
)
}
function SummaryCard({ label, value, subtitle, color }: { label: string; value: number | string; subtitle: string; color: 'blue' | 'red' | 'green' | 'orange' | 'gray' }) {
const bg = {
blue: 'bg-blue-50 border-blue-200 text-blue-700',
red: 'bg-red-50 border-red-200 text-red-700',
green: 'bg-green-50 border-green-200 text-green-700',
orange: 'bg-orange-50 border-orange-200 text-orange-700',
gray: 'bg-gray-50 border-gray-200 text-gray-600',
}[color]
return (
<div className={`rounded-xl border p-3 ${bg}`}>
<p className="text-xs uppercase tracking-wide">{label}</p>
<p className="text-2xl font-bold mt-1">{value}</p>
<p className="text-xs mt-0.5 opacity-80">{subtitle}</p>
</div>
)
}
@@ -0,0 +1,315 @@
'use client'
import React, { useEffect, useState, useCallback, use } from 'react'
import { useRouter } from 'next/navigation'
import { ClassificationBadge } from '../_components/ClassificationBadge'
import { StatusStepper } from '../_components/StatusStepper'
import { SeverityBadge } from '../_components/SeverityBadge'
interface CRAProject {
id: string
name: string
description: string
cra_classification: string | null
classification_rationale: string[]
conformity_path: string | null
status: string
intended_use: string
repo_url: string | null
primary_language: string | null
created_at: string
updated_at: string
}
interface BacklogItem {
rank: number
req_id: string
title: string
category: string
severity: string
effort_days: number
priority_score: number
}
interface BacklogData {
days_to_ce_deadline: number
deadlines: { date: string; label: string }[]
total: number
items: BacklogItem[]
}
const PATH_LABEL: Record<string, string> = {
self_assessment: 'Modul A — Self-Assessment',
harmonized_standard: 'Modul B — Harmonized Standard',
eucc: 'Modul H — EUCC',
notified_body: 'Modul C — Notified Body',
}
export default function CRAProjectDashboard({
params,
}: {
params: Promise<{ projectId: string }>
}) {
const { projectId } = use(params)
const router = useRouter()
const [project, setProject] = useState<CRAProject | null>(null)
const [backlog, setBacklog] = useState<BacklogData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const load = useCallback(async () => {
try {
const headers = { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000001' }
const [projRes, backlogRes] = await Promise.all([
fetch(`/api/sdk/v1/cra/projects/${projectId}`, { headers }),
fetch(`/api/sdk/v1/cra/projects/${projectId}/backlog`, { headers }),
])
if (!projRes.ok) throw new Error(await projRes.text())
setProject(await projRes.json())
if (backlogRes.ok) {
setBacklog(await backlogRes.json())
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [projectId])
useEffect(() => { load() }, [load])
if (loading) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-gray-500">Laedt...</p></div>
if (error) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-red-600">{error}</p></div>
if (!project) return null
const nextStep =
project.status === 'draft' ? { href: `/sdk/cra/${projectId}/intake`, label: 'Intake starten' } :
project.status === 'scoped' ? { href: `/sdk/cra/${projectId}/scope`, label: 'Scope-Check ausfuehren' } :
project.status === 'classified' ? { href: `/sdk/cra/${projectId}/path`, label: 'Konformitaetspfad waehlen' } :
project.status === 'path_selected' ? { href: null, label: 'Phase 2 (Requirements) folgt' } :
{ href: null, label: '' }
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-5xl mx-auto px-4">
<div className="mb-4">
<a href="/sdk/cra" className="text-sm text-blue-600 hover:underline">&larr; Alle CRA-Projekte</a>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex-1 min-w-0">
<h1 className="text-2xl font-bold text-gray-900">{project.name}</h1>
{project.description && (
<p className="text-gray-600 mt-1">{project.description}</p>
)}
</div>
<ClassificationBadge value={project.cra_classification} size="lg" />
</div>
<StatusStepper current={project.status} />
</div>
{/* KPI Cards */}
{backlog && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<KPICard
label="Annex-I Requirements"
value={backlog.total}
hint="aus Migration 059"
color="blue"
/>
<KPICard
label="Critical-Anforderungen"
value={backlog.items.filter(i => i.severity === 'CRITICAL').length}
hint={`+ ${backlog.items.filter(i => i.severity === 'HIGH').length} High`}
color="red"
/>
<KPICard
label="Tage bis CE-Pflicht"
value={backlog.days_to_ce_deadline}
hint="11.12.2027"
color={backlog.days_to_ce_deadline < 365 ? 'orange' : 'green'}
/>
<KPICard
label="Compliance"
value="0%"
hint="Evidence in Phase 3"
color="gray"
/>
</div>
)}
{/* Top-10 Backlog-Snippet */}
{backlog && backlog.items.length > 0 && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Top-10 Prioritaeten</h3>
<a href={`/sdk/cra/${projectId}/backlog`} className="text-xs text-blue-600 hover:underline">
Vollstaendiges Backlog
</a>
</div>
<table className="w-full text-sm">
<thead className="text-xs text-gray-500 uppercase">
<tr>
<th className="text-left py-1">#</th>
<th className="text-left py-1">Anforderung</th>
<th className="text-left py-1">Severity</th>
<th className="text-left py-1">Aufwand</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{backlog.items.slice(0, 10).map(item => (
<tr key={item.req_id} className="hover:bg-gray-50">
<td className="py-2 text-gray-500">{item.rank}</td>
<td className="py-2">
<div className="font-medium text-gray-900">{item.title}</div>
<div className="text-xs text-gray-500">{item.category}</div>
</td>
<td className="py-2"><SeverityBadge value={item.severity} /></td>
<td className="py-2 text-gray-600">{item.effort_days} PT</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-7 gap-2 mb-6">
<a href={`/sdk/cra/${projectId}/requirements`} className="text-center py-2 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 text-xs font-medium">Requirements</a>
<a href={`/sdk/cra/${projectId}/backlog`} className="text-center py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 text-xs font-medium">Backlog</a>
<a href={`/sdk/cra/${projectId}/sbom`} className="text-center py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 text-xs font-medium">SBOM</a>
<a href={`/sdk/cra/${projectId}/checks`} className="text-center py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 text-xs font-medium">Checks</a>
<a href={`/sdk/cra/${projectId}/vuln`} className="text-center py-2 bg-orange-100 text-orange-700 rounded-lg hover:bg-orange-200 text-xs font-medium">Vulns (CVD)</a>
<a href={`/sdk/cra/${projectId}/monitoring`} className="text-center py-2 bg-yellow-100 text-yellow-700 rounded-lg hover:bg-yellow-200 text-xs font-medium">Monitoring</a>
<a href={`/sdk/cra/${projectId}/documents`} className="text-center py-2 bg-teal-100 text-teal-700 rounded-lg hover:bg-teal-200 text-xs font-medium">Dokumente</a>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<InfoCard
title="Intake"
content={
project.status === 'draft'
? <span className="text-gray-400">Noch nicht erfasst</span>
: (
<div className="space-y-1 text-sm">
{project.intended_use && <div><span className="text-gray-500">Use:</span> {project.intended_use}</div>}
{project.primary_language && <div><span className="text-gray-500">Sprache:</span> {project.primary_language}</div>}
{project.repo_url && <div><span className="text-gray-500">Repo:</span> {project.repo_url}</div>}
</div>
)
}
actionHref={`/sdk/cra/${projectId}/intake`}
actionLabel={project.status === 'draft' ? 'Erfassen' : 'Bearbeiten'}
/>
<InfoCard
title="Klassifikation"
content={
project.cra_classification ? (
<div>
<ClassificationBadge value={project.cra_classification} size="md" />
{project.classification_rationale?.length > 0 && (
<ul className="mt-2 text-xs text-gray-600 list-disc list-inside space-y-0.5">
{project.classification_rationale.map((r, i) => <li key={i}>{r}</li>)}
</ul>
)}
</div>
) : <span className="text-gray-400">Scope-Check ausstehend</span>
}
actionHref={`/sdk/cra/${projectId}/scope`}
actionLabel={project.cra_classification ? 'Neu pruefen' : 'Pruefen'}
/>
<InfoCard
title="Konformitaetspfad"
content={
project.conformity_path
? <span className="font-medium text-purple-700">{PATH_LABEL[project.conformity_path] || project.conformity_path}</span>
: <span className="text-gray-400">Noch nicht gewaehlt</span>
}
actionHref={project.cra_classification ? `/sdk/cra/${projectId}/path` : null}
actionLabel={project.conformity_path ? 'Aendern' : 'Waehlen'}
/>
<InfoCard
title="Status"
content={
<div className="space-y-1 text-sm">
<div><span className="text-gray-500">Aktuell:</span> {project.status}</div>
<div className="text-xs text-gray-400">Aktualisiert: {new Date(project.updated_at).toLocaleString('de-DE')}</div>
</div>
}
actionHref={null}
actionLabel=""
/>
</div>
{nextStep.href && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-5 flex items-center justify-between">
<div>
<h3 className="font-semibold text-blue-900">Naechster Schritt</h3>
<p className="text-sm text-blue-700 mt-1">{nextStep.label}</p>
</div>
<button
onClick={() => router.push(nextStep.href!)}
className="px-5 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
>
Weiter
</button>
</div>
)}
{!nextStep.href && nextStep.label && (
<div className="bg-gray-100 border border-gray-200 rounded-xl p-5 text-center text-gray-600">
{nextStep.label}
</div>
)}
</div>
</div>
)
}
function InfoCard({
title, content, actionHref, actionLabel,
}: {
title: string
content: React.ReactNode
actionHref: string | null
actionLabel: string
}) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">{title}</h3>
{actionHref && actionLabel && (
<a href={actionHref} className="text-xs text-blue-600 hover:underline">{actionLabel}</a>
)}
</div>
<div>{content}</div>
</div>
)
}
function KPICard({
label, value, hint, color,
}: {
label: string
value: string | number
hint: string
color: 'blue' | 'red' | 'orange' | 'green' | 'gray'
}) {
const colors = {
blue: 'bg-blue-50 border-blue-200 text-blue-700',
red: 'bg-red-50 border-red-200 text-red-700',
orange: 'bg-orange-50 border-orange-200 text-orange-700',
green: 'bg-green-50 border-green-200 text-green-700',
gray: 'bg-gray-50 border-gray-200 text-gray-700',
}
return (
<div className={`rounded-xl border p-4 ${colors[color]}`}>
<p className="text-xs text-gray-600 uppercase tracking-wide">{label}</p>
<p className="text-2xl font-bold mt-1">{value}</p>
<p className="text-xs mt-1 opacity-80">{hint}</p>
</div>
)
}
@@ -0,0 +1,256 @@
'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[]
}
const PATHS: PathOption[] = [
{
id: 'self_assessment',
modul: 'Modul A',
title: 'Self-Assessment',
short: 'Konformitaetsbewertung durch interne Pruefung',
details: [
'Hersteller fuehrt Konformitaetsbewertung selbst durch',
'Geringster externer Aufwand, schnelle Umsetzung',
'Default fuer Standard-Produkte',
'Technische Dokumentation + DoC bleibt Pflicht',
],
},
{
id: 'harmonized_standard',
modul: 'Modul B',
title: 'Harmonized Standard',
short: 'Konformitaetsvermutung durch harmonisierte Norm',
details: [
'Anwendung einer harmonisierten EU-Norm (z.B. DIN EN 40000-1-2 Entwurf)',
'Konformitaetsvermutung gemaess EU-Recht',
'Geringeres Audit-Risiko',
'Empfohlen bei verfuegbarer harmonisierter Norm',
],
},
{
id: 'eucc',
modul: 'Modul H',
title: 'EUCC Zertifizierung',
short: 'European Cybersecurity Certification Scheme',
details: [
'ENISA-EUCC-Zertifizierung (Common Criteria-basiert)',
'Hoechste Anerkennung in EU + Drittstaaten',
'Hoher Aufwand, ITSEF-Pruefung erforderlich',
'Pflicht bei einigen Important Class II-Produkten',
],
},
{
id: 'notified_body',
modul: 'Modul C',
title: 'Notified Body Assessment',
short: 'Drittprueforganisation pruefn die Konformitaet',
details: [
'Externe Bewertung durch akkreditierte Stelle',
'PFLICHT fuer Critical-Produkte (Annex IV)',
'Hoechste Auditierbarkeit + Vertrauen',
'Laufzeit + Kosten am hoechsten',
],
},
]
const ALLOWED: Record<string, PathId[]> = {
STANDARD: ['self_assessment', 'harmonized_standard', 'eucc', 'notified_body'],
IMPORTANT_I: ['self_assessment', 'harmonized_standard', 'eucc', 'notified_body'],
IMPORTANT_II: ['harmonized_standard', 'eucc', 'notified_body'],
CRITICAL: ['notified_body'],
}
const DEFAULT_FOR: Record<string, PathId> = {
STANDARD: 'self_assessment',
IMPORTANT_I: 'self_assessment',
IMPORTANT_II: 'harmonized_standard',
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 = '00000000-0000-0000-0000-000000000001'
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 isSelected = selected === path.id
return (
<button
key={path.id}
onClick={() => allowed && setSelected(path.id)}
disabled={!allowed}
className={`text-left p-5 rounded-xl border-2 transition-all ${
isSelected ? 'border-red-500 bg-red-50' :
allowed ? 'border-gray-200 bg-white hover:border-red-300 hover:shadow-md' :
'border-gray-200 bg-gray-50 opacity-50 cursor-not-allowed'
}`}
>
<div className="flex items-start justify-between mb-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">Gewaehlt</span>
)}
{!allowed && (
<span className="px-2 py-0.5 text-xs bg-gray-200 text-gray-600 rounded">Nicht zulaessig</span>
)}
</div>
<p className="text-sm text-gray-600 mb-3">{path.short}</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>
)
}
@@ -0,0 +1,182 @@
'use client'
import React, { useEffect, useState, useCallback, use } from 'react'
import { SeverityBadge } from '../../_components/SeverityBadge'
interface Requirement {
req_id: string
n: number
category: string
title: string
annex_anchor: string
iso27001_ref: string[]
description: string
severity: string
mapped_measures: string[]
mapped_measure_names: { id: string; name: string }[]
evidence_type: string
effort_days: number
status: string
}
interface RequirementsResponse {
project_id: string
classification: string | null
total: number
items: Requirement[]
}
export default function RequirementsPage({
params,
}: {
params: Promise<{ projectId: string }>
}) {
const { projectId } = use(params)
const [data, setData] = useState<RequirementsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [filterCategory, setFilterCategory] = useState<string>('all')
const [filterSeverity, setFilterSeverity] = useState<string>('all')
const [expanded, setExpanded] = useState<string | null>(null)
const load = useCallback(async () => {
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/requirements`, {
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000001' },
})
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])
if (loading) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-gray-500">Laedt...</p></div>
if (error) return <div className="min-h-screen bg-gray-50 p-8"><p className="text-red-600">{error}</p></div>
if (!data) return null
const categories = Array.from(new Set(data.items.map(i => i.category)))
const filtered = data.items.filter(r =>
(filterCategory === 'all' || r.category === filterCategory) &&
(filterSeverity === 'all' || r.severity === filterSeverity)
)
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-7xl 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 Annex I Requirements</h1>
<p className="text-sm text-gray-600 mt-1">
Alle {data.total} Essential Cybersecurity Requirements aus Annex I. Status bleibt &quot;unbewertet&quot; bis Evidence-Checks in Phase 3 verknuepft sind.
</p>
</div>
<div className="flex gap-3 mb-4 flex-wrap">
<select
value={filterCategory}
onChange={e => setFilterCategory(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="all">Alle Kategorien</option>
{categories.map(c => <option key={c} value={c}>{c}</option>)}
</select>
<select
value={filterSeverity}
onChange={e => setFilterSeverity(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="all">Alle Severities</option>
<option value="CRITICAL">Kritisch</option>
<option value="HIGH">Hoch</option>
<option value="MEDIUM">Mittel</option>
<option value="LOW">Niedrig</option>
</select>
<span className="text-sm text-gray-500 self-center">
{filtered.length} von {data.total}
</span>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">#</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Anforderung</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Kategorie</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Severity</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Aufwand</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filtered.map(req => (
<React.Fragment key={req.req_id}>
<tr
className="hover:bg-gray-50 cursor-pointer"
onClick={() => setExpanded(expanded === req.req_id ? null : req.req_id)}
>
<td className="px-3 py-2 text-sm text-gray-500">{req.n}</td>
<td className="px-3 py-2">
<div className="text-sm font-medium text-gray-900">{req.title}</div>
<div className="text-xs text-gray-500">{req.annex_anchor} · {req.req_id}</div>
</td>
<td className="px-3 py-2 text-sm text-gray-600">{req.category}</td>
<td className="px-3 py-2"><SeverityBadge value={req.severity} /></td>
<td className="px-3 py-2 text-sm text-gray-600">{req.effort_days} PT</td>
<td className="px-3 py-2">
<span className="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600">{req.status}</span>
</td>
</tr>
{expanded === req.req_id && (
<tr>
<td colSpan={6} className="px-4 py-4 bg-blue-50">
<div className="space-y-3">
<div>
<p className="text-xs font-semibold text-gray-600 uppercase">Beschreibung</p>
<p className="text-sm text-gray-700 mt-1">{req.description}</p>
</div>
{req.iso27001_ref.length > 0 && (
<div>
<p className="text-xs font-semibold text-gray-600 uppercase">ISO 27001:2022 Mapping</p>
<p className="text-sm text-gray-700 mt-1">
{req.iso27001_ref.map(r => (
<span key={r} className="inline-block mr-2 mb-1 px-2 py-0.5 bg-white rounded text-xs">{r}</span>
))}
</p>
</div>
)}
{req.mapped_measure_names.length > 0 && (
<div>
<p className="text-xs font-semibold text-gray-600 uppercase">Empfohlene Massnahmen</p>
<ul className="text-sm text-gray-700 mt-1 space-y-0.5">
{req.mapped_measure_names.map(m => (
<li key={m.id}>
<span className="font-mono text-xs text-gray-500">{m.id}</span> {m.name}
</li>
))}
</ul>
</div>
)}
<div className="text-xs text-gray-500 pt-1">
Evidence-Typ: <span className="font-medium">{req.evidence_type}</span>
</div>
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
@@ -0,0 +1,171 @@
'use client'
import React, { useEffect, useState, useCallback, use, useRef } from 'react'
interface SBOMItem {
id: string
filename: string
format: string
spec_version: string | null
component_count: number
summary: Record<string, unknown>
scan_status: string
scan_summary: Record<string, unknown>
uploaded_at: string
scanned_at: string | null
}
interface SBOMListResponse {
project_id: string
total: number
items: SBOMItem[]
}
export default function SBOMPage({
params,
}: {
params: Promise<{ projectId: string }>
}) {
const { projectId } = use(params)
const [data, setData] = useState<SBOMListResponse | null>(null)
const [loading, setLoading] = useState(true)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
const fileRef = useRef<HTMLInputElement>(null)
const tenant = '00000000-0000-0000-0000-000000000001'
const load = useCallback(async () => {
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/sbom`, {
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 onUpload = async () => {
const f = fileRef.current?.files?.[0]
if (!f) return
setUploading(true)
setError('')
try {
const fd = new FormData()
fd.append('file', f)
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/sbom`, {
method: 'POST',
headers: { 'X-Tenant-ID': tenant },
body: fd,
})
if (!res.ok) throw new Error(await res.text())
if (fileRef.current) fileRef.current.value = ''
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Upload fehlgeschlagen')
} finally {
setUploading(false)
}
}
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">SBOM Software Bill of Materials</h1>
<p className="text-sm text-gray-600 mt-1">
CycloneDX oder SPDX hochladen. Verknuepft mit Annex-I Requirement 23 (SBOM-Pflicht).
</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-red-500 mt-1 underline text-xs">Schliessen</button>
</div>
)}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-6">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Neue Version hochladen</h3>
<div className="flex items-center gap-3">
<input
ref={fileRef}
type="file"
accept=".json,application/json"
className="flex-1 text-sm file:mr-3 file:py-1.5 file:px-3 file:rounded file:border-0 file:text-sm file:bg-blue-100 file:text-blue-700 hover:file:bg-blue-200"
/>
<button
onClick={onUpload}
disabled={uploading}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:bg-gray-300 text-sm font-medium"
>
{uploading ? 'Laedt hoch...' : 'Upload'}
</button>
</div>
<p className="text-xs text-gray-500 mt-2">
Format: CycloneDX-JSON (mit <code>bomFormat: &quot;CycloneDX&quot;</code>) oder SPDX-JSON (mit <code>spdxVersion</code>).
Generieren z.B. via <code>npx @cyclonedx/cyclonedx-npm</code> oder <code>cyclonedx-py</code>.
</p>
</div>
{data && data.items.length === 0 && (
<div className="bg-gray-100 rounded-xl p-8 text-center text-gray-500">
Noch kein SBOM hochgeladen.
</div>
)}
{data && data.items.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-gray-700">Versionen ({data.total})</h3>
{data.items.map(s => (
<div key={s.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-900">{s.filename}</span>
<span className="px-2 py-0.5 text-xs rounded bg-blue-100 text-blue-700 uppercase">{s.format}</span>
{s.spec_version && (
<span className="text-xs text-gray-500">v{s.spec_version}</span>
)}
</div>
<div className="text-xs text-gray-500 mt-1">
{s.component_count} Komponenten · hochgeladen {new Date(s.uploaded_at).toLocaleString('de-DE')}
</div>
</div>
<span className={`px-2 py-1 text-xs rounded-full ${
s.scan_status === 'scanned' ? 'bg-green-100 text-green-700' :
s.scan_status === 'failed' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600'
}`}>
Scan: {s.scan_status}
</span>
</div>
{s.summary && Object.keys(s.summary).length > 0 && (
<details className="mt-3 text-xs">
<summary className="cursor-pointer text-gray-600 hover:text-gray-900">Summary-Details</summary>
<pre className="mt-2 p-2 bg-gray-50 rounded overflow-x-auto text-xs">{JSON.stringify(s.summary, null, 2)}</pre>
</details>
)}
</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> Der osv.dev-Vulnerability-Scan wird durch ein separates Tool im Team durchgefuehrt.
Diese Seite akzeptiert SBOM-Uploads und persistiert sie versioniert.
</div>
</div>
</div>
)
}
@@ -0,0 +1,172 @@
'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
intended_use: string
primary_language: string | null
connected_to_internet: boolean
has_software_updates: boolean
processes_personal_data: boolean
is_critical_infra_supplier: boolean
cra_classification: string | null
classification_rationale: string[]
status: string
}
const CLASSIFICATION_DESC: Record<string, string> = {
NOT_IN_SCOPE: 'Dein Produkt enthaelt keine digitalen Elemente nach CRA-Definition. Es ist nicht vom CRA betroffen.',
STANDARD: 'Default-Kategorie fuer Produkte mit digitalen Elementen. Self-Assessment (Modul A) ist der typische Pfad.',
IMPORTANT_I: 'Annex III Klasse I — Wichtige Produkte mit erhoehten Anforderungen. Self-Assessment OR Harmonized Standard moeglich.',
IMPORTANT_II: 'Annex III Klasse II — Wichtige Produkte mit hohem Sicherheitsbedarf. Harmonized Standard ODER EUCC ODER Notified Body.',
CRITICAL: 'Annex IV — Kritische Produkte (z.B. HSM, Smart-Meter-Gateways). Notified-Body-Assessment Pflicht.',
}
export default function ScopeCheckPage({
params,
}: {
params: Promise<{ projectId: string }>
}) {
const { projectId } = use(params)
const router = useRouter()
const [project, setProject] = useState<CRAProject | null>(null)
const [loading, setLoading] = useState(true)
const [checking, setChecking] = useState(false)
const [error, setError] = useState('')
const tenant = '00000000-0000-0000-0000-000000000001'
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())
setProject(await res.json())
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [projectId])
useEffect(() => { load() }, [load])
const runScopeCheck = async () => {
setChecking(true)
setError('')
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/scope-check`, {
method: 'POST',
headers: { 'X-Tenant-ID': tenant },
})
if (!res.ok) throw new Error(await res.text())
setProject(await res.json())
} catch (e) {
setError(e instanceof Error ? e.message : 'Klassifikation fehlgeschlagen')
} finally {
setChecking(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
const hasResult = !!project.cra_classification
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-3xl 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">Scope-Check & Klassifikation</h1>
<p className="text-sm text-gray-600 mt-1">
Schritt 2 von 3 Wir matchen dein Intake gegen Annex III/IV des CRA.
</p>
</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="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<h3 className="text-sm font-semibold text-gray-700">Aktuelle Intake-Daten</h3>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm">
<Field label="Produkt" value={project.name} />
<Field label="Sprache" value={project.primary_language || '—'} />
<Field label="Intended Use" value={project.intended_use || '—'} fullWidth />
<Field label="Internet" value={project.connected_to_internet ? 'Ja' : 'Nein'} />
<Field label="Software-Updates" value={project.has_software_updates ? 'Ja' : 'Nein'} />
<Field label="Personenbezogene Daten" value={project.processes_personal_data ? 'Ja' : 'Nein'} />
<Field label="Kritische Infra" value={project.is_critical_infra_supplier ? 'Ja' : 'Nein'} />
</dl>
<div className="border-t border-gray-200 pt-4">
<button
onClick={runScopeCheck}
disabled={checking}
className="w-full py-3 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 disabled:bg-gray-300"
>
{checking ? 'Pruefe...' : hasResult ? 'Klassifikation neu berechnen' : 'Klassifikation berechnen'}
</button>
</div>
</div>
{hasResult && (
<div className="mt-6 bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Ergebnis</h3>
<div className="flex items-center gap-4 mb-4">
<ClassificationBadge value={project.cra_classification} size="lg" />
<p className="text-sm text-gray-700">
{CLASSIFICATION_DESC[project.cra_classification!]}
</p>
</div>
{project.classification_rationale?.length > 0 && (
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<p className="text-xs font-semibold text-gray-600 uppercase tracking-wide mb-2">Begruendung</p>
<ul className="list-disc list-inside space-y-1 text-sm text-gray-700">
{project.classification_rationale.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
)}
<div className="flex gap-3">
<button
onClick={() => router.push(`/sdk/cra/${projectId}/intake`)}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Intake anpassen
</button>
<button
onClick={() => router.push(`/sdk/cra/${projectId}/path`)}
disabled={project.cra_classification === 'NOT_IN_SCOPE'}
className="flex-1 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 disabled:bg-gray-300"
>
Weiter zum Konformitaetspfad
</button>
</div>
{project.cra_classification === 'NOT_IN_SCOPE' && (
<p className="text-xs text-gray-500 mt-2 text-center">
Produkt ist nicht im CRA-Scope. Keine weiteren Schritte noetig.
</p>
)}
</div>
)}
</div>
</div>
)
}
function Field({ label, value, fullWidth }: { label: string; value: string; fullWidth?: boolean }) {
return (
<div className={fullWidth ? 'md:col-span-2' : ''}>
<dt className="text-xs text-gray-500">{label}</dt>
<dd className="text-gray-900 mt-0.5">{value}</dd>
</div>
)
}
@@ -0,0 +1,385 @@
'use client'
import React, { useEffect, useState, useCallback, use } from 'react'
import { SeverityBadge } from '../../_components/SeverityBadge'
interface Vuln {
id: string
cve_id: string | null
title: string
description: string
severity: string | null
cvss_score: number | null
affected_components: string[]
reporter_source: string
reporter_contact: string | null
discovered_at: string
triaged_at: string | null
patched_at: string | null
disclosed_at: string | null
embargo_until: string | null
reported_to_enisa_at: string | null
detailed_report_at: string | null
status: string
notes: string
}
interface VulnListResponse {
project_id: string
total: number
summary: {
critical_open: number
breached_24h_reporting: number
breached_72h_reporting: number
by_status: Record<string, number>
}
items: Vuln[]
}
const STATUS_LABEL: Record<string, string> = {
reported: 'Gemeldet',
triaged: 'Triagiert',
patched: 'Gepatcht',
disclosed: 'Offengelegt',
withdrawn: 'Zurueckgezogen',
}
const STATUS_NEXT: Record<string, { status: string; label: string } | null> = {
reported: { status: 'triaged', label: 'Triagieren' },
triaged: { status: 'patched', label: 'Patch verfuegbar' },
patched: { status: 'disclosed', label: 'Offenlegen' },
disclosed: null,
withdrawn: null,
}
function ageHours(iso: string | null): number {
if (!iso) return 0
return (Date.now() - new Date(iso).getTime()) / 3600000
}
function fmtRemaining(iso: string | null, hours: number): { label: string; color: string } {
if (!iso) return { label: '—', color: 'text-gray-400' }
const age = ageHours(iso)
const remaining = hours - age
if (remaining < 0) return { label: `+${Math.round(-remaining)}h ueber Frist`, color: 'text-red-600 font-semibold' }
if (remaining < 4) return { label: `noch ${remaining.toFixed(1)}h`, color: 'text-orange-600 font-semibold' }
return { label: `noch ${Math.round(remaining)}h`, color: 'text-gray-600' }
}
export default function VulnPage({
params,
}: {
params: Promise<{ projectId: string }>
}) {
const { projectId } = use(params)
const [data, setData] = useState<VulnListResponse | null>(null)
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [error, setError] = useState('')
const [creating, setCreating] = useState(false)
const [transitioning, setTransitioning] = useState<string | null>(null)
// New vuln form state
const [title, setTitle] = useState('')
const [cveId, setCveId] = useState('')
const [severity, setSeverity] = useState('')
const [cvssScore, setCvssScore] = useState('')
const [description, setDescription] = useState('')
const [components, setComponents] = useState('')
const [reporterSource, setReporterSource] = useState('internal')
const [reporterContact, setReporterContact] = useState('')
const tenant = '00000000-0000-0000-0000-000000000001'
const load = useCallback(async () => {
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/vulnerabilities`, {
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 create = async () => {
if (!title.trim()) return
setCreating(true)
setError('')
try {
const res = await fetch(`/api/sdk/v1/cra/projects/${projectId}/vulnerabilities`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenant },
body: JSON.stringify({
title,
cve_id: cveId || null,
description,
severity: severity || null,
cvss_score: cvssScore ? parseFloat(cvssScore) : null,
affected_components: components.split(',').map(s => s.trim()).filter(Boolean),
reporter_source: reporterSource,
reporter_contact: reporterContact || null,
}),
})
if (!res.ok) throw new Error(await res.text())
setShowForm(false)
setTitle(''); setCveId(''); setSeverity(''); setCvssScore('')
setDescription(''); setComponents(''); setReporterContact('')
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Anlegen fehlgeschlagen')
} finally {
setCreating(false)
}
}
const transition = async (vulnId: string, nextStatus: string) => {
setTransitioning(vulnId)
setError('')
try {
const res = await fetch(`/api/sdk/v1/cra/vulnerabilities/${vulnId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenant },
body: JSON.stringify({ status: nextStatus }),
})
if (!res.ok) throw new Error(await res.text())
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Statuswechsel fehlgeschlagen')
} finally {
setTransitioning(null)
}
}
const markReported = async (vulnId: string, field: 'reported_to_enisa_at' | 'detailed_report_at') => {
setTransitioning(vulnId)
setError('')
try {
const res = await fetch(`/api/sdk/v1/cra/vulnerabilities/${vulnId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenant },
body: JSON.stringify({ [field]: new Date().toISOString() }),
})
if (!res.ok) throw new Error(await res.text())
await load()
} catch (e) {
setError(e instanceof Error ? e.message : 'Reporting fehlgeschlagen')
} finally {
setTransitioning(null)
}
}
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-6xl 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">Vulnerability Disclosure (CVD)</h1>
<p className="text-sm text-gray-600 mt-1">
Schwachstellen tracken. CRA-Pflichten: 24h Fruehwarnung an ENISA, 72h Detailbericht.
</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-red-500 underline text-xs">Schliessen</button>
</div>
)}
{/* Summary KPIs */}
{data && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
<SummaryCard label="Aktive Vulns" value={data.total - (data.summary.by_status.withdrawn || 0)} color="blue" />
<SummaryCard label="Critical offen" value={data.summary.critical_open} color={data.summary.critical_open > 0 ? 'red' : 'green'} />
<SummaryCard label="24h-Reporting versaeumt" value={data.summary.breached_24h_reporting} color={data.summary.breached_24h_reporting > 0 ? 'red' : 'green'} />
<SummaryCard label="72h-Reporting versaeumt" value={data.summary.breached_72h_reporting} color={data.summary.breached_72h_reporting > 0 ? 'red' : 'green'} />
</div>
)}
<button
onClick={() => setShowForm(!showForm)}
className="mb-4 w-full py-3 border-2 border-dashed border-red-300 rounded-xl text-red-600 hover:bg-red-50 font-medium"
>
{showForm ? 'Abbrechen' : '+ Neue Schwachstelle melden'}
</button>
{showForm && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-6">
<h3 className="text-sm font-semibold mb-3">Neue Schwachstelle</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="md:col-span-2">
<label className="block text-xs text-gray-600 mb-1">Titel *</label>
<input value={title} onChange={e => setTitle(e.target.value)} className="w-full px-3 py-2 border rounded text-sm" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">CVE-ID (optional)</label>
<input value={cveId} onChange={e => setCveId(e.target.value)} placeholder="CVE-2026-12345" className="w-full px-3 py-2 border rounded text-sm font-mono" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Severity</label>
<select value={severity} onChange={e => setSeverity(e.target.value)} className="w-full px-3 py-2 border rounded text-sm">
<option value=""> waehlen </option>
<option value="LOW">LOW</option>
<option value="MEDIUM">MEDIUM</option>
<option value="HIGH">HIGH</option>
<option value="CRITICAL">CRITICAL</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">CVSS Score (0-10)</label>
<input type="number" min="0" max="10" step="0.1" value={cvssScore} onChange={e => setCvssScore(e.target.value)} className="w-full px-3 py-2 border rounded text-sm" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Reporter</label>
<select value={reporterSource} onChange={e => setReporterSource(e.target.value)} className="w-full px-3 py-2 border rounded text-sm">
<option value="internal">Intern</option>
<option value="external">Extern (Kunde/Partner)</option>
<option value="researcher">Security Researcher</option>
<option value="scanner">Automatisierter Scanner</option>
</select>
</div>
<div className="md:col-span-2">
<label className="block text-xs text-gray-600 mb-1">Reporter-Kontakt</label>
<input value={reporterContact} onChange={e => setReporterContact(e.target.value)} placeholder="email@..." className="w-full px-3 py-2 border rounded text-sm" />
</div>
<div className="md:col-span-2">
<label className="block text-xs text-gray-600 mb-1">Betroffene Komponenten (Komma-getrennt)</label>
<input value={components} onChange={e => setComponents(e.target.value)} placeholder="lodash@4.17.20, axios@0.21.0" className="w-full px-3 py-2 border rounded text-sm font-mono" />
</div>
<div className="md:col-span-2">
<label className="block text-xs text-gray-600 mb-1">Beschreibung</label>
<textarea value={description} onChange={e => setDescription(e.target.value)} rows={3} className="w-full px-3 py-2 border rounded text-sm" />
</div>
</div>
<button
onClick={create}
disabled={creating || !title.trim()}
className="mt-4 w-full py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:bg-gray-300 font-medium"
>
{creating ? 'Erstelle...' : 'Schwachstelle erfassen'}
</button>
</div>
)}
{data && data.items.length === 0 && !showForm && (
<div className="bg-gray-100 rounded-xl p-8 text-center text-gray-500">
Noch keine Schwachstellen erfasst.
</div>
)}
{data && data.items.map(v => {
const tx = STATUS_NEXT[v.status]
const rep24 = fmtRemaining(v.discovered_at, 24)
const rep72 = fmtRemaining(v.discovered_at, 72)
return (
<div key={v.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 mb-3">
<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">{v.title}</h3>
{v.cve_id && <span className="font-mono text-xs px-1.5 py-0.5 bg-gray-100 rounded">{v.cve_id}</span>}
{v.severity && <SeverityBadge value={v.severity} />}
{v.cvss_score !== null && <span className="text-xs text-gray-500">CVSS {v.cvss_score}</span>}
</div>
{v.description && <p className="text-sm text-gray-600 mt-1">{v.description}</p>}
{v.affected_components.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{v.affected_components.map((c, i) => (
<span key={i} className="font-mono text-xs px-1.5 py-0.5 bg-yellow-50 text-yellow-800 rounded">{c}</span>
))}
</div>
)}
</div>
<span className="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-700 flex-shrink-0">
{STATUS_LABEL[v.status] || v.status}
</span>
</div>
{/* CRA Reporting Compliance */}
{v.status !== 'withdrawn' && (
<div className="grid grid-cols-2 gap-3 mb-3 text-xs">
<div className={`p-2 rounded ${v.reported_to_enisa_at ? 'bg-green-50' : 'bg-orange-50'}`}>
<div className="font-semibold text-gray-700">24h: ENISA-Fruehwarnung</div>
{v.reported_to_enisa_at ? (
<div className="text-green-700"> {new Date(v.reported_to_enisa_at).toLocaleString('de-DE')}</div>
) : (
<div className="flex items-center justify-between mt-1">
<span className={rep24.color}>{rep24.label}</span>
<button
onClick={() => markReported(v.id, 'reported_to_enisa_at')}
disabled={transitioning === v.id}
className="px-2 py-0.5 bg-orange-600 text-white rounded text-xs"
>
Jetzt melden
</button>
</div>
)}
</div>
<div className={`p-2 rounded ${v.detailed_report_at ? 'bg-green-50' : 'bg-orange-50'}`}>
<div className="font-semibold text-gray-700">72h: Detailbericht</div>
{v.detailed_report_at ? (
<div className="text-green-700"> {new Date(v.detailed_report_at).toLocaleString('de-DE')}</div>
) : (
<div className="flex items-center justify-between mt-1">
<span className={rep72.color}>{rep72.label}</span>
<button
onClick={() => markReported(v.id, 'detailed_report_at')}
disabled={transitioning === v.id}
className="px-2 py-0.5 bg-orange-600 text-white rounded text-xs"
>
Jetzt melden
</button>
</div>
)}
</div>
</div>
)}
<div className="flex items-center justify-between text-xs text-gray-500">
<div>
Entdeckt: {new Date(v.discovered_at).toLocaleString('de-DE')}
{v.patched_at && <> · Gepatcht: {new Date(v.patched_at).toLocaleString('de-DE')}</>}
{v.disclosed_at && <> · Offengelegt: {new Date(v.disclosed_at).toLocaleString('de-DE')}</>}
</div>
{tx && (
<button
onClick={() => transition(v.id, tx.status)}
disabled={transitioning === v.id}
className="px-3 py-1 bg-blue-600 text-white rounded text-xs hover:bg-blue-700 disabled:bg-gray-300"
>
{tx.label}
</button>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
function SummaryCard({ label, value, color }: { label: string; value: number; color: 'blue' | 'red' | 'green' | 'orange' }) {
const bg = {
blue: 'bg-blue-50 border-blue-200 text-blue-700',
red: 'bg-red-50 border-red-200 text-red-700',
green: 'bg-green-50 border-green-200 text-green-700',
orange: 'bg-orange-50 border-orange-200 text-orange-700',
}[color]
return (
<div className={`rounded-xl border p-3 ${bg}`}>
<p className="text-xs uppercase tracking-wide">{label}</p>
<p className="text-2xl font-bold mt-1">{value}</p>
</div>
)
}
@@ -0,0 +1,24 @@
'use client'
type Classification = 'NOT_IN_SCOPE' | 'STANDARD' | 'IMPORTANT_I' | 'IMPORTANT_II' | 'CRITICAL'
const STYLES: Record<string, { bg: string; label: string }> = {
NOT_IN_SCOPE: { bg: 'bg-gray-200 text-gray-700', label: 'Nicht im Scope' },
STANDARD: { bg: 'bg-blue-100 text-blue-800', label: 'Standard' },
IMPORTANT_I: { bg: 'bg-yellow-100 text-yellow-800', label: 'Important Class I' },
IMPORTANT_II: { bg: 'bg-orange-100 text-orange-800', label: 'Important Class II' },
CRITICAL: { bg: 'bg-red-100 text-red-800', label: 'Critical' },
}
export function ClassificationBadge({ value, size = 'md' }: { value: string | null; size?: 'sm' | 'md' | 'lg' }) {
if (!value) {
return <span className="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-500">Unbewertet</span>
}
const style = STYLES[value] || { bg: 'bg-gray-100 text-gray-700', label: value }
const sizeClasses = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-3 py-1 text-sm font-medium',
lg: 'px-4 py-2 text-base font-semibold',
}[size]
return <span className={`rounded-full ${sizeClasses} ${style.bg}`}>{style.label}</span>
}
@@ -0,0 +1,13 @@
'use client'
const STYLES: Record<string, { bg: string; label: string }> = {
CRITICAL: { bg: 'bg-red-600 text-white', label: 'Kritisch' },
HIGH: { bg: 'bg-orange-500 text-white', label: 'Hoch' },
MEDIUM: { bg: 'bg-yellow-400 text-gray-900', label: 'Mittel' },
LOW: { bg: 'bg-blue-100 text-blue-800', label: 'Niedrig' },
}
export function SeverityBadge({ value }: { value: string }) {
const s = STYLES[value] || { bg: 'bg-gray-200 text-gray-700', label: value }
return <span className={`px-2 py-0.5 text-xs font-bold rounded ${s.bg}`}>{s.label}</span>
}
@@ -0,0 +1,40 @@
'use client'
const STEPS = [
{ id: 'draft', label: 'Entwurf' },
{ id: 'scoped', label: 'Intake' },
{ id: 'classified', label: 'Klassifiziert' },
{ id: 'path_selected', label: 'Pfad' },
{ id: 'requirements_mapped', label: 'Requirements' },
{ id: 'evidence_pending', label: 'Evidence' },
{ id: 'ready_for_review', label: 'Review' },
{ id: 'declaration_ready', label: 'DoC' },
{ id: 'post_market', label: 'Post-Market' },
]
export function StatusStepper({ current }: { current: string }) {
const currentIdx = STEPS.findIndex(s => s.id === current)
return (
<div className="flex items-center gap-1 overflow-x-auto py-2">
{STEPS.map((step, idx) => {
const isPast = idx < currentIdx
const isCurrent = idx === currentIdx
return (
<div key={step.id} className="flex items-center gap-1 flex-shrink-0">
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-medium ${
isCurrent ? 'bg-blue-600 text-white' :
isPast ? 'bg-green-500 text-white' :
'bg-gray-200 text-gray-500'
}`}>{idx + 1}</div>
<span className={`text-xs ${isCurrent ? 'font-semibold text-blue-700' : isPast ? 'text-gray-700' : 'text-gray-400'}`}>
{step.label}
</span>
{idx < STEPS.length - 1 && (
<span className={`mx-1 ${isPast ? 'text-green-500' : 'text-gray-300'}`}></span>
)}
</div>
)
})}
</div>
)
}
+200
View File
@@ -0,0 +1,200 @@
'use client'
import React, { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { ClassificationBadge } from './_components/ClassificationBadge'
interface CRAProject {
id: string
name: string
description: string
cra_classification: string | null
conformity_path: string | null
status: string
created_at: string
}
const PATH_LABEL: Record<string, string> = {
self_assessment: 'Modul A (Self-Assessment)',
harmonized_standard: 'Modul B (Harmonized)',
eucc: 'Modul H (EUCC)',
notified_body: 'Modul C (Notified Body)',
}
const STATUS_LABEL: Record<string, string> = {
draft: 'Entwurf',
scoped: 'Intake erfasst',
classified: 'Klassifiziert',
path_selected: 'Pfad gewaehlt',
requirements_mapped: 'Requirements',
evidence_pending: 'Evidence',
gaps_open: 'Gaps offen',
remediation: 'Remediation',
ready_for_review: 'In Pruefung',
declaration_ready: 'DoC bereit',
post_market: 'Post-Market',
archived: 'Archiviert',
}
export default function CRAProjectsPage() {
const router = useRouter()
const [projects, setProjects] = useState<CRAProject[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showModal, setShowModal] = useState(false)
const [newName, setNewName] = useState('')
const [newDescription, setNewDescription] = useState('')
const [creating, setCreating] = useState(false)
const tenantHeader = '00000000-0000-0000-0000-000000000001'
const loadProjects = useCallback(async () => {
try {
const res = await fetch('/api/sdk/v1/cra/projects', {
headers: { 'X-Tenant-ID': tenantHeader },
})
if (!res.ok) throw new Error(await res.text())
const data = await res.json()
setProjects(data.projects || [])
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadProjects() }, [loadProjects])
const createProject = async () => {
if (!newName.trim()) return
setCreating(true)
setError('')
try {
const res = await fetch('/api/sdk/v1/cra/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantHeader },
body: JSON.stringify({ name: newName, description: newDescription }),
})
if (!res.ok) throw new Error(await res.text())
const project = await res.json()
router.push(`/sdk/cra/${project.id}/intake`)
} catch (e) {
setError(e instanceof Error ? e.message : 'Anlegen fehlgeschlagen')
} finally {
setCreating(false)
}
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-6xl mx-auto px-4">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900">CRA Compliance</h1>
<p className="text-gray-600 mt-2">
Cyber Resilience Act Konformitaets-Workflow fuer Produkte mit digitalen Elementen.
</p>
<p className="text-sm text-gray-500 mt-1">
Fuer Entwickler / Tech-Experten. Hardware-CE-Risikobeurteilung siehe{' '}
<a href="/sdk/iace" className="text-blue-600 hover:underline">iACE</a>.
</p>
</div>
{error && (
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">
{error}
<button onClick={() => setError('')} className="ml-3 underline">Schliessen</button>
</div>
)}
<button
onClick={() => setShowModal(true)}
className="mb-6 w-full py-4 border-2 border-dashed border-red-300 rounded-xl text-red-600 hover:bg-red-50 hover:border-red-400 transition-colors font-medium"
>
+ Neues CRA-Projekt
</button>
{loading ? (
<div className="text-center text-gray-500 py-12">Laedt...</div>
) : projects.length === 0 ? (
<p className="text-center text-gray-500 mt-8">
Noch keine Projekte. Starten Sie Ihre erste CRA-Konformitaetsanalyse.
</p>
) : (
<div className="space-y-3">
<h2 className="text-lg font-semibold text-gray-800">Projekte</h2>
{projects.map(p => (
<a
key={p.id}
href={`/sdk/cra/${p.id}`}
className="block bg-white rounded-xl shadow-sm border border-gray-200 p-5 hover:shadow-md hover:border-red-300 transition-all"
>
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{p.name}</h3>
{p.description && (
<p className="text-sm text-gray-500 mt-1 truncate">{p.description}</p>
)}
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<ClassificationBadge value={p.cra_classification} size="sm" />
{p.conformity_path && (
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-100 text-purple-800">
{PATH_LABEL[p.conformity_path] || p.conformity_path}
</span>
)}
<span className="px-2 py-0.5 text-xs rounded-full bg-gray-100 text-gray-700">
{STATUS_LABEL[p.status] || p.status}
</span>
<span className="text-xs text-gray-400">
{new Date(p.created_at).toLocaleDateString('de-DE')}
</span>
</div>
</div>
</a>
))}
</div>
)}
{showModal && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4 p-6">
<h3 className="text-lg font-semibold mb-4">Neues CRA-Projekt anlegen</h3>
<div className="space-y-3">
<input
type="text"
placeholder="Projektname (z.B. SmartHome Gateway v3)"
value={newName}
onChange={e => setNewName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
/>
<textarea
placeholder="Kurzbeschreibung (optional)"
value={newDescription}
onChange={e => setNewDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500"
/>
</div>
<div className="flex gap-3 mt-5">
<button
onClick={() => { setShowModal(false); setNewName(''); setNewDescription('') }}
disabled={creating}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Abbrechen
</button>
<button
onClick={createProject}
disabled={creating || !newName.trim()}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:bg-gray-300"
>
{creating ? 'Erstelle...' : 'Anlegen'}
</button>
</div>
</div>
</div>
)}
</div>
</div>
)
}
@@ -57,12 +57,7 @@ export default function EinwilligungenPage() {
explanation={stepInfo.explanation}
tips={stepInfo.tips}
>
<button className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Export
</button>
<ConsentExportButton />
</StepHeader>
{/* Navigation Tabs */}
@@ -150,3 +145,32 @@ export default function EinwilligungenPage() {
</div>
)
}
// Export-Dropdown im Step-Header. Streamt CSV/JSON direkt aus dem
// Backend via /api/sdk/v1/einwilligungen/export-Proxy.
function ConsentExportButton() {
return (
<div className="relative group">
<button className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Export
</button>
<div className="absolute right-0 top-full mt-1 w-60 bg-white border border-gray-200 rounded-lg shadow-lg invisible group-hover:visible opacity-0 group-hover:opacity-100 transition-all z-10">
<a href="/api/sdk/v1/einwilligungen/export?format=csv&kind=consents" download
className="block px-4 py-2 text-sm text-gray-700 hover:bg-purple-50 first:rounded-t-lg">
Einwilligungen als CSV
</a>
<a href="/api/sdk/v1/einwilligungen/export?format=json&kind=consents" download
className="block px-4 py-2 text-sm text-gray-700 hover:bg-purple-50">
Einwilligungen als JSON
</a>
<a href="/api/sdk/v1/einwilligungen/export?format=csv&kind=history" download
className="block px-4 py-2 text-sm text-gray-700 hover:bg-purple-50 last:rounded-b-lg border-t border-gray-100">
Aenderungs-Historie als CSV
</a>
</div>
</div>
)
}
@@ -1,6 +1,7 @@
'use client'
import React, { useState } from 'react'
import React, { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import type { HazardMatchPair, GroundTruthEntry, HazardSummary } from '../_hooks/useBenchmark'
interface Props {
@@ -11,8 +12,41 @@ interface Props {
type TabType = 'matched' | 'missing' | 'extra'
// Per-hazard clarification status fetched once and shared with all detail rows.
type HazardClarStatus = { open: number; answered: number; total: number }
function useClarificationsByHazard(projectId: string | undefined): Record<string, HazardClarStatus> {
const [byHz, setByHz] = useState<Record<string, HazardClarStatus>>({})
useEffect(() => {
if (!projectId) return
let cancelled = false
fetch(`/api/sdk/v1/iace/projects/${projectId}/clarifications`)
.then(r => r.ok ? r.json() : null)
.then(d => {
if (cancelled || !d?.clarifications) return
const out: Record<string, HazardClarStatus> = {}
for (const c of d.clarifications as Array<{ affected_hazard_ids: string[]; status: string }>) {
const isOpen = c.status !== 'answered' && c.status !== 'not_relevant'
for (const hid of c.affected_hazard_ids) {
if (!out[hid]) out[hid] = { open: 0, answered: 0, total: 0 }
out[hid].total += 1
if (isOpen) out[hid].open += 1
else out[hid].answered += 1
}
}
setByHz(out)
})
.catch(() => {})
return () => { cancelled = true }
}, [projectId])
return byHz
}
export function HazardComparisonTable({ matched, missing, extra }: Props) {
const [tab, setTab] = useState<TabType>('matched')
const params = useParams()
const projectId = params?.projectId as string | undefined
const clarStatusByHazard = useClarificationsByHazard(projectId)
// Split matches: >= 50% are real matches, < 50% are weak (shown separately)
const realMatched = matched.filter(p => p.match_score >= 0.5)
@@ -51,7 +85,7 @@ export function HazardComparisonTable({ matched, missing, extra }: Props) {
</div>
<div className="overflow-x-auto">
{tab === 'matched' && <MatchedTable pairs={realMatched} />}
{tab === 'matched' && <MatchedTable pairs={realMatched} clarStatusByHazard={clarStatusByHazard} projectId={projectId} />}
{tab === 'missing' && <MissingTable entries={allMissing} />}
{tab === 'extra' && <ExtraTable entries={allExtra} />}
</div>
@@ -59,7 +93,7 @@ export function HazardComparisonTable({ matched, missing, extra }: Props) {
)
}
function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
function MatchedTable({ pairs, clarStatusByHazard, projectId }: { pairs: HazardMatchPair[]; clarStatusByHazard: Record<string, HazardClarStatus>; projectId?: string }) {
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
if (pairs.length === 0) return <EmptyState text="Keine Zuordnungen gefunden" />
return (
@@ -109,7 +143,12 @@ function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
{isOpen && (
<tr className="bg-gray-50/70 dark:bg-gray-850">
<td colSpan={6} className="px-4 py-3">
<DetailComparison gt={p.gt_entry} engine={p.engine_hazard} />
<DetailComparison
gt={p.gt_entry}
engine={p.engine_hazard}
clarStatus={clarStatusByHazard[p.engine_hazard.id]}
projectId={projectId}
/>
</td>
</tr>
)}
@@ -137,7 +176,12 @@ function formatLifecycles(raw: string): string {
}
/** Side-by-side detail comparison of GT entry vs. Engine hazard */
function DetailComparison({ gt, engine }: { gt: GroundTruthEntry; engine: HazardSummary }) {
function DetailComparison({ gt, engine, clarStatus, projectId }: {
gt: GroundTruthEntry
engine: HazardSummary
clarStatus?: HazardClarStatus
projectId?: string
}) {
return (
<div className="grid grid-cols-2 gap-4 text-xs">
{/* Left: Ground Truth */}
@@ -163,7 +207,7 @@ function DetailComparison({ gt, engine }: { gt: GroundTruthEntry; engine: Hazard
<div className="space-y-2">
<div className="font-semibold text-purple-700 dark:text-purple-400 uppercase text-[10px]">Engine (automatisch)</div>
<DetailRow label="Gefaehrdung" gt={engine.name} />
<DetailRow label="Szenario" gt={engine.scenario || engine.description || '-'} />
<DetailRow label="Szenario" gt={engine.scenario || extractScenario(engine.description) || '-'} />
<DetailRow label="Gefahrenstelle" gt={engine.zone || '-'} />
{engine.lifecycle_phase && (
<DetailRow label="Lebensphasen" gt={formatLifecycles(engine.lifecycle_phase)} />
@@ -178,11 +222,80 @@ function DetailComparison({ gt, engine }: { gt: GroundTruthEntry; engine: Hazard
) : (
<DetailRow label="Massnahmen" gt="(keine zugeordnet)" />
)}
{clarStatus && clarStatus.total > 0 && (
<ClarificationBanner status={clarStatus} projectId={projectId} />
)}
{(() => {
const norms = extractEngineNorms(engine.description)
if (norms.length === 0) return null
return <DetailRow label="Referenzierte Normen" gt={norms.join(' | ')} />
})()}
</div>
</div>
)
}
/**
* The Go init handler appends two annotated blocks to Hazard.Description:
* "<scenario>\n\nMit Anlagenbauer zu klaeren:\n- frage 1\n- frage 2\n\n
* Referenzierte Normen: EN 60204-1 Ziff. 6.2 | EN 61140"
* These helpers split that string back into structured pieces so the UI
* can render scenario, clarifications and norms as separate sections.
*/
function extractScenario(desc?: string): string {
if (!desc) return ''
const idx = desc.indexOf('\n\nMit Anlagenbauer zu klaeren')
const cut = idx >= 0 ? desc.slice(0, idx) : desc
// Also cut off a trailing norm line if it's the only suffix
const normIdx = cut.indexOf('\n\nReferenzierte Normen')
return (normIdx >= 0 ? cut.slice(0, normIdx) : cut).trim()
}
// (extractClarifications removed in Phase 2 — clarifications are loaded
// from the dedicated /clarifications API and rendered as a status banner
// instead of being parsed out of the hazard description.)
function ClarificationBanner({ status, projectId }: { status: HazardClarStatus; projectId?: string }) {
const allDone = status.open === 0
const href = projectId ? `/sdk/iace/${projectId}/clarifications` : '#'
return (
<div>
<div className="text-[10px] font-medium text-gray-500 uppercase">Klärungen</div>
<a
href={href}
className={`mt-0.5 inline-flex items-center gap-2 px-3 py-1.5 rounded border text-xs ${
allDone
? 'bg-green-50 border-green-200 text-green-800 hover:bg-green-100'
: 'bg-orange-50 border-orange-200 text-orange-800 hover:bg-orange-100'
}`}
>
{allDone ? (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Alle {status.total} Klärungen beantwortet
</>
) : (
<>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
{status.open} offene Klärung{status.open === 1 ? '' : 'en'} {status.answered > 0 && `(${status.answered} beantwortet)`} Klärungen-Seite öffnen
</>
)}
</a>
</div>
)
}
function extractEngineNorms(desc?: string): string[] {
if (!desc) return []
const m = desc.match(/Referenzierte Normen:\s*([^\n]+)/)
if (!m) return []
return m[1].split('|').map(s => s.trim()).filter(Boolean)
}
function DetailRow({ label, gt, multiline }: { label: string; gt: string; multiline?: boolean }) {
return (
<div>
@@ -0,0 +1,476 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useParams } from 'next/navigation'
type Clarification = {
id: string
question: string
source: string
category: 'manufacturer' | 'pattern_norm' | string
norm_references?: string[]
affected_hazard_ids: string[]
affected_hazard_names: string[]
status: 'open' | 'in_progress' | 'answered' | 'not_relevant'
answer?: 'ja' | 'nein' | 'teilweise' | ''
reasoning?: string
answered_by?: string
answered_at?: string
assigned_to?: string
}
type ListResponse = {
clarifications: Clarification[]
open_count: number
answered_count: number
total: number
}
const CATEGORY_LABEL: Record<string, string> = {
manufacturer: 'Hersteller',
pattern_norm: 'Norm / Pattern',
}
const STATUS_LABEL: Record<string, string> = {
open: 'Offen',
in_progress: 'In Klärung',
answered: 'Beantwortet',
not_relevant: 'Nicht relevant',
}
const STATUS_COLOR: Record<string, string> = {
open: 'bg-orange-100 text-orange-800',
in_progress: 'bg-yellow-100 text-yellow-800',
answered: 'bg-green-100 text-green-800',
not_relevant: 'bg-gray-100 text-gray-700',
}
export default function ClarificationsPage() {
const params = useParams()
const projectId = params.projectId as string
const [data, setData] = useState<ListResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [editing, setEditing] = useState<Clarification | null>(null)
const [filter, setFilter] = useState<'all' | 'open' | 'answered'>('open')
const [searchQuery, setSearchQuery] = useState('')
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const r = await fetch(`/api/sdk/v1/iace/projects/${projectId}/clarifications`)
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const json: ListResponse = await r.json()
setData(json)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [projectId])
useEffect(() => {
load()
}, [load])
const filtered = (data?.clarifications ?? []).filter(c => {
if (filter === 'open' && (c.status === 'answered' || c.status === 'not_relevant')) return false
if (filter === 'answered' && c.status !== 'answered' && c.status !== 'not_relevant') return false
if (searchQuery) {
const q = searchQuery.toLowerCase()
if (!c.question.toLowerCase().includes(q) && !c.source.toLowerCase().includes(q)) return false
}
return true
})
const groupedBySource: Record<string, Clarification[]> = {}
for (const c of filtered) {
const key = c.source
if (!groupedBySource[key]) groupedBySource[key] = []
groupedBySource[key].push(c)
}
// CRA-Spur: zeige Banner, wenn mindestens eine Klaerung einen CRA-Bezug
// hat (Norm-Referenz "2024/2847" oder "DIN EN 40000-1-2"). Die Banner
// erinnert den Anwender daran, dass die CRA-Pflichten zwar bereits jetzt
// dokumentiert werden, aber erst zum 11.12.2027 verpflichtend gelten.
const hasCRAClarifications = (data?.clarifications ?? []).some(c =>
(c.norm_references ?? []).some(n => n.includes('2024/2847') || n.includes('40000-1-2'))
)
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="flex items-baseline justify-between mb-4">
<div>
<h1 className="text-2xl font-semibold">Klärungen mit dem Anlagenbauer</h1>
<p className="text-sm text-gray-500 mt-1">
Standardisierte Prüffragen aus Norm- und Herstellerwissen. Eine Antwort gilt für alle referenzierten Gefährdungen.
</p>
</div>
<div className="flex items-center gap-3">
{data && (
<div className="flex gap-2 text-sm">
<Badge color="bg-orange-100 text-orange-800" label={`${data.open_count} offen`} />
<Badge color="bg-green-100 text-green-800" label={`${data.answered_count} beantwortet`} />
<Badge color="bg-gray-100 text-gray-700" label={`${data.total} gesamt`} />
</div>
)}
<a
href={`/api/sdk/v1/iace/projects/${projectId}/clarifications.csv`}
download
className="text-xs px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 inline-flex items-center gap-1.5"
title="CSV-Export für die Übergabe an den Anlagenbauer"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5 5-5M12 15V3" />
</svg>
CSV
</a>
<a
href={`/api/sdk/v1/iace/projects/${projectId}/clarifications.html`}
target="_blank"
rel="noopener noreferrer"
className="text-xs px-3 py-1.5 rounded border border-gray-300 bg-white hover:bg-gray-50 inline-flex items-center gap-1.5"
title="Druckansicht öffnen — mit Strg/Cmd-P als PDF speichern"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
PDF / Druck
</a>
</div>
</div>
<div className="flex gap-3 mb-4 items-center">
<div className="flex gap-1 text-sm">
{(['open', 'answered', 'all'] as const).map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1.5 rounded ${filter === f ? 'bg-blue-600 text-white' : 'bg-gray-100 hover:bg-gray-200'}`}
>
{f === 'open' ? 'Offen' : f === 'answered' ? 'Beantwortet' : 'Alle'}
</button>
))}
</div>
<input
type="text"
placeholder="Suchen in Frage oder Quelle..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="flex-1 max-w-sm border rounded px-3 py-1.5 text-sm"
/>
</div>
{!loading && hasCRAClarifications && (
<div className="mb-4 rounded-md border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-900">
<div className="font-semibold mb-1">Cyber Resilience Act (CRA) Hinweis zur Geltung</div>
<div className="text-blue-800">
Diese Klärungsliste enthält Fragen zur Verordnung (EU) 2024/2847 (CRA). Die CRA gilt für Produkte mit digitalen Elementen", die ab dem <strong>11.12.2027</strong> auf dem EU-Markt bereitgestellt werden. Die hier dokumentierten Pflichten (SBOM, signierte Updates, CVD-Policy, Patch-SLA, Incident-Notification an ENISA) sollten bereits jetzt im Entwurf des Anlagenbauers berücksichtigt sein. Harmonisierter Standard: <strong>DIN EN 40000-1-2</strong> (Entwurf 11/2025).
</div>
</div>
)}
{loading && <div className="text-gray-500">Lade Klärungen…</div>}
{error && <div className="text-red-600">Fehler: {error}</div>}
{!loading && data && Object.keys(groupedBySource).length === 0 && (
<div className="text-gray-500 italic">Keine Klärungen für die aktuelle Auswahl.</div>
)}
{!loading && data && Object.entries(groupedBySource).map(([source, items]) => (
<div key={source} className="mb-6">
<h2 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-2">
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-0.5 rounded">
{CATEGORY_LABEL[items[0].category] || items[0].category}
</span>
{source}
</h2>
<div className="space-y-2">
{items.map(c => (
<div key={c.id} className="border rounded-lg p-3 bg-white shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">{c.question}</div>
<div className="mt-1 text-xs text-gray-500">
Betrifft <strong>{c.affected_hazard_ids.length}</strong> Gefährdung
{c.affected_hazard_ids.length !== 1 ? 'en' : ''}
{c.affected_hazard_names.length > 0 && (
<span className="ml-1">— {c.affected_hazard_names.slice(0, 2).join('; ')}{c.affected_hazard_names.length > 2 ? `, +${c.affected_hazard_names.length - 2} weitere` : ''}</span>
)}
</div>
{c.norm_references && c.norm_references.length > 0 && (
<div className="mt-1 text-xs text-gray-500">
Norm: {c.norm_references.join(' | ')}
</div>
)}
{c.status === 'answered' && c.reasoning && (
<div className="mt-2 text-xs text-gray-700 bg-green-50 border border-green-200 rounded p-2">
<strong>Antwort ({c.answer}):</strong> {c.reasoning}
{c.answered_by && (
<span className="text-gray-500 ml-2">— {c.answered_by}, {c.answered_at?.slice(0, 10)}</span>
)}
</div>
)}
</div>
<div className="flex flex-col items-end gap-2 text-xs">
<span className={`px-2 py-0.5 rounded ${STATUS_COLOR[c.status]}`}>{STATUS_LABEL[c.status]}</span>
<button
onClick={() => setEditing(c)}
className="px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700"
>
{c.status === 'answered' ? 'Bearbeiten' : 'Beantworten'}
</button>
</div>
</div>
</div>
))}
</div>
</div>
))}
{editing && (
<AnswerModal
clarification={editing}
projectId={projectId}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null)
load()
}}
/>
)}
</div>
)
}
function Badge({ color, label }: { color: string; label: string }) {
return <span className={`px-2 py-0.5 rounded text-xs ${color}`}>{label}</span>
}
type Comment = { id: string; author: string; body: string; created_at: string }
type HistoryEntry = {
actor: string
from_status?: string
to_status?: string
from_answer?: string
to_answer?: string
created_at: string
}
function AnswerModal({
clarification,
projectId,
onClose,
onSaved,
}: {
clarification: Clarification & { assigned_to?: string }
projectId: string
onClose: () => void
onSaved: () => void
}) {
const [status, setStatus] = useState(clarification.status)
const [answer, setAnswer] = useState<'ja' | 'nein' | 'teilweise' | ''>(
(clarification.answer as 'ja' | 'nein' | 'teilweise' | '') || ''
)
const [reasoning, setReasoning] = useState(clarification.reasoning || '')
const [answeredBy, setAnsweredBy] = useState(clarification.answered_by || '')
const [assignedTo, setAssignedTo] = useState(clarification.assigned_to || '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [comments, setComments] = useState<Comment[]>([])
const [history, setHistory] = useState<HistoryEntry[]>([])
const [newComment, setNewComment] = useState('')
const [postingComment, setPostingComment] = useState(false)
useEffect(() => {
fetch(`/api/sdk/v1/iace/projects/${projectId}/clarifications/${encodeURIComponent(clarification.id)}/detail`)
.then(r => r.ok ? r.json() : null)
.then(d => {
if (!d) return
setComments(d.comments || [])
setHistory(d.history || [])
})
.catch(() => {})
}, [projectId, clarification.id])
const save = async () => {
setSaving(true)
setError(null)
try {
const r = await fetch(
`/api/sdk/v1/iace/projects/${projectId}/clarifications/${encodeURIComponent(clarification.id)}/answer`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status, answer, reasoning,
answered_by: answeredBy,
assigned_to: assignedTo,
question: clarification.question,
source: clarification.source,
category: clarification.category,
norm_references: clarification.norm_references,
}),
}
)
if (!r.ok) throw new Error(`HTTP ${r.status}`)
onSaved()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}
const postComment = async () => {
if (!newComment.trim()) return
setPostingComment(true)
try {
const r = await fetch(
`/api/sdk/v1/iace/projects/${projectId}/clarifications/${encodeURIComponent(clarification.id)}/comment`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ author: answeredBy || assignedTo || 'unbekannt', body: newComment }),
}
)
if (r.ok) {
const d = await r.json()
if (d.comment) setComments(prev => [...prev, d.comment])
setNewComment('')
} else {
setError(`Kommentar HTTP ${r.status} — bitte zuerst Status setzen, damit der Klärungs-Datensatz angelegt wird.`)
}
} finally {
setPostingComment(false)
}
}
return (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4 overflow-y-auto" onClick={onClose}>
<div className="bg-white rounded-lg max-w-2xl w-full p-5 shadow-xl my-8" onClick={e => e.stopPropagation()}>
<div className="text-sm text-gray-500 mb-1">{clarification.source}</div>
<div className="text-base font-medium mb-4">{clarification.question}</div>
<div className="grid grid-cols-2 gap-3 mb-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Zugewiesen an</label>
<input
type="text"
value={assignedTo}
onChange={e => setAssignedTo(e.target.value)}
className="w-full border rounded p-2 text-sm"
placeholder="z.B. anlagenbauer@fanuc.de"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Bearbeiter</label>
<input
type="text"
value={answeredBy}
onChange={e => setAnsweredBy(e.target.value)}
className="w-full border rounded p-2 text-sm"
placeholder="Name oder Kürzel"
/>
</div>
</div>
<label className="block text-xs font-medium text-gray-700 mb-1">Status</label>
<div className="flex gap-1 mb-3 text-sm">
{(['open', 'in_progress', 'answered', 'not_relevant'] as const).map(s => (
<button
key={s}
onClick={() => setStatus(s)}
className={`px-3 py-1 rounded border ${status === s ? 'bg-blue-600 text-white border-blue-600' : 'bg-white hover:bg-gray-50'}`}
>
{STATUS_LABEL[s]}
</button>
))}
</div>
{(status === 'answered' || status === 'in_progress') && (
<>
<label className="block text-xs font-medium text-gray-700 mb-1">Antwort</label>
<div className="flex gap-1 mb-3 text-sm">
{(['ja', 'teilweise', 'nein'] as const).map(a => (
<button
key={a}
onClick={() => setAnswer(a)}
className={`px-3 py-1 rounded border ${answer === a ? 'bg-blue-600 text-white border-blue-600' : 'bg-white hover:bg-gray-50'}`}
>
{a}
</button>
))}
</div>
</>
)}
<label className="block text-xs font-medium text-gray-700 mb-1">Begründung / Notiz</label>
<textarea
value={reasoning}
onChange={e => setReasoning(e.target.value)}
rows={4}
className="w-full border rounded p-2 text-sm mb-4"
placeholder="z.B. Pruefprotokoll vom 12.03.2024 vom Anlagenbauer FANUC vorgelegt; DCS-Konfig liegt bei."
/>
{/* Comment Thread */}
<div className="border-t pt-3 mt-3 mb-3">
<div className="text-xs font-medium text-gray-700 mb-2">Diskussion ({comments.length})</div>
<div className="space-y-2 max-h-40 overflow-y-auto mb-2">
{comments.map(c => (
<div key={c.id} className="text-xs bg-gray-50 rounded p-2">
<div className="font-medium text-gray-700">{c.author || 'anonym'} <span className="text-gray-400 font-normal">· {c.created_at.slice(0, 16).replace('T', ' ')}</span></div>
<div className="text-gray-700 whitespace-pre-wrap">{c.body}</div>
</div>
))}
{comments.length === 0 && <div className="text-xs text-gray-400 italic">Noch keine Kommentare.</div>}
</div>
<div className="flex gap-1">
<input
type="text"
value={newComment}
onChange={e => setNewComment(e.target.value)}
placeholder="Kommentar hinzufügen..."
className="flex-1 border rounded px-2 py-1.5 text-xs"
onKeyDown={e => { if (e.key === 'Enter') postComment() }}
/>
<button
onClick={postComment}
disabled={postingComment || !newComment.trim()}
className="px-3 py-1 rounded bg-gray-700 text-white text-xs hover:bg-gray-800 disabled:opacity-50"
>Senden</button>
</div>
</div>
{history.length > 0 && (
<details className="mb-3 text-xs">
<summary className="cursor-pointer text-gray-600 hover:text-gray-800">Verlauf ({history.length})</summary>
<div className="mt-1 space-y-1 text-gray-600">
{history.map((h, i) => (
<div key={i} className="border-l-2 border-gray-200 pl-2">
<span className="text-gray-400">{h.created_at.slice(0, 16).replace('T', ' ')}</span> ·
<strong> {h.actor || 'unbekannt'}</strong>: {h.from_status} → {h.to_status}
{h.from_answer !== h.to_answer && ` (Antwort ${h.from_answer || '—'} → ${h.to_answer || '—'})`}
</div>
))}
</div>
</details>
)}
{error && <div className="text-red-600 text-sm mb-2">Fehler: {error}</div>}
<div className="flex justify-end gap-2 text-sm">
<button onClick={onClose} className="px-3 py-1.5 rounded border bg-white hover:bg-gray-50">Abbrechen</button>
<button onClick={save} disabled={saving} className="px-3 py-1.5 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50">
{saving ? 'Speichere…' : 'Speichern'}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,211 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useParams } from 'next/navigation'
type Suggestion = {
name: string
reduction_type: 'design' | 'protection' | 'information' | string
description: string
source_project_count: number
source_project_names: string[]
is_customer_standard: boolean
has_verified_instances: boolean
}
type ProjectInfo = { customer_name?: string; machine_name?: string }
// /sdk/iace/[projectId]/customer-standards
//
// Surfaces mitigations that the expert flagged as "Kundenstandard" (or
// successfully verified) in earlier projects of the SAME customer. Picking
// one and clicking "Übernehmen" applies it to all matching hazards in the
// current project — every match is set to is_relevant=true,
// is_customer_standard=true, status='verified'. Saves the round-trip
// through Massnahmen + Verifikation for the cases where the safety expert
// already knows the answer from a prior plant at the same site.
//
// Filter "Auch verifizierte einbeziehen" widens the pool beyond strictly
// is_customer_standard=true to also include status='verified' rows — useful
// when the customer-standard habit is not yet established in the corpus.
export default function CustomerStandardsPage() {
const params = useParams()
const projectId = params.projectId as string
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
const [project, setProject] = useState<ProjectInfo | null>(null)
const [loading, setLoading] = useState(true)
const [includeVerified, setIncludeVerified] = useState(false)
const [importing, setImporting] = useState<string | null>(null)
const [importedNames, setImportedNames] = useState<Set<string>>(new Set())
const [selected, setSelected] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [sgRes, prRes] = await Promise.all([
fetch(`/api/sdk/v1/iace/projects/${projectId}/customer-standards?include_verified=${includeVerified}`),
fetch(`/api/sdk/v1/iace/projects/${projectId}`),
])
if (sgRes.ok) {
const j = await sgRes.json()
setSuggestions(j.suggestions || [])
}
if (prRes.ok) {
const j = await prRes.json()
const p = j.project || j
setProject({ customer_name: p.customer_name, machine_name: p.machine_name })
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setLoading(false)
}
}, [projectId, includeVerified])
useEffect(() => { load() }, [load])
function toggleSelect(name: string) {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(name)) next.delete(name); else next.add(name)
return next
})
}
async function importOne(name: string) {
setImporting(name)
try {
const r = await fetch(`/api/sdk/v1/iace/projects/${projectId}/customer-standards/import`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
if (r.ok) {
setImportedNames((prev) => new Set(prev).add(name))
setSelected((prev) => { const n = new Set(prev); n.delete(name); return n })
} else {
const j = await r.json().catch(() => null)
setError(j?.error || `HTTP ${r.status}`)
}
} finally {
setImporting(null)
}
}
async function importSelected() {
const names = Array.from(selected)
for (const n of names) {
await importOne(n)
}
}
if (loading) return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
)
// No customer set → guide the user to set it first
const hasCustomer = !!(project?.customer_name && project.customer_name.trim() !== '')
if (!hasCustomer) {
return (
<div className="space-y-4 max-w-3xl">
<h1 className="text-2xl font-bold">Kundenstandards</h1>
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
Dieses Projekt hat noch keinen <em>Kundennamen</em>. Damit Massnahmen aus früheren
Anlagen desselben Kunden wiederverwendet werden können, trage den Kundennamen
unter <a className="text-purple-700 underline" href={`/sdk/iace/${projectId}/order`}>Auftrag Kunde</a> ein.
Sobald der Kundenname gesetzt ist, erscheint hier die Liste der wiederverwendbaren
Maßnahmen aus seinen Vorprojekten.
</div>
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-baseline justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Kundenstandards</h1>
<p className="mt-1 text-sm text-gray-500">
Übernimm Maßnahmen, die der Kunde <strong>{project?.customer_name}</strong> in
anderen Anlagen bereits als Standard etabliert hat. Übernehmen setzt sie für alle
passenden Gefährdungen <em>relevant</em> und <em>verifiziert</em> ohne Nachweis.
</p>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-gray-600">
<input type="checkbox" checked={includeVerified}
onChange={(e) => setIncludeVerified(e.target.checked)}
className="accent-purple-600" />
Auch <em>verifizierte</em> einbeziehen
</label>
{selected.size > 0 && (
<button onClick={importSelected} disabled={!!importing}
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
{importing ? 'Übernehme…' : `${selected.size} übernehmen`}
</button>
)}
</div>
</div>
{error && <div className="text-red-600 text-sm">Fehler: {error}</div>}
{suggestions.length === 0 && (
<div className="rounded-md border border-gray-200 bg-gray-50 px-4 py-6 text-sm text-gray-600">
Keine wiederverwendbaren Maßnahmen für <strong>{project?.customer_name}</strong> gefunden.
{!includeVerified && ' Aktiviere „Auch verifizierte einbeziehen" oben rechts, um den Pool zu erweitern.'}
</div>
)}
{suggestions.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="grid grid-cols-[28px_2fr_120px_100px_120px] gap-3 px-4 py-2 bg-gray-50 dark:bg-gray-750 text-xs font-medium text-gray-500 uppercase tracking-wider">
<div />
<div>Massnahme</div>
<div className="text-center">Vorprojekte</div>
<div>Status</div>
<div className="text-right">Aktion</div>
</div>
{suggestions.map((s) => {
const imported = importedNames.has(s.name)
return (
<div key={s.name} className={`grid grid-cols-[28px_2fr_120px_100px_120px] gap-3 px-4 py-2.5 border-t border-gray-100 dark:border-gray-700 ${imported ? 'bg-green-50/40' : ''} ${selected.has(s.name) ? 'bg-purple-50' : ''}`}>
<div className="pt-0.5">
<input type="checkbox" checked={selected.has(s.name)} onChange={() => toggleSelect(s.name)} disabled={imported}
className="accent-purple-600" />
</div>
<div className="min-w-0">
<div className="text-sm text-gray-900 dark:text-white">{s.name}</div>
{s.description && <div className="text-[11px] text-gray-500 mt-0.5 line-clamp-2">{s.description}</div>}
{s.source_project_names.length > 0 && (
<div className="text-[10px] text-gray-400 mt-1">aus: {s.source_project_names.slice(0,3).join(', ')}{s.source_project_names.length > 3 ? ` (+${s.source_project_names.length - 3})` : ''}</div>
)}
</div>
<div className="text-center self-center">
<span className="text-sm font-semibold text-purple-700">{s.source_project_count}×</span>
</div>
<div className="self-center flex flex-wrap gap-1">
{s.is_customer_standard && <span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-700">Kundenstandard</span>}
{s.has_verified_instances && !s.is_customer_standard && <span className="text-[10px] px-1.5 py-0.5 rounded bg-green-100 text-green-700">Verifiziert</span>}
</div>
<div className="text-right self-center">
{imported ? (
<span className="text-[11px] text-green-700"> Übernommen</span>
) : (
<button onClick={() => importOne(s.name)} disabled={!!importing}
className="px-2.5 py-1 text-[11px] bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50">
{importing === s.name ? 'Übernehme…' : 'Übernehmen'}
</button>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
}
@@ -13,6 +13,9 @@ export interface Mitigation {
verified_by: string | null
source?: string
operational_states?: string[]
// Expert flags (migration 029).
is_relevant?: boolean
is_customer_standard?: boolean
}
export interface Hazard {
@@ -45,6 +45,8 @@ export function useMitigations(projectId: string) {
created_at: (m.created_at || '') as string,
verified_at: (m.verified_at || null) as string | null,
verified_by: (m.verified_by || null) as string | null,
is_relevant: Boolean(m.is_relevant),
is_customer_standard: Boolean(m.is_customer_standard),
operational_states: (() => {
const ids = m.linked_hazard_ids ? (m.linked_hazard_ids as string[]) : m.hazard_id ? [m.hazard_id as string] : []
const states = new Set<string>()
@@ -151,6 +153,48 @@ export function useMitigations(projectId: string) {
}
}
// Bulk delete without per-row confirm; caller owns the confirm-step.
async function handleDeleteSilent(id: string) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}`, { method: 'DELETE' })
if (!res.ok) console.error('delete failed for', id, res.status)
} catch (err) {
console.error('Failed to delete mitigation:', err)
}
}
// Flag a mitigation as relevant for this project (or unflag). Optimistic:
// updates local state immediately, refetches afterwards.
async function handleSetRelevant(id: string, value: boolean) {
setMitigations((prev) => prev.map((m) => m.id === id ? { ...m, status: m.status } : m))
try {
await fetch(`/api/sdk/v1/iace/mitigations/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_relevant: value }),
})
await fetchData()
} catch (err) {
console.error('Failed to set relevant flag:', err)
}
}
// Mark a mitigation as "customer standard" — already implemented at the
// customer's site, no evidence required. Implies is_relevant=true (server
// enforces this via the CHECK constraint).
async function handleSetCustomerStandard(id: string, value: boolean) {
try {
await fetch(`/api/sdk/v1/iace/mitigations/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_customer_standard: value }),
})
await fetchData()
} catch (err) {
console.error('Failed to set customer-standard flag:', err)
}
}
const byType = {
design: mitigations.filter((m) => m.reduction_type === 'design'),
protection: mitigations.filter((m) => m.reduction_type === 'protection'),
@@ -159,7 +203,8 @@ export function useMitigations(projectId: string) {
return {
mitigations, hazards, loading, hierarchyWarning, setHierarchyWarning,
measures, byType,
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify, handleDelete,
measures, byType, fetchData,
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify,
handleDelete, handleDeleteSilent, handleSetRelevant, handleSetCustomerStandard,
}
}
@@ -18,8 +18,9 @@ export default function MitigationsPage() {
const {
hazards, loading, hierarchyWarning, setHierarchyWarning,
measures, byType,
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure, handleVerify, handleDelete,
measures, byType, fetchData,
fetchMeasuresLibrary, handleSubmit, handleAddSuggestedMeasure,
handleDelete, handleDeleteSilent, handleSetRelevant,
} = useMitigations(projectId)
const [measureNorms, setMeasureNorms] = useState<Record<string, string[]>>({})
@@ -47,48 +48,66 @@ export default function MitigationsPage() {
const [showSuggest, setShowSuggest] = useState(false)
const [expanded, setExpanded] = useState<Record<string, boolean>>({ design: true, protection: true, information: true })
const [mitPages, setMitPages] = useState<Record<string, number>>({ design: 1, protection: 1, information: 1 })
const [selected, setSelected] = useState<Set<string>>(new Set())
const [batchAction, setBatchAction] = useState<'verify' | 'delete' | null>(null)
const [expandedMeasure, setExpandedMeasure] = useState<string | null>(null)
// Group-Expand: key = `${type}:${title}` so the same title in different
// reduction stages stays independently togglable.
const [expandedGroup, setExpandedGroup] = useState<Set<string>>(new Set())
function toggleGroup(key: string) {
setExpandedGroup((prev) => {
const next = new Set(prev)
if (next.has(key)) next.delete(key); else next.add(key)
return next
})
}
// Mitigations sharing the same title (e.g. "Sicherheitszeichen nach ISO 7010"
// applied to 21 hazards) collapse into a single group row. Each instance
// keeps its own DB id, status and notes — the grouping is presentation-only.
//
// Within a group we additionally deduplicate by hazard_id: the engine
// sometimes emits the same (name, hazard_id) pair twice when "Neu
// initialisieren" is clicked repeatedly. We pick the row that already
// carries user state (is_relevant=true preferred, then newest created_at)
// so the expert's decisions are not lost. The DB still holds both rows;
// a separate migration adds a UNIQUE(hazard_id, name) constraint to
// prevent the duplicates upstream.
function groupByTitle(items: Mitigation[]): Array<{ title: string; instances: Mitigation[] }> {
const map = new Map<string, Mitigation[]>()
for (const m of items) {
const key = (m.title || '').trim() || '(ohne Titel)'
const arr = map.get(key)
if (arr) arr.push(m); else map.set(key, [m])
}
return Array.from(map.entries()).map(([title, instances]) => {
const byHazard = new Map<string, Mitigation>()
for (const m of instances) {
const hid = (m.linked_hazard_ids || []).join('|') || m.id
const prev = byHazard.get(hid)
if (!prev) { byHazard.set(hid, m); continue }
// Tie-break: prefer is_relevant=true, then newest created_at
const score = (x: Mitigation) => (x.is_relevant ? 2 : 0) + (x.created_at > (prev.created_at || '') ? 1 : 0)
if (score(m) > score(prev)) byHazard.set(hid, m)
}
return { title, instances: Array.from(byHazard.values()) }
})
}
// Compact status distribution: returns counts for the three known states.
function statusCounts(instances: Mitigation[]) {
const c = { planned: 0, implemented: 0, verified: 0 }
for (const m of instances) {
if (m.status === 'planned') c.planned++
else if (m.status === 'implemented') c.implemented++
else if (m.status === 'verified') c.verified++
}
return c
}
function toggleSection(type: string) {
setExpanded((prev) => ({ ...prev, [type]: !prev[type] }))
}
function toggleSelect(id: string) {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
}
function selectAllInType(type: string) {
const items = byType[type as keyof typeof byType]
setSelected((prev) => {
const next = new Set(prev)
const allSelected = items.every((m) => next.has(m.id))
if (allSelected) { items.forEach((m) => next.delete(m.id)) }
else { items.forEach((m) => next.add(m.id)) }
return next
})
}
async function handleBatchVerify() {
setBatchAction('verify')
for (const id of selected) { await handleVerify(id) }
setSelected(new Set())
setBatchAction(null)
}
async function handleBatchDelete() {
if (!confirm(`${selected.size} Massnahmen wirklich loeschen?`)) return
setBatchAction('delete')
for (const id of selected) { await handleDelete(id) }
setSelected(new Set())
setBatchAction(null)
}
function handleOpenLibrary(type?: string) {
setLibraryFilter(type)
fetchMeasuresLibrary(type)
@@ -122,43 +141,31 @@ export default function MitigationsPage() {
</p>
</div>
<div className="flex items-center gap-2">
{selected.size > 0 && (
<>
<span className="text-xs text-gray-500">{selected.size} ausgewaehlt</span>
<button onClick={handleBatchVerify} disabled={batchAction !== null}
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
{batchAction === 'verify' ? 'Verifiziere...' : 'Verifizieren'}
</button>
<button onClick={handleBatchDelete} disabled={batchAction !== null}
className="px-3 py-1.5 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50">
Loeschen
</button>
<button onClick={() => setSelected(new Set())} className="px-2 py-1.5 text-xs text-gray-500 hover:text-gray-700">
Abbrechen
</button>
</>
)}
{selected.size === 0 && (
<>
<button onClick={() => setShowSuggest(true)}
className="px-3 py-1.5 text-xs border border-green-300 text-green-700 rounded-lg hover:bg-green-50">
Vorschlaege
</button>
<button onClick={() => handleOpenLibrary()}
className="px-3 py-1.5 text-xs border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50">
Bibliothek
</button>
<button onClick={() => { setPreselectedType(undefined); setShowForm(true) }}
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700">
+ Hinzufuegen
</button>
</>
)}
<button onClick={() => setShowSuggest(true)}
className="px-3 py-1.5 text-xs border border-green-300 text-green-700 rounded-lg hover:bg-green-50">
Vorschlaege
</button>
<button onClick={() => handleOpenLibrary()}
className="px-3 py-1.5 text-xs border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50">
Bibliothek
</button>
<button onClick={() => { setPreselectedType(undefined); setShowForm(true) }}
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700">
+ Hinzufuegen
</button>
</div>
</div>
{hierarchyWarning && <HierarchyWarning onDismiss={() => setHierarchyWarning(false)} />}
{/* Reinitialisieren-Warnung: nach manuellem Loeschen wuerde ein Reinit
die geloeschten Engine-Vorschlaege wiederherstellen. */}
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-2.5 text-xs text-amber-900">
<strong>Hinweis:</strong> Markiere jede Maßnahme als <em>Relevant</em> () oder lösche sie aus dem Projekt (🗑).
Nur als <em>relevant</em> markierte Maßnahmen erscheinen in der Verifikation.
<strong> Achtung:</strong> nach dem Löschen kein <em>Neu initialisieren</em> mehr drücken sonst werden die gelöschten Vorschläge aus den Engine-Daten wiederhergestellt.
</div>
{showForm && (
<MitigationForm
onSubmit={async (data) => { const ok = await handleSubmit(data); if (ok) setShowForm(false) }}
@@ -173,7 +180,6 @@ export default function MitigationsPage() {
const config = REDUCTION_TYPES[type]
const items = byType[type]
const isExpanded = expanded[type]
const allSelected = items.length > 0 && items.every((m) => selected.has(m.id))
return (
<div key={type} className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
@@ -191,68 +197,133 @@ export default function MitigationsPage() {
<span className="text-sm font-bold">{items.length}</span>
</button>
{/* Accordion Content — Table rows */}
{isExpanded && items.length > 0 && (
{/* Accordion Content — grouped by measure title */}
{isExpanded && items.length > 0 && (() => {
const groups = groupByTitle(items)
const visibleGroups = groups.slice(0, (mitPages[type] || 1) * 50)
return (
<div className="border-t border-gray-100 dark:border-gray-700">
{/* Table header */}
<div className="grid grid-cols-[24px_2fr_1fr_80px] gap-2 px-4 py-2 bg-gray-50 dark:bg-gray-750 text-xs font-medium text-gray-500 uppercase tracking-wider">
<div>
<input type="checkbox" checked={allSelected} onChange={() => selectAllInType(type)}
className="accent-purple-600" title="Alle auswaehlen" />
</div>
<div className="grid grid-cols-[36px_36px_2fr_120px_110px] gap-2 px-4 py-2 bg-gray-50 dark:bg-gray-750 text-xs font-medium text-gray-500 uppercase tracking-wider">
<div title="Relevant fuer dieses Projekt">Relev.</div>
<div />
<div>Massnahme</div>
<div>Gefaehrdung</div>
<div>Status</div>
<div className="text-right pr-2">Gefährdungen</div>
<div>Status (P · I · V)</div>
</div>
{/* Rows — paginated */}
{items.slice(0, (mitPages[type] || 1) * 50).map((m) => {
const isDetailOpen = expandedMeasure === m.id
const catMatch = (m.description || '').match(/Kategorie\s+(\S+)/)
{visibleGroups.map(({ title, instances }) => {
const groupKey = `${type}:${title}`
const isGroupOpen = expandedGroup.has(groupKey)
// (legacy bulk-select removed — Relevant-checkbox is now the primary mass-action)
const counts = statusCounts(instances)
const refs = measureNorms[title.toLowerCase()]
const first = instances[0]
const description = first?.description || ''
const catMatch = description.match(/Kategorie\s+(\S+)/)
const category = catMatch?.[1]
const refs = measureNorms[(m.title || '').toLowerCase()]
const relevantInGroup = instances.filter((m) => m.is_relevant).length
const allRelevant = relevantInGroup === instances.length
return (
<div key={m.id}>
<div onClick={() => setExpandedMeasure(isDetailOpen ? null : m.id)}
className={`grid grid-cols-[24px_2fr_1fr_80px] gap-2 px-4 py-2 border-t border-gray-50 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors cursor-pointer ${selected.has(m.id) ? 'bg-purple-50 dark:bg-purple-900/10' : ''}`}>
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
<input type="checkbox" checked={selected.has(m.id)} onChange={() => toggleSelect(m.id)}
className="accent-purple-600" />
</div>
<div className="min-w-0 flex items-start gap-1">
<svg className={`w-3 h-3 mt-1 shrink-0 text-gray-400 transition-transform ${isDetailOpen ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<div>
<div className="text-sm text-gray-900 dark:text-white">{m.title || ''}</div>
{!isDetailOpen && category && <div className="text-[10px] text-gray-400 mt-0.5">Kategorie: {category}</div>}
<div key={groupKey}>
{/* Group header row */}
<div onClick={() => toggleGroup(groupKey)}
className={`grid grid-cols-[36px_36px_2fr_120px_110px] gap-2 px-4 py-2 border-t border-gray-50 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors cursor-pointer`}>
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
<input type="checkbox" checked={allRelevant} ref={(el) => { if (el) el.indeterminate = !allRelevant && relevantInGroup > 0 }}
onChange={async (e) => {
const target = e.target.checked
for (const m of instances) {
if (m.is_relevant !== target) await handleSetRelevant(m.id, target)
}
}}
className="accent-purple-600" title={`${relevantInGroup}/${instances.length} als relevant markiert. Klick: alle als ${allRelevant ? 'nicht relevant' : 'relevant'} markieren.`} />
</div>
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
<button onClick={async () => {
if (!confirm(`Alle ${instances.length} Instanzen von "${title}" loeschen?`)) return
for (const m of instances) await handleDeleteSilent(m.id)
await fetchData()
}} className="text-gray-400 hover:text-red-600" title="Ganze Gruppe loeschen">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M16 7V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v3" />
</svg>
</button>
</div>
<div className="min-w-0 flex items-start gap-1">
<svg className={`w-3 h-3 mt-1 shrink-0 text-gray-400 transition-transform ${isGroupOpen ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<div>
<div className="text-sm text-gray-900 dark:text-white">{title}</div>
{category && <div className="text-[10px] text-gray-400 mt-0.5">Kategorie: {category}</div>}
</div>
</div>
<div className="text-xs text-gray-500 text-right pr-2">{instances.length}</div>
<div className="text-xs flex items-center gap-1.5 font-mono">
<span className="text-gray-500" title={`${counts.planned} geplant`}>{counts.planned}</span>
<span className="text-gray-300">·</span>
<span className="text-blue-600" title={`${counts.implemented} umgesetzt`}>{counts.implemented}</span>
<span className="text-gray-300">·</span>
<span className="text-green-600" title={`${counts.verified} verifiziert`}>{counts.verified}</span>
</div>
</div>
<div className="text-xs text-gray-500">
{(m.linked_hazard_names || []).join(', ') || '-'}
</div>
<div>
<StatusBadge status={m.status} />
</div>
{/* Group children — one row per instance (hazard) */}
{isGroupOpen && (
<div className="bg-gray-50/40 dark:bg-gray-900/20 border-t border-gray-100 dark:border-gray-700">
{description && (
<p className="px-12 pt-2 pb-1 text-[11px] text-gray-500 dark:text-gray-400 italic">{description}</p>
)}
{refs?.length > 0 && (
<p className="px-12 pb-2 text-[11px] text-blue-500">Normen: {refs.join(', ')}</p>
)}
{instances.map((m) => {
const isDetailOpen = expandedMeasure === m.id
return (
<div key={m.id}>
<div onClick={() => setExpandedMeasure(isDetailOpen ? null : m.id)}
className={`grid grid-cols-[36px_36px_2fr_120px_110px] gap-2 px-4 py-1.5 border-t border-gray-100 dark:border-gray-700 hover:bg-white dark:hover:bg-gray-800 transition-colors cursor-pointer ${m.is_relevant ? 'bg-emerald-50/40 dark:bg-emerald-900/10' : ''}`}>
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
<input type="checkbox" checked={Boolean(m.is_relevant)} onChange={() => handleSetRelevant(m.id, !m.is_relevant)}
className="accent-purple-600" title="Als relevant markieren" />
</div>
<div className="pt-0.5" onClick={(e) => e.stopPropagation()}>
<button onClick={() => handleDelete(m.id)}
className="text-gray-400 hover:text-red-600" title="Loeschen">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6M1 7h22M16 7V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v3" />
</svg>
</button>
</div>
<div className="text-xs text-gray-600 dark:text-gray-300 min-w-0">
{(m.linked_hazard_names || []).join(', ') || '— (keine Gefaehrdung verknuepft)'}
</div>
<div className="text-[11px] text-gray-400 self-center text-right pr-2">
{m.is_customer_standard ? 'Kundenstandard' : ''}
</div>
<div><StatusBadge status={m.status} /></div>
</div>
{isDetailOpen && (
<div className="px-12 py-2 bg-white dark:bg-gray-800 border-t border-gray-100 dark:border-gray-700 text-xs space-y-1">
<MitigationHints projectId={projectId} mitigationId={m.id} />
</div>
)}
</div>
)
})}
</div>
)}
</div>
{isDetailOpen && (
<div className="px-12 py-3 bg-gray-50 dark:bg-gray-750 border-t border-gray-100 dark:border-gray-700 text-xs space-y-1">
{m.description && <p className="text-gray-600 dark:text-gray-300">{m.description}</p>}
{category && <p className="text-purple-600">Diese Massnahme gilt fuer alle Gefaehrdungen der Kategorie <strong>{category}</strong>.</p>}
{refs?.length > 0 && <p className="text-blue-500">Normen: {refs.join(', ')}</p>}
<MitigationHints projectId={projectId} mitigationId={m.id} />
</div>
)}
</div>
)
})}
{items.length > (mitPages[type] || 1) * 50 && (
{groups.length > visibleGroups.length && (
<button onClick={() => setMitPages(prev => ({ ...prev, [type]: (prev[type] || 1) + 1 }))}
className="w-full py-2 text-xs text-purple-600 hover:bg-purple-50 border-t border-gray-100 transition-colors">
Weitere {Math.min(50, items.length - (mitPages[type] || 1) * 50)} von {items.length} laden...
Weitere {Math.min(50, groups.length - visibleGroups.length)} von {groups.length} Maßnahmen laden...
</button>
)}
</div>
)}
)
})()}
{isExpanded && items.length === 0 && (
<div className="px-4 py-6 text-center text-sm text-gray-400 border-t border-gray-100">
@@ -68,10 +68,14 @@ export default function OrderPage() {
setSaveState('saving')
try {
const merged = { ...existingMetaRef.current, order_data: next }
// Mirror Auftraggeber.Firmenname into the top-level customer_name
// column so the Customer-Standards-Reuse feature can index by it.
// Empty string → null on the backend, no broken reuse for fresh projects.
const customerName = (next.client.company || '').trim()
await fetch(`/api/sdk/v1/iace/projects/${projectId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metadata: merged }),
body: JSON.stringify({ metadata: merged, customer_name: customerName }),
})
existingMetaRef.current = merged
setSaveState('saved')
@@ -1,86 +1,41 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useState } from 'react'
import { useParams } from 'next/navigation'
import type { VerificationItem, VerificationFormData } from './_components/verification-types'
import { VerificationForm } from './_components/VerificationForm'
import { CompleteModal } from './_components/CompleteModal'
import { SuggestEvidenceModal } from './_components/SuggestEvidenceModal'
import { VerificationTable } from './_components/VerificationTable'
import { useMitigations } from '../mitigations/_hooks/useMitigations'
import type { Mitigation } from '../mitigations/_components/types'
// Verifikations-Page (Phase-1 Workflow):
//
// Diese Seite ist eine abgeleitete View auf die Maßnahmen-Liste. Sie zeigt
// nur diejenigen Maßnahmen, die der Fachmann auf der Maßnahmen-Seite als
// `is_relevant = true` markiert hat. Pro Maßnahme stehen zwei Aktionen
// zur Verfügung:
//
// 1. "Beim Kunden Standard" — Die Maßnahme ist beim Kunden bereits
// umgesetzt (z.B. firmenweite Vorgabe, identische Vor-Anlage).
// Setzt is_customer_standard = true und status = verified.
// Es ist kein Nachweis-Dokument erforderlich.
//
// 2. "Verifizieren (mit Nachweis)" — Öffnet ein Modal, in dem der
// Verifizierer einen Text-Nachweis hinterlegt (Prüfprotokoll-Nummer,
// Abnahme-Referenz, etc.). Setzt status = verified. Die File-Upload-
// Variante folgt in Phase 2, sobald ein Object-Storage-Backend
// verfügbar ist.
//
// Wenn die Maßnahme bereits verifiziert ist, wird ein "Zurücksetzen"-Link
// angeboten — er stellt status auf 'implemented' zurück, damit der
// Fachmann eine versehentliche Bestätigung rückgängig machen kann.
export default function VerificationPage() {
const params = useParams()
const projectId = params.projectId as string
const [items, setItems] = useState<VerificationItem[]>([])
const [hazards, setHazards] = useState<{ id: string; name: string }[]>([])
const [mitigations, setMitigations] = useState<{ id: string; title: string }[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [completingItem, setCompletingItem] = useState<VerificationItem | null>(null)
const [showSuggest, setShowSuggest] = useState(false)
useEffect(() => { fetchData() }, [projectId])
const { byType, loading, handleSetCustomerStandard } = useMitigations(projectId)
async function fetchData() {
try {
// Only load verifications initially — hazards/mitigations loaded on demand
const verRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`)
if (verRes.ok) { const j = await verRes.json(); setItems(j.verifications || j || []) }
} catch (err) { console.error('Failed to fetch data:', err) }
finally { setLoading(false) }
}
async function loadMitigationsIfNeeded() {
if (mitigations.length > 0) return
try {
const mitRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`)
if (mitRes.ok) {
const j = await mitRes.json()
const mits = (j.mitigations || j || []).map((m: Record<string, string>) => ({ id: m.id, title: m.title || m.name || '' }))
setMitigations(mits)
}
} catch { /* ignore */ }
}
async function handleSubmit(data: VerificationFormData) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
})
if (res.ok) { setShowForm(false); await fetchData() }
} catch (err) { console.error('Failed to add verification:', err) }
}
async function handleAddSuggestedEvidence(title: string, description: string, method: string, mitigationId: string) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description, method, linked_mitigation_id: mitigationId }),
})
if (res.ok) await fetchData()
} catch (err) { console.error('Failed to add suggested evidence:', err) }
}
async function handleComplete(id: string, result: string, passed: boolean) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}/complete`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ result, passed }),
})
if (res.ok) { setCompletingItem(null); await fetchData() }
} catch (err) { console.error('Failed to complete verification:', err) }
}
async function handleDelete(id: string) {
if (!confirm('Verifikation wirklich loeschen?')) return
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}`, { method: 'DELETE' })
if (res.ok) await fetchData()
} catch (err) { console.error('Failed to delete verification:', err) }
}
const completed = items.filter(i => i.status === 'completed').length
const failed = items.filter(i => i.status === 'failed').length
const pending = items.filter(i => i.status === 'pending' || i.status === 'in_progress').length
const [verifyTarget, setVerifyTarget] = useState<Mitigation | null>(null)
const [verifyResult, setVerifyResult] = useState('')
const [submitting, setSubmitting] = useState(false)
if (loading) return (
<div className="flex items-center justify-center h-64">
@@ -88,82 +43,191 @@ export default function VerificationPage() {
</div>
)
const allRelevant = [...byType.design, ...byType.protection, ...byType.information].filter((m) => m.is_relevant)
const groups = groupByTitle(allRelevant)
const totals = {
total: allRelevant.length,
verified: allRelevant.filter((m) => m.status === 'verified').length,
customerStd: allRelevant.filter((m) => m.is_customer_standard).length,
pending: allRelevant.filter((m) => m.status !== 'verified').length,
}
async function setStatus(id: string, value: 'implemented' | 'verified') {
await fetch(`/api/sdk/v1/iace/mitigations/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: value }),
})
}
async function submitVerify() {
if (!verifyTarget) return
setSubmitting(true)
try {
await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${verifyTarget.id}/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ verification_result: verifyResult }),
})
// Refetch via window-reload of just the data — useMitigations refreshes on mount.
window.location.reload()
} finally {
setSubmitting(false)
setVerifyTarget(null)
setVerifyResult('')
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Verifikationsplan</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Nachweisfuehrung fuer alle Schutzmassnahmen und Sicherheitsanforderungen.</p>
</div>
<div className="flex items-center gap-2">
{true && (
<button onClick={async () => { await loadMitigationsIfNeeded(); setShowSuggest(true) }} className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors text-sm">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
Nachweise vorschlagen
</button>
)}
<button onClick={() => setShowForm(true)} className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Verifikation hinzufuegen
</button>
</div>
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Verifikation</h1>
<p className="mt-1 text-sm text-gray-500">
Bestätige die Umsetzung jeder als relevant markierten Maßnahme entweder als
<em> Kundenstandard</em> (keine Nachweis-Datei nötig) oder mit hinterlegtem Nachweis.
</p>
</div>
{items.length > 0 && (
<div className="grid grid-cols-4 gap-3">
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-center">
<div className="text-2xl font-bold text-gray-900 dark:text-white">{items.length}</div>
<div className="text-xs text-gray-500">Gesamt</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-green-200 p-4 text-center">
<div className="text-2xl font-bold text-green-600">{completed}</div>
<div className="text-xs text-green-600">Abgeschlossen</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-red-200 p-4 text-center">
<div className="text-2xl font-bold text-red-600">{failed}</div>
<div className="text-xs text-red-600">Fehlgeschlagen</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-yellow-200 p-4 text-center">
<div className="text-2xl font-bold text-yellow-600">{pending}</div>
<div className="text-xs text-yellow-600">Ausstehend</div>
</div>
{totals.total === 0 ? (
<div className="rounded-md border border-gray-200 bg-gray-50 px-4 py-6 text-sm text-gray-600">
Keine als <em>relevant</em> markierten Maßnahmen vorhanden. Gehe zurück zur
{' '}<a className="text-purple-600 underline" href={`/sdk/iace/${projectId}/mitigations`}>Maßnahmen-Seite</a>{' '}
und kreuze die anwendbaren Maßnahmen an.
</div>
) : (
<>
<div className="grid grid-cols-4 gap-3">
<Stat n={totals.total} label="relevant" tone="gray" />
<Stat n={totals.pending} label="offen" tone="amber" />
<Stat n={totals.verified} label="verifiziert" tone="green" />
<Stat n={totals.customerStd} label="Kundenstandard" tone="blue" />
</div>
{groups.map(({ title, instances }) => {
const verifiedCount = instances.filter((m) => m.status === 'verified').length
const allDone = verifiedCount === instances.length
return (
<div key={title} className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className={`flex items-center gap-3 px-4 py-3 ${allDone ? 'bg-green-50 dark:bg-green-900/20' : 'bg-gray-50 dark:bg-gray-750'}`}>
<div className="flex-1">
<div className="text-sm font-semibold text-gray-900 dark:text-white">{title}</div>
<div className="text-xs text-gray-500">{verifiedCount}/{instances.length} verifiziert</div>
</div>
{allDone && (
<svg className="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
</div>
<div className="divide-y divide-gray-100 dark:divide-gray-700">
{instances.map((m) => {
const isVerified = m.status === 'verified'
return (
<div key={m.id} className={`grid grid-cols-[1fr_240px] gap-3 px-4 py-2.5 items-center ${isVerified ? 'bg-green-50/30 dark:bg-green-900/10' : ''}`}>
<div className="min-w-0">
<div className="text-sm text-gray-700 dark:text-gray-200">
{(m.linked_hazard_names || []).join(', ') || '— (keine Gefährdung verknüpft)'}
</div>
{m.is_customer_standard && (
<div className="text-[11px] text-blue-600 mt-0.5">Beim Kunden Standard</div>
)}
</div>
<div className="flex items-center justify-end gap-2">
{!isVerified ? (
<>
<button onClick={async () => {
await handleSetCustomerStandard(m.id, true)
await setStatus(m.id, 'verified')
window.location.reload()
}} className="px-2.5 py-1 text-[11px] border border-blue-300 text-blue-700 rounded hover:bg-blue-50">
Kundenstandard
</button>
<button onClick={() => { setVerifyTarget(m); setVerifyResult('') }}
className="px-2.5 py-1 text-[11px] bg-green-600 text-white rounded hover:bg-green-700">
Verifizieren
</button>
</>
) : (
<>
<span className="text-[11px] text-green-700"> Verifiziert</span>
<button onClick={async () => {
if (!confirm('Verifizierung zurücksetzen?')) return
await setStatus(m.id, 'implemented')
window.location.reload()
}} className="text-[11px] text-gray-400 hover:text-red-600 underline">
Zurücksetzen
</button>
</>
)}
</div>
</div>
)
})}
</div>
</div>
)
})}
</>
)}
{showForm && <VerificationForm onSubmit={handleSubmit} onCancel={() => setShowForm(false)} hazards={hazards} mitigations={mitigations} />}
{completingItem && <CompleteModal item={completingItem} onSubmit={handleComplete} onClose={() => setCompletingItem(null)} />}
{showSuggest && <SuggestEvidenceModal mitigations={mitigations} projectId={projectId} onAddEvidence={handleAddSuggestedEvidence} onClose={() => setShowSuggest(false)} />}
{items.length > 0 ? (
<VerificationTable items={items} onComplete={setCompletingItem} onDelete={handleDelete} />
) : !showForm && (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Kein Verifikationsplan vorhanden</h3>
<p className="mt-2 text-gray-500 max-w-md mx-auto">
Definieren Sie Verifikationsschritte fuer Ihre Schutzmassnahmen.
Jede Massnahme sollte durch mindestens eine Verifikation abgedeckt sein.
</p>
<div className="mt-6 flex items-center justify-center gap-3">
{mitigations.length > 0 && (
<button onClick={async () => { await loadMitigationsIfNeeded(); setShowSuggest(true) }} className="px-6 py-3 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors">
Nachweise vorschlagen
{verifyTarget && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50">
<div className="bg-white dark:bg-gray-800 rounded-xl max-w-lg w-full p-6 space-y-4">
<div>
<h2 className="text-lg font-semibold">Verifizieren</h2>
<p className="text-sm text-gray-500 mt-1">{verifyTarget.title}</p>
<p className="text-xs text-gray-400 mt-0.5">{(verifyTarget.linked_hazard_names || []).join(', ')}</p>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Nachweis / Prüfprotokoll-Referenz</label>
<textarea value={verifyResult} onChange={(e) => setVerifyResult(e.target.value)}
placeholder="z.B. Prüfprotokoll PM-2026-014 vom 14.05.2026, durchgeführt durch Hr. Schmidt (TÜV Süd)"
className="w-full border rounded px-3 py-2 text-sm h-24" />
<p className="text-[10px] text-gray-400 mt-1">Datei-Upload folgt in Phase 2 vorerst genügt eine eindeutige Referenz.</p>
</div>
<div className="flex items-center justify-end gap-2">
<button onClick={() => setVerifyTarget(null)} disabled={submitting} className="text-xs px-3 py-1.5 text-gray-500 hover:text-gray-700">Abbrechen</button>
<button onClick={submitVerify} disabled={submitting || !verifyResult.trim()}
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50">
{submitting ? 'Speichere…' : 'Verifizieren'}
</button>
)}
<button onClick={() => setShowForm(true)} className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
Erste Verifikation anlegen
</button>
</div>
</div>
</div>
)}
</div>
)
}
function Stat({ n, label, tone }: { n: number; label: string; tone: 'gray' | 'amber' | 'green' | 'blue' }) {
const color =
tone === 'amber' ? 'text-amber-600 border-amber-200' :
tone === 'green' ? 'text-green-600 border-green-200' :
tone === 'blue' ? 'text-blue-600 border-blue-200' :
'text-gray-700 border-gray-200'
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg border p-4 text-center ${color}`}>
<div className="text-2xl font-bold">{n}</div>
<div className="text-xs">{label}</div>
</div>
)
}
function groupByTitle(items: Mitigation[]): Array<{ title: string; instances: Mitigation[] }> {
const map = new Map<string, Mitigation[]>()
for (const m of items) {
const key = (m.title || '').trim() || '(ohne Titel)'
const arr = map.get(key)
if (arr) arr.push(m); else map.set(key, [m])
}
// Frontend dedupe per hazard_id (mirrors mitigations/page.tsx)
return Array.from(map.entries()).map(([title, list]) => {
const byHazard = new Map<string, Mitigation>()
for (const m of list) {
const hid = (m.linked_hazard_ids || []).join('|') || m.id
const prev = byHazard.get(hid)
if (!prev || (m.status === 'verified' && prev.status !== 'verified')) byHazard.set(hid, m)
}
return { title, instances: Array.from(byHazard.values()) }
})
}
+34 -1
View File
@@ -14,7 +14,9 @@ const IACE_NAV_ITEMS = [
{ id: 'components', label: 'Komponenten', href: '/components', icon: 'cube' },
{ id: 'hazards', label: 'Hazard Log', href: '/hazards', icon: 'warning' },
{ id: 'mitigations', label: 'Massnahmen', href: '/mitigations', icon: 'shield' },
{ id: 'clarifications', label: 'Klärungen', href: '/clarifications', icon: 'chat' },
{ id: 'verification', label: 'Verifikation', href: '/verification', icon: 'check' },
{ id: 'customer-standards', label: 'Kundenstandards', href: '/customer-standards', icon: 'building' },
{ id: 'evidence', label: 'Nachweise', href: '/evidence', icon: 'document' },
{ id: 'tech-file', label: 'CE-Akte', href: '/tech-file', icon: 'folder' },
]
@@ -66,6 +68,12 @@ function NavIcon({ icon, className }: { icon: string; className?: string }) {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
case 'building':
return (
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0H5m14 0h2m-16 0H3m4-4h2m-2-4h2m-2-4h2m4 8h2m-2-4h2m-2-4h2" />
</svg>
)
case 'document':
return (
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -115,6 +123,23 @@ export default function IACELayout({ children }: { children: React.ReactNode })
const [variantInfo, setVariantInfo] = React.useState<{
parentProjectId?: string; parentName?: string; variantCount?: number
}>({})
const [openClarifications, setOpenClarifications] = React.useState<number | null>(null)
// Poll the clarifications endpoint so the sidebar always shows the
// current "offene Klaerungen" counter. Refresh whenever the user
// navigates back to this layout (i.e. when pathname changes).
React.useEffect(() => {
if (!projectId) return
let cancelled = false
fetch(`/api/sdk/v1/iace/projects/${projectId}/clarifications`)
.then(r => r.ok ? r.json() : null)
.then(d => {
if (cancelled || !d || typeof d.open_count !== 'number') return
setOpenClarifications(d.open_count)
})
.catch(() => {})
return () => { cancelled = true }
}, [projectId, pathname])
React.useEffect(() => {
if (!projectId) return
@@ -218,7 +243,15 @@ export default function IACELayout({ children }: { children: React.ReactNode })
}`}
>
<NavIcon icon={item.icon} className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{item.label}</span>
<span className="truncate flex-1">{item.label}</span>
{item.id === 'clarifications' && openClarifications !== null && openClarifications > 0 && (
<span
className="ml-auto inline-flex items-center justify-center min-w-[20px] px-1.5 py-0.5 text-[10px] font-semibold rounded-full bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300"
title={`${openClarifications} offene Klärung${openClarifications === 1 ? '' : 'en'}`}
>
{openClarifications}
</span>
)}
</Link>
))}
</nav>
@@ -199,32 +199,43 @@ function MCDetail({ mc, onBack }: { mc: Record<string, unknown>; onBack: () => v
</div>
) : (
<div className="divide-y divide-gray-50">
{filtered.map((m, i) => (
<div key={i} className="px-4 py-3 hover:bg-gray-50">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-gray-400">{m.control_id}</span>
{m.severity && (
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold ${SEV[m.severity] || 'bg-gray-100 text-gray-600'}`}>
{m.severity}
</span>
{filtered.map((m, i) => {
const inner = (
<>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-gray-400">{m.control_id}</span>
{m.severity && (
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold ${SEV[m.severity] || 'bg-gray-100 text-gray-600'}`}>
{m.severity}
</span>
)}
{m.phase && (
<span className="text-[10px] text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">
{m.phase}
</span>
)}
{m.action && (
<span className="text-[10px] text-gray-400">{m.action}</span>
)}
</div>
<p className="text-sm text-gray-900">{m.title}</p>
{m.regulation_source && (
<p className="text-xs text-blue-600 mt-1">
{m.regulation_source} {m.regulation_article}
</p>
)}
{m.phase && (
<span className="text-[10px] text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">
{m.phase}
</span>
)}
{m.action && (
<span className="text-[10px] text-gray-400">{m.action}</span>
)}
</div>
<p className="text-sm text-gray-900">{m.title}</p>
{m.regulation_source && (
<p className="text-xs text-blue-600 mt-1">
{m.regulation_source} {m.regulation_article}
</p>
)}
</div>
))}
</>
)
return m.control_id ? (
<a key={i}
href={`/sdk/control-library?control=${encodeURIComponent(m.control_id)}`}
className="block px-4 py-3 hover:bg-purple-50/40 transition-colors">
{inner}
</a>
) : (
<div key={i} className="px-4 py-3 hover:bg-gray-50">{inner}</div>
)
})}
{filtered.length === 0 && !loading && (
<div className="p-8 text-center text-gray-400">Keine Controls gefunden</div>
)}
@@ -0,0 +1,152 @@
'use client'
import { useEffect, useState } from 'react'
import { fetchCriterionTree, type QuaidalControl, type QuaidalCriterionTree } from '../_hooks/useQuaidalData'
interface Props {
sectionId: string
onClose: () => void
}
function ControlBlock({ ctrl, badgeColor }: { ctrl: QuaidalControl; badgeColor: string }) {
return (
<div className="border border-gray-200 rounded-lg p-4 bg-white">
<div className="flex items-start justify-between gap-3 mb-2">
<h4 className="font-semibold text-gray-900">{ctrl.canonical_name}</h4>
<span className={`px-2 py-0.5 text-xs rounded-full ${badgeColor} shrink-0`}>{ctrl.source.section}</span>
</div>
<p className="text-sm text-gray-600 mb-3 whitespace-pre-line">{ctrl.description}</p>
{ctrl.source.url && (
<a
href={ctrl.source.url}
target="_blank"
rel="noreferrer noopener"
className="text-xs text-purple-600 hover:text-purple-800 underline"
>
BSI-Quelle ansehen ({ctrl.source.framework})
</a>
)}
</div>
)
}
export function QuaidalCriterionDetail({ sectionId, onClose }: Props) {
const [tree, setTree] = useState<QuaidalCriterionTree | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let active = true
setLoading(true)
fetchCriterionTree(sectionId).then(t => {
if (active) {
setTree(t)
setLoading(false)
}
})
return () => { active = false }
}, [sectionId])
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<div>
<div className="text-xs text-gray-500 uppercase tracking-wide">QUAIDAL Kriterium</div>
<h2 className="text-xl font-bold text-gray-900">
{tree?.criterion.canonical_name || sectionId}
</h2>
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-full hover:bg-gray-100 flex items-center justify-center text-gray-500"
aria-label="Schliessen"
>×</button>
</div>
<div className="overflow-y-auto p-6 space-y-6">
{loading && <div className="text-center text-gray-400 py-12">Lade...</div>}
{tree && (
<>
<div>
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-2">
Anforderung (eigene Formulierung)
</h3>
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<p className="text-gray-800 whitespace-pre-line">{tree.criterion.description}</p>
</div>
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-gray-500">
<span>Regulierung: <span className="font-medium text-gray-700">{tree.criterion.regulation_anchor || '—'}</span></span>
<span>Quelle: <span className="font-medium text-gray-700">{tree.criterion.source.framework} {tree.criterion.source.section}</span></span>
{tree.criterion.source.url && (
<a href={tree.criterion.source.url} target="_blank" rel="noreferrer noopener" className="text-purple-600 hover:text-purple-800 underline">
Originalquelle
</a>
)}
</div>
</div>
{tree.criterion.external_refs.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-2">
Externe Referenzen (nicht ingestiert, nur Verweis)
</h3>
<div className="flex flex-wrap gap-2">
{tree.criterion.external_refs.map((ref, i) => (
<span key={i} className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded">
{ref.framework}{ref.citation ? `${ref.citation}` : ''}
</span>
))}
</div>
</div>
)}
{tree.building_blocks.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
Bausteine ({tree.building_blocks.length})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{tree.building_blocks.map(qb => (
<ControlBlock key={qb.derived_id} ctrl={qb} badgeColor="bg-blue-100 text-blue-700" />
))}
</div>
</div>
)}
{tree.measures.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
Maßnahmen ({tree.measures.length})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{tree.measures.map(m => (
<ControlBlock key={m.derived_id} ctrl={m} badgeColor="bg-green-100 text-green-700" />
))}
</div>
</div>
)}
{tree.metrics.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-3">
Metriken & Methoden ({tree.metrics.length})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{tree.metrics.map(qm => (
<ControlBlock key={qm.derived_id} ctrl={qm} badgeColor="bg-amber-100 text-amber-700" />
))}
</div>
</div>
)}
</>
)}
</div>
<div className="px-6 py-3 border-t border-gray-200 bg-gray-50 text-xs text-gray-500">
Eigene Clean-Room-Ableitung von BSI QUAIDAL. Quellverweis und Lizenz-Note pro Eintrag.
</div>
</div>
</div>
)
}
@@ -0,0 +1,109 @@
'use client'
import { useState } from 'react'
import { useQuaidalData, type QuaidalControl } from '../_hooks/useQuaidalData'
import { QuaidalCriterionDetail } from './QuaidalCriterionDetail'
function CriterionCard({ ctrl, onOpen }: { ctrl: QuaidalControl; onOpen: () => void }) {
return (
<button
onClick={onOpen}
className="text-left bg-white rounded-xl border border-gray-200 p-5 hover:border-purple-400 hover:shadow-sm transition-all"
>
<div className="flex items-start justify-between mb-2">
<h3 className="font-semibold text-gray-900">{ctrl.canonical_name}</h3>
<span className="px-2 py-0.5 text-xs rounded-full bg-purple-100 text-purple-700">
{ctrl.source.section}
</span>
</div>
<p className="text-sm text-gray-600 line-clamp-3">{ctrl.description}</p>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
<span className="text-gray-500">Bausteine: <span className="font-medium text-gray-700">{ctrl.related_quaidal_ids.length}</span></span>
{ctrl.external_refs.slice(0, 2).map((r, i) => (
<span key={i} className="px-1.5 py-0.5 bg-gray-100 text-gray-600 rounded">
{r.framework}
</span>
))}
</div>
</button>
)
}
export function TrainingDataQualityTab() {
const { criteria, stats, loading, error } = useQuaidalData()
const [openSection, setOpenSection] = useState<string | null>(null)
if (loading) {
return <div className="text-center text-gray-400 py-12">Lade QUAIDAL-Katalog...</div>
}
if (error) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
QUAIDAL-Daten konnten nicht geladen werden: {error}
</div>
)
}
return (
<div className="space-y-6">
<div className="bg-purple-50 border border-purple-200 rounded-xl p-5">
<h2 className="text-lg font-semibold text-gray-900">Trainingsdaten-Qualität nach BSI QUAIDAL</h2>
<p className="text-sm text-gray-600 mt-1">
Operative Umsetzung von EU AI Act Art. 10 (Datenqualität für Hochrisiko-KI) auf Basis des
BSI-Katalogs QUAIDAL. Alle Controls sind eigenständig formuliert (Clean-Room) und verweisen
auf die jeweilige QUAIDAL-Sektion.
</p>
{stats && (
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
<div>
<div className="text-xs text-gray-500">Qualitätskriterien</div>
<div className="text-xl font-semibold text-gray-900">{stats.counts_by_kind.criterion ?? 0}</div>
</div>
<div>
<div className="text-xs text-gray-500">Bausteine</div>
<div className="text-xl font-semibold text-gray-900">{stats.counts_by_kind.building_block ?? 0}</div>
</div>
<div>
<div className="text-xs text-gray-500">Maßnahmen</div>
<div className="text-xl font-semibold text-gray-900">{stats.counts_by_kind.measure ?? 0}</div>
</div>
<div>
<div className="text-xs text-gray-500">Metriken & Methoden</div>
<div className="text-xl font-semibold text-gray-900">{stats.counts_by_kind.metric ?? 0}</div>
</div>
</div>
)}
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">10 Qualitätskriterien</h3>
{criteria.length === 0 ? (
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center text-gray-400">
Keine Kriterien gefunden. Bitte Backend-Ingest prüfen.
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{criteria.map(c => (
<CriterionCard
key={c.derived_id}
ctrl={c}
onOpen={() => setOpenSection(c.source.section)}
/>
))}
</div>
)}
</div>
{stats?.license_note && (
<div className="text-xs text-gray-500 italic">{stats.license_note}</div>
)}
{openSection && (
<QuaidalCriterionDetail
sectionId={openSection}
onClose={() => setOpenSection(null)}
/>
)}
</div>
)
}
@@ -0,0 +1,86 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
export interface QuaidalExternalRef {
framework: string
citation: string | null
}
export interface QuaidalSource {
framework: string
section: string
url: string | null
commit_sha: string | null
title_original: string | null
license_note: string | null
}
export interface QuaidalControl {
derived_id: string
kind: 'criterion' | 'building_block' | 'measure' | 'metric'
canonical_name: string
description: string
regulation_anchor: string | null
related_quaidal_ids: string[]
external_refs: QuaidalExternalRef[]
source: QuaidalSource
plagiarism_score: number | null
}
export interface QuaidalStats {
counts_by_kind: Record<string, number>
source_framework: string
source_commit_sha: string | null
license_note: string | null
}
export interface QuaidalCriterionTree {
criterion: QuaidalControl
building_blocks: QuaidalControl[]
measures: QuaidalControl[]
metrics: QuaidalControl[]
}
const API_BASE = '/api/sdk/v1/quaidal'
export function useQuaidalData() {
const [criteria, setCriteria] = useState<QuaidalControl[]>([])
const [stats, setStats] = useState<QuaidalStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const loadAll = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [criteriaRes, statsRes] = await Promise.all([
fetch(`${API_BASE}/criteria`, { cache: 'no-store' }),
fetch(`${API_BASE}/stats`, { cache: 'no-store' }),
])
if (criteriaRes.ok) {
const data = (await criteriaRes.json()) as QuaidalControl[]
setCriteria(Array.isArray(data) ? data : [])
} else {
setError(`Criteria endpoint returned ${criteriaRes.status}`)
}
if (statsRes.ok) {
setStats(await statsRes.json())
}
} catch (err) {
setError(String(err))
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadAll() }, [loadAll])
return { criteria, stats, loading, error, reload: loadAll }
}
export async function fetchCriterionTree(sectionId: string): Promise<QuaidalCriterionTree | null> {
const res = await fetch(`${API_BASE}/criteria/${encodeURIComponent(sectionId)}`, { cache: 'no-store' })
if (!res.ok) return null
return (await res.json()) as QuaidalCriterionTree
}
+56 -16
View File
@@ -1,15 +1,23 @@
'use client'
import { useState, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { useSDK } from '@/lib/sdk'
import { useQualityData } from './_hooks/useQualityData'
import { MetricCard, type QualityMetric } from './_components/MetricCard'
import { TestRow } from './_components/TestRow'
import { MetricModal } from './_components/MetricModal'
import { TestModal } from './_components/TestModal'
import { TrainingDataQualityTab } from './_components/TrainingDataQualityTab'
type TabId = 'model_quality' | 'data_quality'
export default function QualityPage() {
const { state } = useSDK()
const searchParams = useSearchParams()
const initialTab: TabId = searchParams?.get('category') === 'data_quality' ? 'data_quality' : 'model_quality'
const [tab, setTab] = useState<TabId>(initialTab)
const {
metrics,
tests,
@@ -41,24 +49,54 @@ export default function QualityPage() {
<h1 className="text-2xl font-bold text-gray-900">AI Quality Dashboard</h1>
<p className="mt-1 text-gray-500">Ueberwachen Sie die Qualitaet und Fairness Ihrer KI-Systeme</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowTestModal(true)}
className="flex items-center gap-2 px-4 py-2 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
Test hinzufuegen
</button>
<button
onClick={() => { setEditMetric(undefined); setShowMetricModal(true) }}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /></svg>
Messung hinzufuegen
</button>
</div>
{tab === 'model_quality' && (
<div className="flex items-center gap-2">
<button
onClick={() => setShowTestModal(true)}
className="flex items-center gap-2 px-4 py-2 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
Test hinzufuegen
</button>
<button
onClick={() => { setEditMetric(undefined); setShowMetricModal(true) }}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /></svg>
Messung hinzufuegen
</button>
</div>
)}
</div>
<div className="border-b border-gray-200">
<nav className="-mb-px flex gap-6">
<button
onClick={() => setTab('model_quality')}
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
tab === 'model_quality'
? 'border-purple-500 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Modell-Qualität
</button>
<button
onClick={() => setTab('data_quality')}
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
tab === 'data_quality'
? 'border-purple-500 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Trainingsdaten-Qualität (BSI QUAIDAL)
</button>
</nav>
</div>
{tab === 'data_quality' && <TrainingDataQualityTab />}
{tab === 'model_quality' && (
<>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="text-sm text-gray-500">Durchschnittlicher Score</div>
@@ -141,6 +179,8 @@ export default function QualityPage() {
</div>
</div>
</div>
</>
)}
{showMetricModal && (
<MetricModal
@@ -75,6 +75,28 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
<AdditionalModuleItem href="/sdk/agent" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>} label="Compliance Agent" isActive={pathname?.startsWith('/sdk/agent') ?? false} collapsed={collapsed} projectId={projectId} />
</div>
{/* CRA Compliance */}
<div className="border-t-2 border-red-200 py-2 bg-red-50/30">
{!collapsed && (
<div className="px-4 py-2 text-xs font-semibold text-red-600 uppercase tracking-wider">
CRA Compliance
</div>
)}
<AdditionalModuleItem
href="/sdk/cra"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
}
label="CRA Compliance"
isActive={pathname?.startsWith('/sdk/cra') ?? false}
collapsed={collapsed}
projectId={projectId}
/>
</div>
{/* Regulatory Gap-Analyse */}
<div className="border-t-2 border-orange-200 py-2 bg-orange-50/30">
{!collapsed && (
@@ -0,0 +1,156 @@
/**
* Content-Blocker Generator (Borlabs-Parity).
*
* Returns a small JS snippet that scans the page for blockable third-party
* embeds (YouTube, Vimeo, Google Maps, Spotify, Twitter, Facebook) and
* replaces them with a click-to-consent placeholder until the user agrees
* to the relevant cookie category.
*
* The customer drops a SECOND script tag next to the banner:
* <script src="/cookie-banner.js"></script>
* <script src="/cookie-content-blocker.js"></script>
*
* Author writes content as either:
* <bp-consent-block category="EXTERNAL_MEDIA"
* provider="YouTube"
* src="https://www.youtube.com/embed/...">
* <!-- the original iframe / embed code -->
* </bp-consent-block>
*
* OR auto-detect: any <iframe src="https://www.youtube.com/...">
* gets wrapped on page load.
*/
const KNOWN_EMBEDS: Array<{ host: string; provider: string; category: string }> = [
{ host: 'youtube.com', provider: 'YouTube', category: 'EXTERNAL_MEDIA' },
{ host: 'youtu.be', provider: 'YouTube', category: 'EXTERNAL_MEDIA' },
{ host: 'vimeo.com', provider: 'Vimeo', category: 'EXTERNAL_MEDIA' },
{ host: 'google.com/maps', provider: 'Google Maps', category: 'EXTERNAL_MEDIA' },
{ host: 'maps.googleapis.com', provider: 'Google Maps', category: 'EXTERNAL_MEDIA' },
{ host: 'spotify.com', provider: 'Spotify', category: 'EXTERNAL_MEDIA' },
{ host: 'soundcloud.com', provider: 'SoundCloud', category: 'EXTERNAL_MEDIA' },
{ host: 'twitter.com', provider: 'Twitter / X', category: 'PERSONALIZATION' },
{ host: 'facebook.com', provider: 'Facebook', category: 'PERSONALIZATION' },
{ host: 'instagram.com', provider: 'Instagram', category: 'PERSONALIZATION' },
]
export function generateContentBlockerJS(cookieName: string = 'cookie_consent'): string {
return `(function () {
'use strict';
var COOKIE_NAME = ${JSON.stringify(cookieName)};
var KNOWN_EMBEDS = ${JSON.stringify(KNOWN_EMBEDS)};
function getConsent() {
var c = document.cookie.split('; ').find(function (r) {
return r.indexOf(COOKIE_NAME + '=') === 0;
});
if (!c) return null;
try { return JSON.parse(decodeURIComponent(c.split('=')[1])); } catch (e) { return null; }
}
function categoryGranted(cat) {
var c = getConsent();
if (!c) return false;
var k = String(cat).toLowerCase();
return c[cat] === true || c[k] === true;
}
function classifyByHost(src) {
if (!src) return null;
for (var i = 0; i < KNOWN_EMBEDS.length; i++) {
if (src.indexOf(KNOWN_EMBEDS[i].host) > -1) return KNOWN_EMBEDS[i];
}
return null;
}
function makePlaceholder(provider, category, originalHTML, parent) {
var ph = document.createElement('div');
ph.className = 'bp-consent-placeholder';
ph.style.cssText = 'border:2px dashed #cbd5e1;background:#f8fafc;padding:24px;' +
'border-radius:8px;text-align:center;font-family:-apple-system,sans-serif;color:#475569';
ph.innerHTML =
'<div style="font-size:14px;font-weight:600;color:#1e293b;margin-bottom:8px">' +
'Inhalt von ' + provider + ' blockiert</div>' +
'<div style="font-size:12px;margin-bottom:12px">' +
'Zum Anzeigen dieses Inhalts wird Ihre Einwilligung fuer die Kategorie ' +
'<strong>' + category + '</strong> benoetigt. ' +
'Beim Akzeptieren werden Cookies von ' + provider + ' gesetzt.</div>' +
'<button class="bp-consent-load-btn" ' +
'style="background:#7c3aed;color:white;border:none;padding:8px 16px;' +
'border-radius:6px;font-size:13px;cursor:pointer;margin-right:6px">' +
'Inhalt einmalig laden</button>' +
'<button class="bp-consent-accept-btn" ' +
'style="background:#16a34a;color:white;border:none;padding:8px 16px;' +
'border-radius:6px;font-size:13px;cursor:pointer">' +
category + ' akzeptieren</button>';
ph.querySelector('.bp-consent-load-btn').addEventListener('click', function () {
var div = document.createElement('div');
div.innerHTML = originalHTML;
while (div.firstChild) parent.insertBefore(div.firstChild, ph);
ph.remove();
});
ph.querySelector('.bp-consent-accept-btn').addEventListener('click', function () {
var c = getConsent() || {};
c[category] = true;
var date = new Date();
date.setTime(date.getTime() + 180 * 86400000);
document.cookie = COOKIE_NAME + '=' + encodeURIComponent(JSON.stringify(c)) +
';expires=' + date.toUTCString() + ';path=/;SameSite=Lax';
window.dispatchEvent(new CustomEvent('cookieConsentUpdated', { detail: c }));
// Re-scan: placeholders for THIS category get replaced now
processAll();
});
return ph;
}
function processWrapped() {
var wrapped = document.querySelectorAll('bp-consent-block, [data-bp-consent-block]');
wrapped.forEach(function (el) {
var cat = el.getAttribute('category') || el.getAttribute('data-category') || 'EXTERNAL_MEDIA';
var prov = el.getAttribute('provider') || el.getAttribute('data-provider') || 'Drittanbieter';
if (categoryGranted(cat)) {
// Already consented: unwrap the inner content
var html = el.innerHTML;
var tmp = document.createElement('div');
tmp.innerHTML = html;
var parent = el.parentNode;
while (tmp.firstChild) parent.insertBefore(tmp.firstChild, el);
el.remove();
} else {
var parent = el.parentNode;
var inner = el.innerHTML;
var ph = makePlaceholder(prov, cat, inner, parent);
parent.insertBefore(ph, el);
el.remove();
}
});
}
function processBareIframes() {
var iframes = document.querySelectorAll('iframe[src]:not([data-bp-processed])');
iframes.forEach(function (f) {
var match = classifyByHost(f.getAttribute('src') || '');
if (!match) return;
f.setAttribute('data-bp-processed', '1');
if (categoryGranted(match.category)) return;
var html = f.outerHTML;
var parent = f.parentNode;
var ph = makePlaceholder(match.provider, match.category, html, parent);
parent.replaceChild(ph, f);
});
}
function processAll() {
processWrapped();
processBareIframes();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', processAll);
} else {
processAll();
}
// Re-process when consent updates
window.addEventListener('cookieConsentUpdated', processAll);
})();`
}
@@ -325,18 +325,25 @@ function generateJS(config: CookieBannerConfig): string {
const CATEGORIES = ${JSON.stringify(categoryIds)};
const REQUIRED_CATEGORIES = ${JSON.stringify(requiredCategories)};
// Google Consent Mode v2 — PFLICHT seit Maerz 2024 fuer Google Services in EEA
// Sets default consent state to "denied" BEFORE any Google tags fire
if (typeof gtag === 'function') {
gtag('consent', 'default', {
analytics_storage: 'denied',
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
functionality_storage: 'granted',
security_storage: 'granted',
});
// Google Consent Mode v2 — PFLICHT seit Maerz 2024 fuer Google Services
// in EEA. Shim gtag/dataLayer falls Google Tag noch nicht initialisiert
// wurde, dann sofort den default consent state setzen (DENIED).
window.dataLayer = window.dataLayer || [];
if (typeof gtag !== 'function') {
window.gtag = function () { window.dataLayer.push(arguments); };
}
// wait_for_update gibt dem Banner 500ms Zeit, damit der Nutzer
// entscheiden kann bevor Tags feuern. Empfehlung von Google fuer GCM v2.
gtag('consent', 'default', {
analytics_storage: 'denied',
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
functionality_storage: 'granted',
security_storage: 'granted',
wait_for_update: 500,
region: ['EEA', 'CH', 'GB'],
});
function updateGoogleConsentMode(consent) {
if (typeof gtag !== 'function') return;
@@ -364,10 +371,61 @@ function generateJS(config: CookieBannerConfig): string {
document.cookie = COOKIE_NAME + '=' + encodeURIComponent(JSON.stringify(consent)) +
';expires=' + date.toUTCString() +
';path=/;SameSite=Lax';
// Append to local history (Art. 7(3) DSGVO Best-Practice + Borlabs-Parity).
// Server-seitiges Logging laeuft separat via consent-service.
try {
const HKEY = COOKIE_NAME + '_history';
const hist = JSON.parse(localStorage.getItem(HKEY) || '[]');
hist.push({
ts: new Date().toISOString(),
choices: consent,
});
if (hist.length > 50) hist.splice(0, hist.length - 50);
localStorage.setItem(HKEY, JSON.stringify(hist));
} catch (e) { /* localStorage blocked */ }
window.dispatchEvent(new CustomEvent('cookieConsentUpdated', { detail: consent }));
updateGoogleConsentMode(consent);
}
// Borlabs-Parity: zeigt dem Nutzer alle seine bisherigen Einwilligungen.
// Aufruf via window.bpShowConsentHistory() oder Klick auf den Link im Banner-Footer.
window.bpShowConsentHistory = function () {
var existing = document.getElementById('bpConsentHistoryModal');
if (existing) { existing.remove(); return; }
var hist = [];
try { hist = JSON.parse(localStorage.getItem(COOKIE_NAME + '_history') || '[]'); } catch (e) {}
var rows = hist.length === 0
? '<p style="color:#94a3b8;font-style:italic">Noch keine Einwilligungen gespeichert.</p>'
: hist.slice().reverse().map(function (h) {
var d = new Date(h.ts);
var parts = Object.keys(h.choices).map(function (k) {
return '<span style="margin-right:8px;font-size:11px;color:' +
(h.choices[k] ? '#16a34a' : '#dc2626') + '">' +
(h.choices[k] ? '✓ ' : '✗ ') + k + '</span>';
}).join('');
return '<div style="border-bottom:1px solid #e5e7eb;padding:8px 0">' +
'<div style="font-size:12px;color:#64748b;margin-bottom:4px">' +
d.toLocaleString('de-DE') + '</div>' +
'<div>' + parts + '</div></div>';
}).join('');
var modal = document.createElement('div');
modal.id = 'bpConsentHistoryModal';
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);' +
'z-index:999999;display:flex;align-items:center;justify-content:center;padding:20px';
modal.innerHTML = '<div style="background:white;border-radius:8px;max-width:500px;' +
'width:100%;max-height:80vh;overflow:auto;padding:20px;font-family:-apple-system,sans-serif">' +
'<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">' +
'<h3 style="margin:0;font-size:16px">Ihre Einwilligungs-Historie</h3>' +
'<button onclick="document.getElementById(\\'bpConsentHistoryModal\\').remove()" ' +
'style="background:none;border:none;font-size:24px;cursor:pointer;color:#94a3b8">×</button>' +
'</div>' +
'<p style="font-size:12px;color:#64748b;margin:0 0 12px">' +
'Lokal in Ihrem Browser gespeichert. Server-seitig laufen Audit-Logs gemaess Art. 7(1) DSGVO.</p>' +
rows + '</div>';
modal.addEventListener('click', function (e) { if (e.target === modal) modal.remove(); });
document.body.appendChild(modal);
};
function hasConsent(category) {
const consent = getConsent();
if (!consent) return REQUIRED_CATEGORIES.includes(category);
+41 -40
View File
@@ -21,7 +21,7 @@
"jspdf": "^4.1.0",
"jszip": "^3.10.1",
"lucide-react": "^0.468.0",
"next": "^15.1.0",
"next": "^15.5.16",
"pg": "^8.13.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -1597,15 +1597,15 @@
}
},
"node_modules/@next/env": {
"version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.12.tgz",
"integrity": "sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==",
"version": "15.5.16",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.16.tgz",
"integrity": "sha512-9QMKolCl+JnJtaRAQSXy4RQrhgfe8W7/G1+Hl3QSB/HZY7zQMzTwPDdTRwwio8BS96ps1MHpHhbS8qxoNV3JIQ==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.12.tgz",
"integrity": "sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==",
"version": "15.5.16",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.16.tgz",
"integrity": "sha512-wzdER4JZj+31vNkhaZ1Ght3IsNI8DMwj7VqadfIOqJB5sh8FiOqNSopYADQn6mgEPomzDd/DHqBcfo2fmVMYtg==",
"cpu": [
"arm64"
],
@@ -1619,9 +1619,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.12.tgz",
"integrity": "sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==",
"version": "15.5.16",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.16.tgz",
"integrity": "sha512-PPTo+cvcanxkuDEuDyZGk28ntmu0WjfkxqlG7hw9Mhsiribs4x1C6h2Culn0cJKqsne1gFjjZRK3ax7WYlSxgg==",
"cpu": [
"x64"
],
@@ -1635,9 +1635,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.12.tgz",
"integrity": "sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==",
"version": "15.5.16",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.16.tgz",
"integrity": "sha512-Jl0IL9P7S8uNl5oI1TqrQmfmLp7OqjWM58000pVnUVIsHrvPP6m9QDW/uNWYUbmd+8IYvc6MTeZKICstBMBpew==",
"cpu": [
"arm64"
],
@@ -1651,9 +1651,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.12.tgz",
"integrity": "sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==",
"version": "15.5.16",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.16.tgz",
"integrity": "sha512-Zf0BIqv/o5uOWfyRkzgGhyV2Tky7HLt0bG+w7XWdaU1JpyX0tltM3TrSfa/Y9c597SJG4CzN47+u2InhgZZ4vg==",
"cpu": [
"arm64"
],
@@ -1667,9 +1667,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.12.tgz",
"integrity": "sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==",
"version": "15.5.16",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.16.tgz",
"integrity": "sha512-HCDDU1TRLeUDV180QQTWrs5Oa4lIcI7XH9nF0UVUVmYLN/boZ6LqyFtm3814gc1fv+lOVyKaw5B6bVC9BpXTSQ==",
"cpu": [
"x64"
],
@@ -1683,9 +1683,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.12.tgz",
"integrity": "sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==",
"version": "15.5.16",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.16.tgz",
"integrity": "sha512-kvXUY1dn5wxKuMkXxQRUbPjEnKxW1PR9uKOm0zpIpj3574+cFfaePhYFmBVtrOuwt+w34OdDzNaJr5Iixf+HBQ==",
"cpu": [
"x64"
],
@@ -1699,9 +1699,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.12.tgz",
"integrity": "sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==",
"version": "15.5.16",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.16.tgz",
"integrity": "sha512-zpOQuF+eyENMXRjglp2hZCIrUjTdO37suEBnDn1mX4PXSuetXZDMLpjKOh4dYSw3SiDTnOoOUwBl5i5Elr6nnQ==",
"cpu": [
"arm64"
],
@@ -1715,9 +1715,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.5.12",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.12.tgz",
"integrity": "sha512-Z1Dh6lhFkxvBDH1FoW6OU/L6prYwPSlwjLiZkExIAh8fbP6iI/M7iGTQAJPYJ9YFlWobCZ1PHbchFhFYb2ADkw==",
"version": "15.5.16",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.16.tgz",
"integrity": "sha512-LnwKYpiSmIzXlTq76hMeeIzZoDcFwu848p6H+QBkGFJIbZphgzNUPdHruJcHM/bFnaFeco0l1Frie5I27VKglA==",
"cpu": [
"x64"
],
@@ -5118,12 +5118,12 @@
}
},
"node_modules/next": {
"version": "15.5.12",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz",
"integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==",
"version": "15.5.16",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.16.tgz",
"integrity": "sha512-aZExBk/V6JCu3NCFc90twdj9L/M3y0+ukeQwUAZbOiqRhAX+h2oMEa0NZFhcpj6HYRYjVS3V2/3xvyOpNnmw7A==",
"license": "MIT",
"dependencies": {
"@next/env": "15.5.12",
"@next/env": "15.5.16",
"@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
@@ -5136,14 +5136,14 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "15.5.12",
"@next/swc-darwin-x64": "15.5.12",
"@next/swc-linux-arm64-gnu": "15.5.12",
"@next/swc-linux-arm64-musl": "15.5.12",
"@next/swc-linux-x64-gnu": "15.5.12",
"@next/swc-linux-x64-musl": "15.5.12",
"@next/swc-win32-arm64-msvc": "15.5.12",
"@next/swc-win32-x64-msvc": "15.5.12",
"@next/swc-darwin-arm64": "15.5.16",
"@next/swc-darwin-x64": "15.5.16",
"@next/swc-linux-arm64-gnu": "15.5.16",
"@next/swc-linux-arm64-musl": "15.5.16",
"@next/swc-linux-x64-gnu": "15.5.16",
"@next/swc-linux-x64-musl": "15.5.16",
"@next/swc-win32-arm64-msvc": "15.5.16",
"@next/swc-win32-x64-msvc": "15.5.16",
"sharp": "^0.34.3"
},
"peerDependencies": {
@@ -5472,6 +5472,7 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
+1 -1
View File
@@ -31,7 +31,7 @@
"jspdf": "^4.1.0",
"jszip": "^3.10.1",
"lucide-react": "^0.468.0",
"next": "^15.1.0",
"next": "^15.5.16",
"pg": "^8.13.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -0,0 +1,581 @@
package handlers
import (
"encoding/csv"
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"time"
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// projectMetadataRoot is the shape we store inside iace_projects.metadata.
// We only own the "clarification_answers" key; everything else is preserved
// as opaque JSON so we don't trample on existing fields (limits_form, etc).
type projectMetadataRoot map[string]json.RawMessage
const clarificationAnswersKey = "clarification_answers"
// readClarificationAnswers parses project.metadata and returns the
// clarification_answers map. Missing/empty metadata yields an empty map.
func readClarificationAnswers(meta json.RawMessage) (map[string]iace.ClarificationAnswer, projectMetadataRoot) {
root := projectMetadataRoot{}
if len(meta) > 0 {
_ = json.Unmarshal(meta, &root)
}
answers := map[string]iace.ClarificationAnswer{}
if raw, ok := root[clarificationAnswersKey]; ok && len(raw) > 0 {
_ = json.Unmarshal(raw, &answers)
}
return answers, root
}
// reconstructHazardPatterns re-runs the pattern engine for the project's
// narrative so we can map each hazard back to the patterns that fired for
// it. The Hazard table itself doesn't persist the source-pattern list, so
// this is the only way to know "which clarifications apply to which hazard".
func (h *IACEHandler) reconstructHazardPatterns(narrative string, machineType string, hazards []iace.Hazard) map[uuid.UUID][]string {
parsed := iace.ParseNarrative(narrative, machineType)
compIDs := make([]string, 0, len(parsed.Components))
for _, c := range parsed.Components {
compIDs = append(compIDs, c.LibraryID)
}
energyIDs := make([]string, 0, len(parsed.EnergySources))
for _, e := range parsed.EnergySources {
energyIDs = append(energyIDs, e.SourceID)
}
engine := iace.NewPatternEngine()
out := engine.Match(iace.MatchInput{
ComponentLibraryIDs: compIDs,
EnergySourceIDs: energyIDs,
LifecyclePhases: parsed.LifecyclePhases,
CustomTags: parsed.CustomTags,
OperationalStates: parsed.OperationalStates,
StateTransitions: parsed.StateTransitions,
HumanRoles: parsed.Roles,
MachineTypes: []string{machineType},
})
// Map hazard.HazardousZone → set of HP-IDs by substring-matching the
// pattern's ZoneDE. The hazard table doesn't keep a back-pointer to
// the source pattern, so this approximation re-runs pattern matching
// against the narrative and matches by normalised zone.
hazardToPatterns := map[uuid.UUID][]string{}
for _, hz := range hazards {
hzZone := normalizeKey(hz.HazardousZone)
if hzZone == "" {
continue
}
for _, m := range out.MatchedPatterns {
pz := normalizeKey(m.ZoneDE)
if pz == "" {
continue
}
if pz == hzZone || containsSubstring(hzZone, pz) || containsSubstring(pz, hzZone) {
hazardToPatterns[hz.ID] = appendUnique(hazardToPatterns[hz.ID], m.PatternID)
}
}
}
return hazardToPatterns
}
func normalizeKey(s string) string {
s = iace.NormalizeDEPublic(s)
out := []rune{}
for _, r := range s {
switch r {
case ',', '/', '(', ')', '-', '.', ':', ';':
out = append(out, ' ')
default:
out = append(out, r)
}
}
return string(out)
}
func appendUnique(slice []string, s string) []string {
for _, x := range slice {
if x == s {
return slice
}
}
return append(slice, s)
}
// ListClarifications handles GET /projects/:id/clarifications.
// Returns the aggregated clarification list with affected-hazard cross-refs
// and the persisted answer state.
//
// Phase 3 storage model: answers live in the iace_clarifications table
// when migration 028 has been applied. The JSONB fallback in
// project.metadata.clarification_answers is still read so projects that
// were answered before the migration keep their state until the one-shot
// upcopy runs.
func (h *IACEHandler) ListClarifications(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
ctx := c.Request.Context()
project, err := h.store.GetProject(ctx, projectID)
if err != nil || project == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
hazards, _ := h.store.ListHazards(ctx, projectID)
// Primary: relational answers
answers := map[string]iace.ClarificationAnswer{}
if rows, rerr := h.store.ListClarificationsForProject(ctx, projectID); rerr == nil {
for _, r := range rows {
answeredAt := ""
if r.AnsweredAt != nil {
answeredAt = r.AnsweredAt.UTC().Format(time.RFC3339)
}
answers[r.ClarificationKey] = iace.ClarificationAnswer{
Status: r.Status,
Answer: r.Answer,
Reasoning: r.Reasoning,
AnsweredBy: r.AnsweredBy,
AnsweredAt: answeredAt,
AssignedTo: r.AssignedTo,
}
}
}
// Fallback: JSONB legacy answers (keep until one-shot upcopy is done)
if legacy, _ := readClarificationAnswers(project.Metadata); len(legacy) > 0 {
for k, v := range legacy {
if _, ok := answers[k]; !ok {
answers[k] = v
}
}
}
narrative := extractNarrativeFromMetadata(project.Metadata)
hazardToPatterns := h.reconstructHazardPatterns(narrative, project.MachineType, hazards)
manufHits := iace.LookupManufacturerFeaturesInText(narrative)
clarifications := iace.BuildProjectClarifications(hazards, hazardToPatterns, manufHits, answers)
sort.Slice(clarifications, func(i, j int) bool {
// Open first, then answered. Within a status, group by category, then by source.
if clarifications[i].Status != clarifications[j].Status {
return clarifications[i].Status == "open"
}
if clarifications[i].Category != clarifications[j].Category {
return clarifications[i].Category < clarifications[j].Category
}
return clarifications[i].Source < clarifications[j].Source
})
openCount, answeredCount := 0, 0
for _, cl := range clarifications {
switch cl.Status {
case "answered", "not_relevant":
answeredCount++
default:
openCount++
}
}
c.JSON(http.StatusOK, gin.H{
"clarifications": clarifications,
"open_count": openCount,
"answered_count": answeredCount,
"total": len(clarifications),
})
}
// AnswerClarificationRequest is the request body for POST .../answer.
type AnswerClarificationRequest struct {
Status string `json:"status"` // open | in_progress | answered | not_relevant
Answer string `json:"answer"` // ja | nein | teilweise
Reasoning string `json:"reasoning"`
AnsweredBy string `json:"answered_by"`
AssignedTo string `json:"assigned_to"`
// Snapshot fields written into the new table on first contact so the
// audit trail does not break if the pattern library changes later.
Question string `json:"question,omitempty"`
Source string `json:"source,omitempty"`
Category string `json:"category,omitempty"`
NormReferences []string `json:"norm_references,omitempty"`
}
// AnswerClarification handles POST /projects/:id/clarifications/:cid/answer.
// Upserts the answer in iace_clarifications (Phase 3). Old JSONB answers
// remain readable but are no longer written.
func (h *IACEHandler) AnswerClarification(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
cid := c.Param("cid")
if cid == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing clarification id"})
return
}
var req AnswerClarificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Status == "" {
if req.Answer != "" {
req.Status = "answered"
} else {
req.Status = "open"
}
}
ctx := c.Request.Context()
project, err := h.store.GetProject(ctx, projectID)
if err != nil || project == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
tenantID, terr := getTenantID(c)
if terr != nil {
tenantID = project.TenantID
}
// If the client didn't supply snapshot fields, fall back to whatever
// the engine currently produces for this clarification id.
if req.Question == "" || req.Source == "" {
if prev, _ := h.store.GetClarificationByKey(ctx, projectID, cid); prev != nil {
if req.Question == "" {
req.Question = prev.Question
}
if req.Source == "" {
req.Source = prev.Source
}
if req.Category == "" {
req.Category = prev.Category
}
if len(req.NormReferences) == 0 {
req.NormReferences = prev.NormReferences
}
}
}
now := time.Now().UTC()
answeredAt := &now
if req.Status != "answered" && req.Status != "not_relevant" {
answeredAt = nil
}
in := iace.ClarificationRow{
TenantID: tenantID,
ProjectID: projectID,
ClarificationKey: cid,
Question: req.Question,
Source: req.Source,
Category: req.Category,
NormReferences: req.NormReferences,
Status: req.Status,
Answer: req.Answer,
Reasoning: req.Reasoning,
AssignedTo: req.AssignedTo,
AnsweredBy: req.AnsweredBy,
AnsweredAt: answeredAt,
}
row, err := h.store.UpsertClarification(ctx, in)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"clarification_id": cid,
"row": row,
})
}
// CommentRequest is the body for POST .../comment.
type CommentRequest struct {
Author string `json:"author"`
Body string `json:"body"`
}
// PostClarificationComment handles POST /projects/:id/clarifications/:cid/comment.
func (h *IACEHandler) PostClarificationComment(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
cid := c.Param("cid")
var req CommentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Body == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "body required"})
return
}
ctx := c.Request.Context()
row, err := h.store.GetClarificationByKey(ctx, projectID, cid)
if err != nil || row == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "clarification not found — answer/assign it first to create the row"})
return
}
comment, err := h.store.AddClarificationComment(ctx, row.ID, req.Author, req.Body)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"comment": comment})
}
// ListClarificationDetail handles GET /projects/:id/clarifications/:cid/detail
// and returns comments + history for one clarification.
func (h *IACEHandler) ListClarificationDetail(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
cid := c.Param("cid")
ctx := c.Request.Context()
row, _ := h.store.GetClarificationByKey(ctx, projectID, cid)
if row == nil {
c.JSON(http.StatusOK, gin.H{"row": nil, "comments": []any{}, "history": []any{}})
return
}
comments, _ := h.store.ListClarificationComments(ctx, row.ID)
history, _ := h.store.ListClarificationHistory(ctx, row.ID)
c.JSON(http.StatusOK, gin.H{
"row": row,
"comments": comments,
"history": history,
})
_ = json.RawMessage{} // keep encoding/json import in case of future fields
}
// ExportClarificationsCSV handles GET /projects/:id/clarifications.csv.
// Returns the aggregated clarifications as a CSV for handover to the
// Anlagenbauer — one row per question with all referenced hazards and
// the current answer state.
func (h *IACEHandler) ExportClarificationsCSV(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
ctx := c.Request.Context()
project, err := h.store.GetProject(ctx, projectID)
if err != nil || project == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
hazards, _ := h.store.ListHazards(ctx, projectID)
answers, _ := readClarificationAnswers(project.Metadata)
narrative := extractNarrativeFromMetadata(project.Metadata)
hazardToPatterns := h.reconstructHazardPatterns(narrative, project.MachineType, hazards)
manufHits := iace.LookupManufacturerFeaturesInText(narrative)
clarifications := iace.BuildProjectClarifications(hazards, hazardToPatterns, manufHits, answers)
sort.Slice(clarifications, func(i, j int) bool {
if clarifications[i].Status != clarifications[j].Status {
return clarifications[i].Status == "open"
}
return clarifications[i].Source < clarifications[j].Source
})
filename := fmt.Sprintf("klaerungen_%s_%s.csv", project.MachineName, time.Now().Format("2006-01-02"))
filename = strings.ReplaceAll(filename, " ", "_")
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="`+filename+`"`)
// Excel-Erkennung: UTF-8 BOM voranstellen
c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
w := csv.NewWriter(c.Writer)
w.Comma = ';'
_ = w.Write([]string{
"ID", "Quelle", "Kategorie", "Frage", "Status", "Antwort", "Begruendung",
"Bearbeiter", "Beantwortet_am", "Anzahl_Gefaehrdungen", "Gefaehrdungen", "Norm_Referenzen",
})
for _, cl := range clarifications {
_ = w.Write([]string{
cl.ID,
cl.Source,
cl.Category,
cl.Question,
cl.Status,
cl.Answer,
cl.Reasoning,
cl.AnsweredBy,
cl.AnsweredAt,
fmt.Sprintf("%d", len(cl.AffectedHazardIDs)),
strings.Join(cl.AffectedHazardNames, " | "),
strings.Join(cl.NormReferences, " | "),
})
}
w.Flush()
}
// methodologyBlock returns the standardised methodology paragraph that
// must be printed at the start of every IACE risk-assessment report.
// Pure references to norm identifiers (no norm text) — kept here so
// the same wording appears in every export.
const methodologyBlock = `<section style="background:#f9fafb;border:1px solid #d1d5db;border-radius:6px;padding:12px;margin-bottom:18px;">
<h3 style="font-size:11pt;margin:0 0 6px 0;">Methodik der Risikobeurteilung</h3>
<ul style="margin:0;padding-left:18px;font-size:9.5pt;line-height:1.45;">
<li>Gefaehrdungsidentifikation nach <strong>EN ISO 12100</strong>, Anhang B (Tabelle B.1) Mechanik, Elektrik, Thermik, Laerm, Vibration, Strahlung, Materialien/Substanzen, Ergonomie.</li>
<li>Bestimmung des erforderlichen Performance Levels (PLr) nach <strong>EN ISO 13849-1</strong>, Anhang A (Risikograph) aus S (Schwere), F (Haeufigkeit/Dauer) und P (Vermeidungsmoeglichkeit).</li>
<li>Massnahmen-Hierarchie nach ISO 12100, Abschnitt 6: <strong>6.2 Inhaerent sichere Konstruktion</strong> (Design) &rarr; <strong>6.3 Technische Schutzmassnahmen</strong> (Protection) &rarr; <strong>6.4 Benutzerinformation</strong> (Information).</li>
<li>Klaerungspunkte mit dem Anlagenbauer werden separat in der Klaerungs-Liste verwaltet (Audit-Trail mit Bearbeiter und Zeitstempel).</li>
</ul>
</section>`
// ExportClarificationsHTML handles GET /projects/:id/clarifications.html
// and returns a print-friendly standalone HTML document that the browser
// can render to PDF (no server-side PDF dependency needed). The Bediener
// opens the link, hits Cmd-P / Strg-P and saves as PDF.
func (h *IACEHandler) ExportClarificationsHTML(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
ctx := c.Request.Context()
project, err := h.store.GetProject(ctx, projectID)
if err != nil || project == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
hazards, _ := h.store.ListHazards(ctx, projectID)
answers := map[string]iace.ClarificationAnswer{}
if rows, _ := h.store.ListClarificationsForProject(ctx, projectID); rows != nil {
for _, r := range rows {
at := ""
if r.AnsweredAt != nil {
at = r.AnsweredAt.UTC().Format(time.RFC3339)
}
answers[r.ClarificationKey] = iace.ClarificationAnswer{
Status: r.Status, Answer: r.Answer, Reasoning: r.Reasoning,
AnsweredBy: r.AnsweredBy, AnsweredAt: at,
}
}
}
if legacy, _ := readClarificationAnswers(project.Metadata); len(legacy) > 0 {
for k, v := range legacy {
if _, ok := answers[k]; !ok {
answers[k] = v
}
}
}
narrative := extractNarrativeFromMetadata(project.Metadata)
hazardToPatterns := h.reconstructHazardPatterns(narrative, project.MachineType, hazards)
manufHits := iace.LookupManufacturerFeaturesInText(narrative)
cls := iace.BuildProjectClarifications(hazards, hazardToPatterns, manufHits, answers)
sort.Slice(cls, func(i, j int) bool {
if cls[i].Status != cls[j].Status {
return cls[i].Status == "open"
}
return cls[i].Source < cls[j].Source
})
c.Header("Content-Type", "text/html; charset=utf-8")
w := c.Writer
fmt.Fprintf(w, `<!doctype html><html lang="de"><head><meta charset="utf-8">
<title>Klaerungen %s</title>
<style>
@page { size: A4; margin: 18mm 15mm; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; font-size: 10pt; color: #111; line-height: 1.4; }
h1 { font-size: 16pt; margin: 0 0 4px 0; }
.sub { font-size: 9pt; color: #555; margin-bottom: 16px; }
.meta { font-size: 9pt; color: #444; margin-bottom: 12px; }
.bar { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 8pt; margin-right: 4px; }
.open { background: #fed7aa; color: #7c2d12; }
.done { background: #bbf7d0; color: #14532d; }
.gray { background: #e5e7eb; color: #374151; }
section { page-break-inside: avoid; margin-bottom: 14px; border: 1px solid #d1d5db; border-radius: 6px; padding: 10px 12px; }
section h2 { font-size: 10pt; margin: 0 0 2px 0; }
section .src { font-size: 8pt; color: #6b7280; margin-bottom: 6px; }
.q { font-weight: 600; font-size: 10.5pt; margin: 4px 0; }
.norm { font-size: 8pt; color: #555; }
.affected { font-size: 8pt; color: #555; margin: 4px 0; }
.answer { background: #ecfdf5; border: 1px solid #a7f3d0; padding: 6px 8px; border-radius: 4px; font-size: 9pt; margin-top: 6px; }
.signrow { margin-top: 30px; display: flex; gap: 40px; }
.signrow div { flex: 1; border-top: 1px solid #6b7280; padding-top: 4px; font-size: 8pt; color: #6b7280; }
@media print { .noprint { display: none; } }
.noprint { background: #fef9c3; border: 1px solid #fde047; padding: 6px 10px; border-radius: 4px; margin-bottom: 12px; font-size: 9pt; }
</style></head><body>
<div class="noprint">Tipp: Mit <kbd>Strg+P</kbd> / <kbd>Cmd+P</kbd> als PDF speichern.</div>
<h1>Klaerungsliste %s</h1>
<div class="sub">Projekt-ID %s · Stand %s</div>
` + methodologyBlock + `
<div class="meta">
<span class="bar open">%d offen</span>
<span class="bar done">%d beantwortet</span>
<span class="bar gray">%d gesamt</span>
</div>
`,
htmlEscape(project.MachineName),
htmlEscape(project.MachineName),
project.ID.String(),
time.Now().Format("2006-01-02 15:04"),
countByStatus(cls, false), countByStatus(cls, true), len(cls),
)
for _, cl := range cls {
statusCls := "open"
statusLabel := "Offen"
if cl.Status == "answered" {
statusCls, statusLabel = "done", "Beantwortet"
} else if cl.Status == "not_relevant" {
statusCls, statusLabel = "gray", "Nicht relevant"
} else if cl.Status == "in_progress" {
statusCls, statusLabel = "open", "In Klaerung"
}
fmt.Fprintf(w, `<section><div class="src">%s · <span class="bar %s">%s</span></div>
<h2>%s</h2>
`,
htmlEscape(cl.Source), statusCls, statusLabel,
htmlEscape(cl.Question),
)
if len(cl.NormReferences) > 0 {
fmt.Fprintf(w, `<div class="norm">Normen: %s</div>`, htmlEscape(strings.Join(cl.NormReferences, " | ")))
}
if len(cl.AffectedHazardNames) > 0 {
fmt.Fprintf(w, `<div class="affected">Betrifft %d Gefaehrdung(en): %s</div>`,
len(cl.AffectedHazardIDs),
htmlEscape(strings.Join(cl.AffectedHazardNames, "; ")),
)
}
if cl.Status == "answered" || cl.Status == "not_relevant" {
fmt.Fprintf(w, `<div class="answer"><strong>Antwort (%s):</strong> %s`,
htmlEscape(cl.Answer),
htmlEscape(cl.Reasoning),
)
if cl.AnsweredBy != "" {
ts := cl.AnsweredAt
if len(ts) > 10 {
ts = ts[:10]
}
fmt.Fprintf(w, ` <em>— %s, %s</em>`, htmlEscape(cl.AnsweredBy), htmlEscape(ts))
}
fmt.Fprintf(w, `</div>`)
}
fmt.Fprintf(w, `</section>`)
}
fmt.Fprintf(w, `<div class="signrow"><div>Anlagenbauer · Datum · Unterschrift</div><div>Bediener · Datum · Unterschrift</div></div>`)
fmt.Fprintf(w, `</body></html>`)
}
func htmlEscape(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", `'`, "&#39;")
return r.Replace(s)
}
func countByStatus(cls []iace.Clarification, answered bool) int {
n := 0
for _, c := range cls {
isDone := c.Status == "answered" || c.Status == "not_relevant"
if isDone == answered {
n++
}
}
return n
}
@@ -0,0 +1,69 @@
package handlers
import (
"net/http"
"github.com/breakpilot/ai-compliance-sdk/internal/iace"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// ListCustomerStandardSuggestions handles
// GET /api/v1/iace/projects/:id/customer-standards?include_verified=true|false
//
// Returns the set of reusable mitigations from prior projects of the same
// customer. Empty array when the project has no customer_name or no
// matching priors. The include_verified query flag controls whether
// status='verified' mitigations are included alongside the explicit
// is_customer_standard=true ones.
func (h *IACEHandler) ListCustomerStandardSuggestions(c *gin.Context) {
pid, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
includeVerified := c.Query("include_verified") == "true"
suggestions, err := h.store.ListCustomerStandardSuggestions(c.Request.Context(), pid, includeVerified)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if suggestions == nil {
suggestions = []iace.CustomerStandardSuggestion{}
}
c.JSON(http.StatusOK, gin.H{
"suggestions": suggestions,
"count": len(suggestions),
})
}
// ImportCustomerStandardSuggestion handles
// POST /api/v1/iace/projects/:id/customer-standards/import
// Body: { "name": "Sicherheitszeichen nach ISO 7010" }
//
// Applies one suggestion to all matching hazards in the current project.
// New mitigations are created idempotently; existing ones are flipped to
// is_relevant=true + is_customer_standard=true + status='verified'.
func (h *IACEHandler) ImportCustomerStandardSuggestion(c *gin.Context) {
pid, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
return
}
var body struct {
Name string `json:"name" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
n, err := h.store.ImportCustomerStandardSuggestion(c.Request.Context(), pid, body.Name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"imported": n,
"name": body.Name,
})
}
@@ -212,11 +212,40 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
// Join all applicable lifecycles as comma-separated string
lifecycleStr := strings.Join(mp.ApplicableLifecycles, ",")
// Phase 2: clarification questions are no longer embedded
// in the hazard description — they live in the dedicated
// /clarifications API and the UI loads them on demand.
// The hazard description stays clean and focused on the
// scenario itself. Only the aggregated norm-references
// block is appended below for an at-a-glance audit trail.
desc := mp.ScenarioDE
// Phase 17: PLr per EN ISO 13849-1 Anhang A. The graph
// inputs come from the pattern's DefaultSeverity/Exposure
// (mapped to S1/S2 and F1/F2 at threshold 3) plus
// DefaultAvoidability (P1/P2). If avoidability is unset
// we default to P1 — the conservative direction is
// downward (lower PLr), the operator can raise it
// manually after expert review.
avoid := 1
if mp.DefaultAvoidability == 2 {
avoid = 2
}
if mp.DefaultSeverity > 0 && mp.DefaultExposure > 0 {
sBin := iace.SeverityToS(mp.DefaultSeverity)
fBin := iace.ExposureToF(mp.DefaultExposure)
plr := iace.ComputePLr(sBin, fBin, avoid)
desc += fmt.Sprintf("\n\nRisikograph EN ISO 13849-1 (Anhang A): S%d · F%d · P%d → PLr %s",
sBin, fBin, avoid, plr)
}
if mp.ISO12100Section != "" {
desc += "\n\nKlassifikation: EN ISO 12100 Anhang B, Abschnitt " + mp.ISO12100Section
}
hz, cerr := h.store.CreateHazard(ctx, iace.CreateHazardRequest{
ProjectID: projectID,
ComponentID: compID,
Name: name,
Description: mp.ScenarioDE,
Description: desc,
Category: cat,
Scenario: mp.ScenarioDE,
Function: iace.EncodeOpStates(mp.OperationalStates),
@@ -273,16 +302,29 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
}
}
// For each hazard: assign up to maxMitigationsPerHazard measures
// Priority 1: Pattern-specific SuggestedMeasureIDs (from the pattern that created this hazard)
// Priority 2: Category fallback (generic measures for the hazard category)
// For each hazard: only pattern-specific SuggestedMeasureIDs are
// used, FILTERED by category. Measures whose HazardCategory is
// incompatible with the pattern's accepted set are skipped with a
// MEASURE-SKIP log entry. There is NO category fallback any more —
// if the pattern author left a hazard without applicable measures,
// the hazard is created with zero mitigations and the operator must
// consult an expert. This is the only honest answer: silently
// inventing generic defaults (the previous behavior) produced
// nonsense like "Rotationsbewegung vermeiden" for a sharp-edge
// hazard. See feat/iace-measure-category-filter for context.
_ = measuresByCat // retained for backwards-compat read by other code paths
_ = patternCatToMeasureCat
zeroMitigationHazards := 0
for _, hazID := range allHazardIDs {
hazCat := hazardCatByID[hazID]
measCat := patternCatToMeasureCat(hazCat)
accepted := acceptableMeasureCategories(hazCat)
added := 0
usedIDs := make(map[string]bool)
// Aggregate norm references across all kept mitigations for this
// hazard so we can attach a single "Referenzierte Normen" line
// to the hazard description below.
var hazardNorms []string
seenNorm := map[string]bool{}
// Priority 1: Pattern-specific measures
if patternMIDs, ok := hazardPatternMeasures[hazID]; ok {
for _, mid := range patternMIDs {
if added >= maxMitigationsPerHazard {
@@ -292,44 +334,61 @@ func (h *IACEHandler) InitializeProject(c *gin.Context) {
if !ok {
continue
}
if !isCategoryCompatible(entry.HazardCategory, accepted) {
fmt.Printf("MEASURE-SKIP: pattern-cat=%s acceptable=%v but mid=%s has cat=%s (%q) — skipping mismatch\n",
hazCat, keysOf(accepted), mid, entry.HazardCategory, entry.Name)
continue
}
rt := iace.ReductionType(entry.ReductionType)
if rt == "" {
rt = iace.ReductionTypeInformation
}
mitDesc := entry.Description
if len(entry.NormReferences) > 0 {
mitDesc += "\n\nNormen: " + strings.Join(entry.NormReferences, " | ")
for _, n := range entry.NormReferences {
if !seenNorm[n] {
seenNorm[n] = true
hazardNorms = append(hazardNorms, n)
}
}
}
_, cerr := h.store.CreateMitigation(ctx, iace.CreateMitigationRequest{
HazardID: hazID, ReductionType: rt,
Name: entry.Name, Description: entry.Description,
Name: entry.Name, Description: mitDesc,
})
if cerr != nil {
fmt.Printf("MEASURE-ERROR: mid=%s name=%s err=%v\n", mid, entry.Name, cerr)
} else {
created++
added++
usedIDs[mid] = true
}
}
}
// Append the aggregated norm list to the hazard so the UI shows
// a single "Referenzierte Normen" panel per hazard.
if len(hazardNorms) > 0 {
if existing, getErr := h.store.GetHazard(ctx, hazID); getErr == nil && existing != nil {
if !strings.Contains(existing.Description, "Referenzierte Normen:") {
newDesc := existing.Description + "\n\nReferenzierte Normen: " + strings.Join(hazardNorms, " | ")
_, _ = h.store.UpdateHazard(ctx, hazID, map[string]interface{}{
"description": newDesc,
})
}
}
}
// Priority 2: Category fallback (skip already-used IDs)
for _, m := range measuresByCat[measCat] {
if added >= maxMitigationsPerHazard || usedIDs[m.ID] {
continue
}
rt := iace.ReductionType(m.ReductionType)
if rt == "" {
rt = iace.ReductionTypeInformation
}
_, cerr := h.store.CreateMitigation(ctx, iace.CreateMitigationRequest{
HazardID: hazID, ReductionType: rt,
Name: m.Name, Description: m.Description,
})
if cerr == nil {
created++
added++
}
if added == 0 {
zeroMitigationHazards++
fmt.Printf("COVERAGE-GAP: hazard %s (cat=%s) has no pattern-specific measures — operator must consult expert\n",
hazID, hazCat)
}
}
if zeroMitigationHazards > 0 {
fmt.Printf("COVERAGE-GAP-SUMMARY: %d/%d hazards in this project have no mitigations and need expert review\n",
zeroMitigationHazards, len(allHazardIDs))
}
patternMeasureCount := 0
for _, mids := range hazardPatternMeasures {
patternMeasureCount += len(mids)
@@ -45,6 +45,108 @@ func extractNarrativeFromMetadata(metadata json.RawMessage) string {
return result
}
// acceptableMeasureCategories returns the set of measure HazardCategory values
// that are semantically applicable to a hazard with the given pattern category.
// The mapping is a *set*, not a single value — many pattern categories accept
// measures from several measure-library categories that are conceptually
// related. E.g. a safety_function_failure hazard is sensibly mitigated by
// software_control measures like watchdogs, plausibility checks or self-tests,
// not just by the (almost empty) safety_function category.
//
// "general" is implicit — handled in isCategoryCompatible and not duplicated
// in every set below.
func acceptableMeasureCategories(patternCat string) map[string]bool {
sets := map[string][]string{
"mechanical_hazard": {"mechanical"},
"electrical_hazard": {"electrical"},
"thermal_hazard": {"thermal", "material_environmental"},
// ISO 12100 Anhang B splits Nr. 4 Laerm and Nr. 5 Vibration into
// two top-level groups. The legacy combined alias noise_vibration
// is kept for backwards compat — all three resolve to the same
// measure pool today (the library doesn't separate noise vs
// vibration measures), but the pattern category now matches the
// norm structure.
"noise_hazard": {"noise_vibration", "ergonomic"},
"vibration_hazard": {"noise_vibration", "ergonomic"},
"noise_vibration": {"noise_vibration", "ergonomic"},
"pneumatic_hydraulic": {"pneumatic_hydraulic", "mechanical"},
"material_environmental": {"material_environmental"},
"chemical_risk": {"material_environmental", "thermal"},
"ergonomic": {"ergonomic"},
"ergonomic_hazard": {"ergonomic"},
"fire_explosion": {"thermal", "material_environmental"},
"radiation_hazard": {"material_environmental"},
"emc_hazard": {"electrical", "software_control"},
"maintenance_hazard": {"mechanical"},
"safety_function_failure": {"safety_function", "software_control"},
"software_fault": {"software_control"},
"sensor_fault": {"software_control"},
"configuration_error": {"software_control"},
"update_failure": {"software_control"},
"hmi_error": {"software_control"},
"mode_confusion": {"software_control"},
"unauthorized_access": {"cyber_network", "software_control"},
"communication_failure": {"cyber_network", "software_control"},
"firmware_corruption": {"cyber_network", "software_control"},
"logging_audit_failure": {"cyber_network", "software_control"},
"ai_misclassification": {"ai_specific", "software_control"},
"false_classification": {"ai_specific", "software_control"},
"model_drift": {"ai_specific", "software_control"},
"data_poisoning": {"ai_specific", "software_control"},
"sensor_spoofing": {"ai_specific", "software_control"},
"unintended_bias": {"ai_specific", "software_control"},
// CRA / DIN EN 40000-1-2 cyber-resilience patterns (HP1910+).
// cyber_resilience is the umbrella category used by patterns that
// fire on the manufacturer-side obligations: SBOM, signed updates,
// CVD policy, patch-SLA, hardening docs, incident notification.
// Accept measures from the dedicated cyber_resilience pool plus the
// broader cyber_network and software_control pools (existing
// measures like "intrusion detection" or "audit logging" are
// applicable here too).
"cyber_resilience": {"cyber_resilience", "cyber_network", "software_control"},
// Edge-case pattern categories from legacy authors. Treated as
// synonyms of their primary hazard category so existing patterns
// keep matching the right measure pool.
"noise_source": {"noise_vibration", "ergonomic"},
"vibration_source": {"noise_vibration", "ergonomic"},
"high_temperature": {"thermal", "material_environmental"},
"material_environmental_hazard": {"material_environmental"},
}
out := map[string]bool{"general": true}
if list, ok := sets[patternCat]; ok {
for _, c := range list {
out[c] = true
}
}
return out
}
// isCategoryCompatible reports whether a measure with HazardCategory measureCat
// is semantically applicable to a hazard whose acceptable measure categories
// are listed in accepted. Empty measureCat is always allowed (legacy entries),
// "general" measures are pre-seeded into accepted by acceptableMeasureCategories.
//
// Without this guard, patterns silently inherit nonsense mitigations (e.g.
// HP1651 "robot restart while person in cell" inheriting M054 "Sichere
// thermische Auslegung" — a thermal-design measure used as generic default in
// ~100 mechanical patterns). The Fachmann benchmark rejects such mismatches.
func isCategoryCompatible(measureCat string, accepted map[string]bool) bool {
if measureCat == "" {
return true
}
return accepted[measureCat]
}
// keysOf returns the sorted keys of a string-bool set, used for diagnostic
// log messages that report which measure categories were accepted for a hazard.
func keysOf(s map[string]bool) []string {
out := make([]string, 0, len(s))
for k := range s {
out = append(out, k)
}
return out
}
// patternCatToMeasureCat maps pattern hazard categories to measure categories.
func patternCatToMeasureCat(patternCat string) string {
m := map[string]string{
@@ -63,6 +165,7 @@ func patternCatToMeasureCat(patternCat string) string {
"update_failure": "software_control", "hmi_error": "software_control",
"emc_hazard": "electrical", "maintenance_hazard": "mechanical",
"mode_confusion": "software_control", "chemical_risk": "material_environmental",
"cyber_resilience": "cyber_resilience",
}
if cat, ok := m[patternCat]; ok {
return cat
@@ -0,0 +1,57 @@
package handlers
import "testing"
// TestAcceptableMeasureCategories pins the set-based category acceptance map.
// Each pattern category accepts not just its own measure category but a
// curated set of semantically related ones — a safety_function_failure
// pattern is sensibly mitigated by software_control measures (watchdogs,
// plausibility checks), not just by the near-empty safety_function category.
func TestAcceptableMeasureCategories(t *testing.T) {
cases := []struct {
patternCat string
mustAccept []string // measure categories that MUST be accepted
mustReject []string // measure categories that MUST be rejected
}{
// mechanical hazards: own + general only
{"mechanical_hazard", []string{"mechanical", "general"}, []string{"thermal", "electrical"}},
// electrical hazards: own + general only
{"electrical_hazard", []string{"electrical", "general"}, []string{"thermal", "mechanical"}},
// safety-function failures accept watchdogs (software_control)
{"safety_function_failure", []string{"safety_function", "software_control", "general"}, []string{"mechanical", "thermal"}},
// EMC accepts electrical + software (shielding + filter logic both apply)
{"emc_hazard", []string{"electrical", "software_control", "general"}, []string{"mechanical"}},
// AI failures accept ai_specific + software_control
{"false_classification", []string{"ai_specific", "software_control", "general"}, []string{"mechanical", "electrical"}},
// Fire/explosion accepts thermal + material_environmental
{"fire_explosion", []string{"thermal", "material_environmental", "general"}, []string{"mechanical", "electrical"}},
// Unknown pattern category: only general
{"unknown_made_up_cat", []string{"general"}, []string{"mechanical", "electrical"}},
}
for _, c := range cases {
accepted := acceptableMeasureCategories(c.patternCat)
for _, mc := range c.mustAccept {
if !isCategoryCompatible(mc, accepted) {
t.Errorf("patternCat=%q must accept measureCat=%q but rejected (set=%v)",
c.patternCat, mc, accepted)
}
}
for _, mc := range c.mustReject {
if isCategoryCompatible(mc, accepted) {
t.Errorf("patternCat=%q must reject measureCat=%q but accepted (set=%v)",
c.patternCat, mc, accepted)
}
}
}
}
// TestIsCategoryCompatible_EmptyMeasureCat pins that legacy measures with
// no HazardCategory set are always allowed — they would otherwise silently
// disappear during the re-init, since the audit found ~80 such entries in
// older library files.
func TestIsCategoryCompatible_EmptyMeasureCat(t *testing.T) {
accepted := acceptableMeasureCategories("mechanical_hazard")
if !isCategoryCompatible("", accepted) {
t.Error("empty measure category must be accepted (legacy entries)")
}
}
+13
View File
@@ -451,6 +451,19 @@ func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) {
// CE x Compliance Crossover
iaceRoutes.GET("/projects/:id/compliance-triggers", h.GetComplianceTriggers)
iaceRoutes.GET("/compliance-faq", h.GetComplianceFAQ)
// Clarifications — aggregated open questions per project
iaceRoutes.GET("/projects/:id/clarifications", h.ListClarifications)
iaceRoutes.GET("/projects/:id/clarifications.csv", h.ExportClarificationsCSV)
iaceRoutes.GET("/projects/:id/clarifications.html", h.ExportClarificationsHTML)
iaceRoutes.GET("/projects/:id/clarifications/:cid/detail", h.ListClarificationDetail)
iaceRoutes.POST("/projects/:id/clarifications/:cid/answer", h.AnswerClarification)
iaceRoutes.POST("/projects/:id/clarifications/:cid/comment", h.PostClarificationComment)
// Customer-Standard Reuse (migration 031): pull reusable mitigations
// across prior projects of the same customer.
iaceRoutes.GET("/projects/:id/customer-standards", h.ListCustomerStandardSuggestions)
iaceRoutes.POST("/projects/:id/customer-standards/import", h.ImportCustomerStandardSuggestion)
}
}
@@ -0,0 +1,243 @@
package iace
import (
"strings"
"github.com/google/uuid"
)
// Clarification represents an aggregated open question that the operator
// must verify with the Anlagenbauer. The engine NEVER generates commentary
// — it only surfaces norm-/manufacturer-derived check items that can be
// objectively answered.
//
// IDs are deterministic so existing answers survive every project re-init:
// - pattern:<HP-ID>:<index> — question is hard-coded on a HazardPattern
// - manuf:<Manufacturer>:<index> — question comes from the manufacturer library
//
// "AffectedHazardIDs" / "AffectedMitigationIDs" are filled at request time
// from the project's current hazards. They tell the UI which entries in the
// hazard list will be marked "geklaert" once this clarification is answered.
type Clarification struct {
ID string `json:"id"`
Question string `json:"question"`
Source string `json:"source"` // "FANUC (Dual Check Safety)", "Pattern HP1640", ...
Category string `json:"category"` // "manufacturer" | "pattern_norm"
NormReferences []string `json:"norm_references,omitempty"`
AffectedHazardIDs []uuid.UUID `json:"affected_hazard_ids"`
AffectedHazardNames []string `json:"affected_hazard_names"` // shown directly in the table
AffectedMitigationIDs []uuid.UUID `json:"affected_mitigation_ids,omitempty"`
// State (merged from project.metadata.clarification_answers)
Status string `json:"status"` // "open" | "in_progress" | "answered" | "not_relevant"
Answer string `json:"answer,omitempty"` // "ja" | "nein" | "teilweise"
Reasoning string `json:"reasoning,omitempty"`
AnsweredBy string `json:"answered_by,omitempty"`
AnsweredAt string `json:"answered_at,omitempty"`
AssignedTo string `json:"assigned_to,omitempty"`
}
// ClarificationAnswer is the persisted shape (one entry in
// project.metadata.clarification_answers[<clarification.id>]).
type ClarificationAnswer struct {
Status string `json:"status"`
Answer string `json:"answer,omitempty"`
Reasoning string `json:"reasoning,omitempty"`
AnsweredBy string `json:"answered_by,omitempty"`
AnsweredAt string `json:"answered_at,omitempty"`
AssignedTo string `json:"assigned_to,omitempty"`
}
// BuildProjectClarifications walks the project's current hazards and returns
// the deduplicated list of clarification questions that apply, with each
// hazard correctly cross-referenced.
//
// Inputs are resolved upstream so this function stays free of DB access and
// is unit-testable:
// - hazards: the project's persisted hazards (Name, ID, Category)
// - hazardSourcePatterns: per hazard, the HP-IDs that fired for it (today
// we don't have a clean back-reference, so the handler does a name+zone
// re-match against patterns)
// - manufacturerHits: ManufacturerSafetyFeature entries whose aliases were
// found in the project narrative
// - answers: map[clarificationID]ClarificationAnswer from project.metadata
func BuildProjectClarifications(
hazards []Hazard,
hazardSourcePatterns map[uuid.UUID][]string,
manufacturerHits []ManufacturerSafetyFeature,
answers map[string]ClarificationAnswer,
) []Clarification {
// Lookup helpers
patternByID := make(map[string]HazardPattern)
for _, p := range collectAllPatterns() {
patternByID[p.ID] = p
}
// Bucket by clarification ID so we accumulate affected hazards
buckets := make(map[string]*Clarification)
// 1) Pattern-level clarifications
for hzID, hpIDs := range hazardSourcePatterns {
hz := findHazard(hazards, hzID)
if hz == nil {
continue
}
for _, hpID := range hpIDs {
p, ok := patternByID[hpID]
if !ok {
continue
}
for i, q := range p.ClarificationQuestionsDE {
cid := "pattern:" + hpID + ":" + intStr(i)
b, exists := buckets[cid]
if !exists {
b = &Clarification{
ID: cid,
Question: q,
Source: "Pattern " + hpID + " — " + p.NameDE,
Category: "pattern_norm",
Status: "open",
}
buckets[cid] = b
}
b.AffectedHazardIDs = append(b.AffectedHazardIDs, hz.ID)
b.AffectedHazardNames = appendUniqueString(b.AffectedHazardNames, hz.Name)
}
}
}
// 2) Manufacturer-level clarifications — apply to every hazard whose
// category matches the manufacturer entry's AppliesToHazardCats
for _, mf := range manufacturerHits {
applicable := func(cat string) bool {
if len(mf.AppliesToHazardCats) == 0 {
return true
}
for _, c := range mf.AppliesToHazardCats {
if c == cat {
return true
}
}
return false
}
for i, q := range mf.Clarifications {
cid := "manuf:" + slug(mf.Manufacturer) + ":" + slug(mf.FeatureName) + ":" + intStr(i)
b, exists := buckets[cid]
if !exists {
b = &Clarification{
ID: cid,
Question: q,
Source: mf.Manufacturer + " — " + mf.FeatureName,
Category: "manufacturer",
NormReferences: mf.NormReferences,
Status: "open",
}
buckets[cid] = b
}
for _, hz := range hazards {
if !applicable(hz.Category) {
continue
}
b.AffectedHazardIDs = append(b.AffectedHazardIDs, hz.ID)
b.AffectedHazardNames = appendUniqueString(b.AffectedHazardNames, hz.Name)
}
}
}
// Merge persisted answers
out := make([]Clarification, 0, len(buckets))
for cid, b := range buckets {
if ans, ok := answers[cid]; ok {
if ans.Status != "" {
b.Status = ans.Status
} else if ans.Answer != "" {
b.Status = "answered"
}
b.Answer = ans.Answer
b.Reasoning = ans.Reasoning
b.AnsweredBy = ans.AnsweredBy
b.AnsweredAt = ans.AnsweredAt
b.AssignedTo = ans.AssignedTo
}
// dedup hazard IDs (multiple patterns can target the same hazard)
b.AffectedHazardIDs = dedupUUIDs(b.AffectedHazardIDs)
out = append(out, *b)
}
return out
}
func findHazard(hazards []Hazard, id uuid.UUID) *Hazard {
for i := range hazards {
if hazards[i].ID == id {
return &hazards[i]
}
}
return nil
}
func appendUniqueString(slice []string, s string) []string {
for _, x := range slice {
if x == s {
return slice
}
}
return append(slice, s)
}
func dedupUUIDs(ids []uuid.UUID) []uuid.UUID {
seen := make(map[uuid.UUID]bool, len(ids))
out := make([]uuid.UUID, 0, len(ids))
for _, id := range ids {
if !seen[id] {
seen[id] = true
out = append(out, id)
}
}
return out
}
func intStr(i int) string {
if i == 0 {
return "0"
}
neg := false
if i < 0 {
neg = true
i = -i
}
var buf [20]byte
pos := len(buf)
for i > 0 {
pos--
buf[pos] = byte('0' + i%10)
i /= 10
}
if neg {
pos--
buf[pos] = '-'
}
return string(buf[pos:])
}
// slug lowercases and replaces non-[a-z0-9] with "-" so the manufacturer name
// and feature name can be embedded in a stable clarification ID.
func slug(s string) string {
s = normalizeForMatch(s) // already lower + umlaut-folded
var b strings.Builder
prevDash := false
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
prevDash = false
} else {
if !prevDash && b.Len() > 0 {
b.WriteRune('-')
prevDash = true
}
}
}
out := b.String()
if strings.HasSuffix(out, "-") {
out = out[:len(out)-1]
}
return out
}
@@ -0,0 +1,161 @@
package iace
import (
"strings"
"testing"
)
// TestGTBremse_PinnedHazardToMeasureMappings is a regression net for the IACE
// benchmark fix. Each pinned (GT-Nr, hazard pattern, measure) triple was
// validated by an expert review on 2026-05 against testdata/ground_truth_bremse.json.
// If any pattern stops referencing the listed measures, this test fails — so the
// underlying GT scenario is no longer answered with the Fachmann-grade mitigation.
//
// Adding new entries here pins the Engine's answer for a specific GT scenario.
// Removing entries means the GT scenario is no longer covered with the same
// concrete measure (e.g. because the library was reorganized) — that needs an
// active decision, not a silent drift.
func TestGTBremse_PinnedHazardToMeasureMappings(t *testing.T) {
cases := []struct {
gtNr string
patternID string
requiredMeasures []string
}{
// GT 2.1/2.2: Elektrischer Schlag durch direktes Beruehren
// Expert demand: konkrete Isolation MOhm + IP2X Einhausung
{"2.1/2.2", "HP1640", []string{"M481", "M482"}},
// GT 2.4: Schutzleiterfehler (>10 mA Ableitstroeme)
// Expert demand: mech. Schutz + 10mm²-Cu + Ueberwachung + durchgehende Verbindung
{"2.4", "HP1641", []string{"M511", "M512", "M514", "M515"}},
// GT 2.5: Indirektes Beruehren — Schutzleiter durchgaengig + SK II / Kleinspannung
{"2.5", "HP1685", []string{"M511", "M512", "M515", "M516"}},
// GT 2.7: RCD an Steckdosenkreisen
{"2.7", "HP1689", []string{"M518"}},
// GT 2.12: Potentialausgleich zwischen Anlagenteilen
{"2.12", "HP1688", []string{"M475", "M477"}},
// GT 1.3: Pneumatik-Komponenten + Schlauchsicherung
{"1.3", "HP1630", []string{"M483", "M484", "M485"}},
// GT 1.5: Pneumatik-Restenergie nach Abschaltung
{"1.5", "HP1717", []string{"M485", "M534"}},
// GT 1.7: Teach-Modus mit Schluesselschalter + 250 mm/s + Zustimmtaster
{"1.7", "HP1605", []string{"M491", "M492", "M493"}},
// GT 1.8: Sicher begrenzter Bewegungsbereich + Zaun-Lastbemessung
{"1.8", "HP1604", []string{"M494", "M501"}},
// GT 1.10/1.18: Reach-over Sicherheitsabstand
{"1.10/1.18", "HP1602", []string{"M495", "M486"}},
// GT 1.11: Foerderband-Geometrie (Abstand + Oeffnungsgroesse)
{"1.11", "HP1621", []string{"M496", "M497", "M498"}},
// GT 1.22: Greifer-Versagen + Werkstueck weggeschleudert
{"1.22", "HP1711", []string{"M501", "M502", "M536"}},
// GT 1.24: Eingeschlossen in Zelle — Innenoeffnung + bewusster Wiederanlauf
{"1.24", "HP1603", []string{"M489", "M488"}},
// GT 1.12/1.24 (HP1651 Wiederanlauf-Variante): Wiederanlauf-Schutz-Measures —
// NOT thermal (M054 was wrongly placed here and surfaced as
// "Sichere thermische Auslegung" for a restart hazard)
{"1.12/1.24", "HP1651", []string{"M488", "M487", "M489", "M490"}},
// GT 1.1 (HP1625 sharp edges): edge-specific only, no rotational/distance fillers
{"1.1", "HP1625", []string{"M003", "M004", "M027"}},
// GT 1.26: Foerderband-Geschwindigkeit < 100 mm/s
{"1.26", "HP1620", []string{"M498", "M499"}},
// GT 1.27: Mechanischer Anschlag am Bandende
{"1.27", "HP1622", []string{"M500"}},
// GT 1.30: Druckluft-Reinigungsduese
{"1.30", "HP1712", []string{"M504", "M505"}},
// GT 1.32: WZM-Beladetuer + zweikanaliger Tuerschalter
{"1.32", "HP1634", []string{}}, // skipped: HP1634 already had M061; verify exists
// GT 1.34/2.10: KSS-Druckschlauch
{"1.34/2.10", "HP1675", []string{"M484", "M483"}},
// GT 1.38/1.39: KSS-Auslauf unten + Druck begrenzt
{"1.38/1.39", "HP1703", []string{"M505", "M506", "M526"}},
// GT 2.9: Wasser/Reinigung Schaltschrank
{"2.9", "HP1716", []string{"M521", "M522", "M539"}},
// GT 7.1: KSS-Hautkontakt
{"7.1", "HP1715", []string{"M408", "M533"}},
// GT 8.1: Manuelle Werkstueck-Handhabung + Hebehilfe >25kg
{"8.1", "HP1713", []string{"M530", "M532"}},
// GT 8.2: Bedienelement-Position ergonomisch
{"8.2", "HP1714", []string{"M531"}},
}
patterns := collectAllPatterns()
measureByID := make(map[string]ProtectiveMeasureEntry)
for _, m := range GetProtectiveMeasureLibrary() {
measureByID[m.ID] = m
}
patternByID := make(map[string]HazardPattern)
for _, p := range patterns {
patternByID[p.ID] = p
}
for _, c := range cases {
t.Run(c.gtNr+"_"+c.patternID, func(t *testing.T) {
p, ok := patternByID[c.patternID]
if !ok {
t.Fatalf("pattern %s missing — GT %s no longer covered", c.patternID, c.gtNr)
}
suggested := make(map[string]bool)
for _, m := range p.SuggestedMeasureIDs {
suggested[m] = true
}
for _, req := range c.requiredMeasures {
if _, exists := measureByID[req]; !exists {
t.Errorf("required measure %s referenced by GT %s does not exist in library", req, c.gtNr)
continue
}
if !suggested[req] {
t.Errorf("pattern %s no longer suggests %s — GT %s expert mitigation lost (current: %v)",
c.patternID, req, c.gtNr, p.SuggestedMeasureIDs)
}
}
})
}
}
// TestGTBremse_ExpertMeasuresAllResolvable pins the static-text expectation
// that every Fachmann measure newly added during the 2026-05 GT coverage work
// (M481-M482, M483-M539) carries the concrete EN/IEC/ISO/DGUV norm reference
// that the expert cited in the GT file. A measure without a concrete norm
// reference is a regression — generic "Sichere X" entries were exactly the
// problem this work was meant to fix.
func TestGTBremse_ExpertMeasuresAllResolvable(t *testing.T) {
expertIDs := []string{
"M481", "M482", "M483", "M484", "M485", "M486", "M487", "M488", "M489", "M490",
"M491", "M492", "M493", "M494", "M495", "M496", "M497", "M498", "M499", "M500",
"M501", "M502", "M503", "M504", "M505", "M506", "M507", "M508", "M509", "M510",
"M511", "M512", "M513", "M514", "M515", "M516", "M517", "M518", "M519", "M520",
"M521", "M522", "M523", "M524", "M525", "M526", "M527", "M528", "M529", "M530",
"M531", "M532", "M533", "M534", "M535", "M536", "M537", "M538", "M539",
}
measureByID := make(map[string]ProtectiveMeasureEntry)
for _, m := range GetProtectiveMeasureLibrary() {
measureByID[m.ID] = m
}
knownPrefixes := []string{"EN ", "IEC ", "ISO ", "DIN ", "TRBS", "TRGS", "ASR ", "DGUV", "OSHA", "VDE", "EN ISO", "DIN EN"}
for _, id := range expertIDs {
m, ok := measureByID[id]
if !ok {
t.Errorf("expert measure %s missing from library", id)
continue
}
if len(m.NormReferences) == 0 {
t.Errorf("measure %s (%q) has no NormReferences — concrete norm anchor missing", id, m.Name)
continue
}
found := false
for _, nr := range m.NormReferences {
for _, p := range knownPrefixes {
if strings.HasPrefix(nr, p) {
found = true
break
}
}
if found {
break
}
}
if !found {
t.Errorf("measure %s (%q) NormReferences %v contain no recognized norm prefix",
id, m.Name, m.NormReferences)
}
}
}
@@ -19,6 +19,18 @@ type HazardPattern struct {
RequiresExpertCalculation bool `json:"requires_expert_calculation,omitempty"`
ExpertHintDE string `json:"expert_hint_de,omitempty"`
ExpertHintEN string `json:"expert_hint_en,omitempty"`
// ClarificationQuestionsDE: konkrete Fragen die der Bediener mit dem
// Anlagenbauer abklaeren sollte, bevor er das Hazard als abgedeckt
// betrachtet. Die Engine erfindet keine Begruendungen, sondern hinter-
// legt nur prueffaehige Fragen aus Norm-Wissen. Beispiele:
// - "Liegt ein Pruefprotokoll nach EN 60204-1 vor?"
// - "Wird die Sicherheitsfunktion durch CE-Konformitaet der
// Subkomponente abgedeckt? (Herstellererklaerung anfordern)"
// - "Ist DCS-Achsbegrenzung am Roboter konfiguriert und validiert?"
// Im Init-Handler werden diese Fragen an die Hazard.Description
// angehaengt mit einem klaren Trenner — kein DB-Schema-Aenderungs-
// bedarf.
ClarificationQuestionsDE []string `json:"clarification_questions_de,omitempty"`
// Detail fields — populated into Hazard DB when pattern fires
ScenarioDE string `json:"scenario_de,omitempty"` // Gefahrensituation
TriggerDE string `json:"trigger_de,omitempty"` // Gefaehrdendes Ereignis
@@ -58,6 +70,58 @@ type HazardPattern struct {
// is relevant. Written into the Hazard's LifecyclePhase field on creation.
// Empty = not set (pattern does not specify lifecycle applicability).
ApplicableLifecycles []string `json:"applicable_lifecycles,omitempty"`
// ISO12100Section is a free-text identifier of the ISO 12100 Annex B
// section that owns this hazard type (e.g. "6.2.2.1" or "6.3.5.5").
// Stored as an identifier only — the norm text is NOT included to
// keep the library urheberrechtlich neutral (DIN/Beuth license).
// The frontend renders it as "EN ISO 12100 Abschnitt 6.3.5.5".
ISO12100Section string `json:"iso_12100_section,omitempty"`
// DefaultAvoidability is the P parameter of the EN ISO 13849-1
// risk graph (Annex A): 1 = avoidable under certain conditions, 2 =
// hardly avoidable. Combined with DefaultSeverity (S1/S2 derived
// at threshold 3) and DefaultExposure (F1/F2 at threshold 3) it
// feeds into the PLr (required Performance Level) computation,
// see ComputePLr.
DefaultAvoidability int `json:"default_avoidability,omitempty"` // 1 or 2
}
// ComputePLr returns the required Performance Level (PLr) per EN ISO
// 13849-1 Anhang A (Risikograph). Inputs are the three parameters of
// the graph in their 1/2 form:
// - s (Schwere): 1 = leicht/reversibel, 2 = schwer/irreversibel inkl. Tod
// - f (Haeufigkeit/Dauer): 1 = selten/kurz, 2 = haeufig/dauernd
// - p (Moeglichkeit Vermeidung): 1 = unter Bedingungen moeglich, 2 = kaum
// Return value is one of "a", "b", "c", "d", "e" (PLa..PLe).
//
// The mapping follows the canonical 8-leaf binary tree of the standard:
// S1 F1 P1 -> a
// S1 F1 P2 -> b
// S1 F2 P1 -> b
// S1 F2 P2 -> c
// S2 F1 P1 -> c
// S2 F1 P2 -> d
// S2 F2 P1 -> d
// S2 F2 P2 -> e
func ComputePLr(s, f, p int) string {
idx := 0
if s == 2 { idx += 4 }
if f == 2 { idx += 2 }
if p == 2 { idx += 1 }
return []string{"a", "b", "b", "c", "c", "d", "d", "e"}[idx]
}
// SeverityToS maps a 1-5 DefaultSeverity to the binary S parameter of
// EN ISO 13849-1: 1-2 -> S1 (leicht/reversibel), 3-5 -> S2 (schwer/Tod).
func SeverityToS(severity int) int {
if severity >= 3 { return 2 }
return 1
}
// ExposureToF maps a 1-5 DefaultExposure to the binary F parameter of
// EN ISO 13849-1: 1-2 -> F1 (selten/kurz), 3-5 -> F2 (haeufig/dauernd).
func ExposureToF(exposure int) int {
if exposure >= 3 { return 2 }
return 1
}
// Standard human roles for machinery interaction (ISO 12100 + BetrSichV).
@@ -180,7 +180,7 @@ func GetAGVAgriPatterns() []HazardPattern {
RequiredEnergyTags: []string{},
ExcludedComponentTags: []string{"single_agv_system"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M054", "M106"},
SuggestedMeasureIDs: []string{"M002", "M008", "M061", "M141"},
SuggestedEvidenceIDs: []string{"E01"},
Priority: 78,
ScenarioDE: "Zwei AGVs kollidieren an einer Kreuzung oder im engen Gang. Ladung wird verschoben, umstehende Personen gefaehrdet.",
@@ -195,7 +195,7 @@ func GetAGVAgriPatterns() []HazardPattern {
RequiredComponentTags: []string{"agv", "sensor_part", "electrical_part"},
RequiredEnergyTags: []string{"electrical"},
GeneratedHazardCats: []string{"electrical_hazard", "safety_function_failure"},
SuggestedMeasureIDs: []string{"M106"},
SuggestedMeasureIDs: []string{"M478", "M479", "M141"},
SuggestedEvidenceIDs: []string{"E01"},
Priority: 72,
ScenarioDE: "Elektromagnetische Stoerungen (Schweissgeraete, Frequenzumrichter) beeinflussen AGV-Sensorik oder Steuerung.",
@@ -8,7 +8,7 @@ func builtinAIPatterns() []HazardPattern {
RequiredComponentTags: []string{"has_ai"},
RequiredEnergyTags: []string{"ai_model"},
GeneratedHazardCats: []string{"false_classification"},
SuggestedMeasureIDs: []string{"M101", "M102"},
SuggestedMeasureIDs: []string{"M133", "M214", "M213", "M044", "M119"},
SuggestedEvidenceIDs: []string{"E01", "E15"},
Priority: 90,
ScenarioDE: "KI-Modell klassifiziert Objekt oder Zustand falsch und loest darauf basierend eine gefaehrliche Aktion aus.",
@@ -23,7 +23,7 @@ func builtinAIPatterns() []HazardPattern {
RequiredComponentTags: []string{"has_ai"},
RequiredEnergyTags: []string{"ai_model"},
GeneratedHazardCats: []string{"model_drift"},
SuggestedMeasureIDs: []string{"M103"},
SuggestedMeasureIDs: []string{"M133", "M227", "M214", "M112"},
SuggestedEvidenceIDs: []string{"E01", "E15"},
Priority: 85,
ScenarioDE: "KI-Modell verliert ueber Zeit an Genauigkeit, weil sich Eingangsdaten schleichend veraendern.",
@@ -38,7 +38,7 @@ func builtinAIPatterns() []HazardPattern {
RequiredComponentTags: []string{"has_ai"},
RequiredEnergyTags: []string{"cyber", "ai_model"},
GeneratedHazardCats: []string{"data_poisoning"},
SuggestedMeasureIDs: []string{"M101", "M116"},
SuggestedMeasureIDs: []string{"M188", "M133", "M113", "M214", "M187"},
SuggestedEvidenceIDs: []string{"E01", "E15", "E16"},
Priority: 85,
ScenarioDE: "Angreifer manipuliert Trainingsdaten oder Eingangssignale, um das KI-Modell gezielt zu taeuschen.",
@@ -53,7 +53,7 @@ func builtinAIPatterns() []HazardPattern {
RequiredComponentTags: []string{"has_ai"},
RequiredEnergyTags: []string{"ai_model"},
GeneratedHazardCats: []string{"unintended_bias"},
SuggestedMeasureIDs: []string{"M101"},
SuggestedMeasureIDs: []string{"M133", "M227", "M204"},
SuggestedEvidenceIDs: []string{"E01", "E15"},
Priority: 75,
ScenarioDE: "KI-Modell trifft systematisch ungleiche Entscheidungen fuer bestimmte Personengruppen oder Bedingungen.",
@@ -68,7 +68,7 @@ func builtinAIPatterns() []HazardPattern {
RequiredComponentTags: []string{"has_ai", "sensor_part"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"sensor_spoofing"},
SuggestedMeasureIDs: []string{"M101", "M102"},
SuggestedMeasureIDs: []string{"M213", "M214", "M119", "M133"},
SuggestedEvidenceIDs: []string{"E01", "E15"},
Priority: 80,
ScenarioDE: "Sensor, der KI-Eingangsdaten liefert, wird manipuliert oder liefert durch Verschmutzung/Defekt falsche Werte.",
@@ -129,7 +129,7 @@ func GetCNCHazardPatterns() []HazardPattern {
ID: "HP1408", NameDE: "Falscher Werkzeug-Offset", NameEN: "Wrong tool offset after setup",
RequiredComponentTags: []string{"cutting_tool", "programmable"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M041", "M050"},
SuggestedMeasureIDs: []string{"M008", "M001", "M061", "M141"},
SuggestedEvidenceIDs: []string{"E14"},
Priority: 78, MachineTypes: cncTypes,
OperationalStates: []string{"teach_mode"},
@@ -220,7 +220,7 @@ func GetCNCHazardPatterns() []HazardPattern {
ID: "HP1414", NameDE: "Rutschgefahr durch KSS-Leckage am Boden", NameEN: "Slip hazard from MWF leakage on floor",
RequiredComponentTags: []string{"cutting_tool"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M420", "M101"},
SuggestedMeasureIDs: []string{"M538", "M484", "M141"},
SuggestedEvidenceIDs: []string{"E01"},
Priority: 65, MachineTypes: cncTypes,
ScenarioDE: "KSS-Leckage erzeugt rutschigen Bodenbelag um die Werkzeugmaschine",
@@ -49,7 +49,7 @@ func GetCobotHazardPatterns() []HazardPattern {
RequiredComponentTags: []string{"afterrun_risk", "moving_part"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M054"},
SuggestedMeasureIDs: []string{"M002", "M494", "M487", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E08"},
Priority: 90,
RequiresExpertCalculation: true,
@@ -0,0 +1,198 @@
package iace
// GetCRAPatterns returns hazard patterns for the EU Cyber Resilience Act
// compliance track. They fire when a project's components carry digital
// elements (software, network, AI) and therefore fall under the CRA.
//
// The CRA itself (Verordnung (EU) 2024/2847) became force-of-law on
// 11 December 2024 and applies to products with digital elements placed
// on the EU market from 11 December 2027. The harmonised standard for
// machinery is DIN EN 40000-1-2 (Entwurf November 2025).
//
// Identifiers only — no normative text is reproduced.
//
// HP range: HP1910-HP1918.
func GetCRAPatterns() []HazardPattern {
return []HazardPattern{
{
ID: "HP1910", NameDE: "Fehlende Software-Stueckliste (SBOM) fuer die Maschine", NameEN: "Missing Software Bill of Materials (SBOM)",
RequiredComponentTags: []string{"has_software"},
GeneratedHazardCats: []string{"cyber_resilience"},
SuggestedMeasureIDs: []string{"M540"},
Priority: 70,
ApplicableLifecycles: []string{"normal_operation", "maintenance"},
ScenarioDE: "Die Maschine enthaelt Software-Komponenten (PLC-Firmware, HMI, Roboter-Controller). Wird keine maschinenlesbare Komponentenliste mitgeliefert, kann der Betreiber im Falle einer veroeffentlichten Schwachstelle (CVE) nicht feststellen, ob seine Maschine betroffen ist.",
TriggerDE: "Veroeffentlichung einer Sicherheitsluecke in einer mitverwendeten Bibliothek (z.B. log4j, OpenSSL); Audit-Anfrage des Betreibers; Schwachstellenmeldung durch Forscher.",
HarmDE: "Cybersecurity-Folgeschaden: nicht behebbare Schwachstelle, Maschinen-Stillstand bei Behoerden-Audit, Reputations- und Haftungsrisiko.",
AffectedDE: "Betreiber, IT-/OT-Verantwortliche, Anlagenbauer",
ZoneDE: "Maschinendokumentation, Software-Auslieferung",
ISO12100Section: "6.3.5.7",
ClarificationQuestionsDE: []string{
"Wird eine SBOM (SPDX oder CycloneDX) mit jeder Maschine bzw. jedem Software-Release ausgeliefert?",
"Sind alle Open-Source-Bibliotheken in der SBOM mit Version und Lizenz inventarisiert?",
"Wie wird die SBOM bei einem Software-Update aktualisiert?",
},
DefaultSeverity: 3, DefaultExposure: 4, DefaultAvoidability: 1,
},
{
ID: "HP1911", NameDE: "Updates ohne kryptographische Signatur einspielbar", NameEN: "Unsigned firmware/software updates accepted",
RequiredComponentTags: []string{"has_software", "networked"},
GeneratedHazardCats: []string{"cyber_resilience"},
SuggestedMeasureIDs: []string{"M541", "M547"},
Priority: 90,
ApplicableLifecycles: []string{"maintenance", "fault_clearing"},
ScenarioDE: "Software- oder Firmware-Updates der Maschine koennen ohne Pruefung einer Hersteller-Signatur eingespielt werden. Ein Angreifer mit physischem oder Netzwerk-Zugriff kann manipulierte Updates aufspielen und die Maschinensicherheit kompromittieren.",
TriggerDE: "Update-USB an HMI angesteckt; ungesicherter Update-Endpoint im Netzwerk erreichbar; Service-Techniker bringt nicht authentifiziertes Update mit.",
HarmDE: "Kompromittierung der Steuerung mit Folgewirkung auf Maschinensicherheit (z.B. Deaktivieren von Sicherheitsfunktionen, Manipulation von Achs-Limits, Daten-Diebstahl).",
AffectedDE: "Bedienpersonal, Wartungspersonal, Betreiber",
ZoneDE: "Steuerung, HMI, Update-Schnittstellen",
ISO12100Section: "6.3.5.7",
ClarificationQuestionsDE: []string{
"Werden alle Software-/Firmware-Updates kryptographisch signiert ausgeliefert?",
"Prueft die Maschine die Signatur vor dem Anwenden eines Updates und verweigert unsignierte Pakete?",
"Ist ein Rollback auf nachweislich verwundbare Versionen durch einen Versions-Counter blockiert?",
},
DefaultSeverity: 4, DefaultExposure: 2, DefaultAvoidability: 1,
},
{
ID: "HP1912", NameDE: "Werkseitige Default-Passwoerter bleiben im Betrieb aktiv", NameEN: "Factory-default credentials remain active in production",
RequiredComponentTags: []string{"has_software", "user_interface"},
GeneratedHazardCats: []string{"cyber_resilience"},
SuggestedMeasureIDs: []string{"M542"},
Priority: 85,
ApplicableLifecycles: []string{"setup", "normal_operation"},
ScenarioDE: "Die Maschine wird mit werkseitigen Standard-Passwoertern (z.B. admin/admin, 1234, Werks-Default-PIN) ausgeliefert. Werden diese nicht beim ersten Hochfahren zwangsweise geaendert, sind sie ueblicherweise oeffentlich bekannt.",
TriggerDE: "Inbetriebnahme ohne Initialisierungs-Wizard; Werks-Reset durch unbefugte Person; Werks-Default in Online-Listen veroeffentlicht.",
HarmDE: "Unautorisierter Zugriff auf Bediener-/Wartungsebene, Manipulation der Maschinen-Konfiguration, Abgriff von Produktions-/Personendaten.",
AffectedDE: "Betreiber, IT-/OT-Verantwortliche",
ZoneDE: "HMI, Wartungs-Login, Remote-Service-Zugang",
ISO12100Section: "6.3.5.7",
ClarificationQuestionsDE: []string{
"Erzwingt die Maschine beim ersten Hochfahren die Aenderung aller werkseitigen Default-Passwoerter?",
"Sind die werkseitigen Credentials in der oeffentlichen Dokumentation NICHT enthalten?",
"Wie wird ein Werks-Reset gegen unbefugten Zugriff geschuetzt?",
},
DefaultSeverity: 3, DefaultExposure: 3, DefaultAvoidability: 1,
},
{
ID: "HP1913", NameDE: "Keine veroeffentlichte CVD-Policy (Vulnerability Disclosure)", NameEN: "No coordinated vulnerability disclosure policy",
RequiredComponentTags: []string{"has_software"},
GeneratedHazardCats: []string{"cyber_resilience"},
SuggestedMeasureIDs: []string{"M543"},
Priority: 70,
ApplicableLifecycles: []string{"normal_operation"},
ScenarioDE: "Der Anlagenbauer hat keine veroeffentlichte Kontaktstelle fuer die Meldung von Schwachstellen. Externe Sicherheitsforscher oder Kunden finden keinen Weg, Schwachstellen vertraulich zu melden — diese werden im Zweifel ungefiltert oeffentlich (Full-Disclosure).",
TriggerDE: "Sicherheitsforscher findet Schwachstelle, kein PSIRT-Kontakt; Kunde will Schwachstelle melden, scheitert an Erreichbarkeit.",
HarmDE: "Veroeffentlichung von Schwachstellen ohne Patch verfuegbar (0-day in der Wildbahn), Reputationsverlust, Behoerden-Sanktion nach CRA.",
AffectedDE: "Hersteller, Betreiber, Sicherheitsforscher",
ZoneDE: "Hersteller-Website, Dokumentation",
ISO12100Section: "6.3.5.7",
ClarificationQuestionsDE: []string{
"Existiert eine veroeffentlichte CVD-Policy mit eindeutiger Meldekontaktstelle?",
"Ist eine security.txt nach RFC 9116 auf der Hersteller-Website hinterlegt?",
"Sind Antwort- und Behebungsfristen in der Policy dokumentiert?",
},
DefaultSeverity: 2, DefaultExposure: 3, DefaultAvoidability: 1,
},
{
ID: "HP1914", NameDE: "Keine dokumentierte Patch-SLA fuer Sicherheits-Updates", NameEN: "No documented security patch SLA",
RequiredComponentTags: []string{"has_software"},
GeneratedHazardCats: []string{"cyber_resilience"},
SuggestedMeasureIDs: []string{"M544"},
Priority: 70,
ApplicableLifecycles: []string{"normal_operation"},
ScenarioDE: "Der Hersteller hat keine verbindlichen Reaktionszeiten fuer Sicherheits-Patches definiert. Im Schwachstellenfall ist offen, wie schnell ein Patch geliefert wird — Betreiber kann sein Risiko nicht steuern.",
TriggerDE: "CVE wird veroeffentlicht; Betreiber fragt Patch-Termin an; Hersteller hat keine SLA und kann keinen Termin nennen.",
HarmDE: "Lange Exposition gegenueber bekannter Schwachstelle; Verstoss gegen CRA-Pflichten zur Schwachstellenbehandlung.",
AffectedDE: "Hersteller, Betreiber, IT-/OT-Verantwortliche",
ZoneDE: "Vertrags-/Dokumentationsebene",
ISO12100Section: "6.3.5.7",
ClarificationQuestionsDE: []string{
"Existiert eine dokumentierte Patch-SLA mit CVSS-abhaengigen Reaktionszeiten?",
"Welcher Sicherheits-Supportzeitraum wird zugesagt (CRA-Mindest: erwartete Nutzungsdauer, typ. 5 Jahre)?",
"Wird die SLA mit der Maschine ausgeliefert oder vertraglich vereinbart?",
},
DefaultSeverity: 2, DefaultExposure: 3, DefaultAvoidability: 1,
},
{
ID: "HP1915", NameDE: "Fehlende Cybersecurity-Anwender-Dokumentation (Hardening-Guide)", NameEN: "Missing user-facing cybersecurity hardening guide",
RequiredComponentTags: []string{"has_software", "networked"},
GeneratedHazardCats: []string{"cyber_resilience"},
SuggestedMeasureIDs: []string{"M545"},
Priority: 65,
ApplicableLifecycles: []string{"setup", "normal_operation"},
ScenarioDE: "Der Betreiber erhaelt keine Anleitung zur sicheren Inbetriebnahme und Konfiguration der Maschine im Werks-Netzwerk. Konfigurations-Fehler (offene Ports, ungehaerteter Default-Zustand) bleiben unentdeckt.",
TriggerDE: "Inbetriebnahme der Maschine im IT/OT-Netzwerk; Sicherheits-Audit beim Betreiber.",
HarmDE: "Angreifbares Konfigurations-Profil, das vom Standardzustand der Maschine vererbt wurde; Verstoss gegen CRA Anhang II (Anwender-Information).",
AffectedDE: "Betreiber, IT-/OT-Verantwortliche",
ZoneDE: "Maschinen-Konfiguration, Netzwerk-Anbindung",
ISO12100Section: "6.3.5.7",
ClarificationQuestionsDE: []string{
"Liegt der Maschine ein Cybersecurity-Hardening-Guide bei (Netzwerk-Segmentierung, deaktivierbare Dienste, sichere Konfiguration)?",
"Wird der Guide bei Updates gepflegt?",
"Enthaelt die Betriebsanleitung ein Kapitel 'Sichere Inbetriebnahme'?",
},
DefaultSeverity: 2, DefaultExposure: 3, DefaultAvoidability: 1,
},
{
ID: "HP1916", NameDE: "Kein definierter Meldeprozess fuer Sicherheitsvorfaelle an ENISA/CSIRT", NameEN: "No incident notification process to ENISA / national CSIRT",
RequiredComponentTags: []string{"has_software"},
GeneratedHazardCats: []string{"cyber_resilience"},
SuggestedMeasureIDs: []string{"M546"},
Priority: 75,
ApplicableLifecycles: []string{"normal_operation"},
ScenarioDE: "Der Hersteller hat keinen internen Prozess, um aktiv ausgenutzte Schwachstellen oder schwere Sicherheitsvorfaelle innerhalb der CRA-Meldefristen (24 h / 72 h / 14 Tage) an ENISA bzw. die zustaendige nationale Stelle zu melden.",
TriggerDE: "Schwachstelle wird in der Wildbahn ausgenutzt; Sicherheitsvorfall bei einem Kunden gemeldet.",
HarmDE: "Versaeumte Meldefrist nach CRA Art. 14 — Bussgeldrisiko, Verlust der CE-Konformitaetsvermutung.",
AffectedDE: "Hersteller, Compliance/PSIRT-Team",
ZoneDE: "Hersteller-Prozesse",
ISO12100Section: "6.3.5.7",
ClarificationQuestionsDE: []string{
"Existiert ein dokumentierter Incident-Notification-Prozess mit Eskalationsmatrix?",
"Sind die CRA-Meldefristen (Erstmeldung 24 h, Update 72 h, Abschluss 14 Tage) als interne SLA umgesetzt?",
"Ist die zustaendige nationale CSIRT-Stelle ermittelt und der Meldekanal getestet?",
},
DefaultSeverity: 3, DefaultExposure: 2, DefaultAvoidability: 1,
},
{
ID: "HP1917", NameDE: "Keine Sicherheitsbewertung vor Inverkehrbringen (Pen-Test / Static Analysis)", NameEN: "No security assessment prior to placing on market",
RequiredComponentTags: []string{"has_software"},
GeneratedHazardCats: []string{"cyber_resilience"},
SuggestedMeasureIDs: []string{"M548"},
Priority: 70,
ApplicableLifecycles: []string{"setup"},
ScenarioDE: "Vor dem erstmaligen Inverkehrbringen wurde keine dokumentierte Sicherheitsbewertung (mind. statische Code-Analyse + Schwachstellen-Scan) durchgefuehrt. Bekannte Schwachstellen-Klassen gelangen unbemerkt in die Auslieferung.",
TriggerDE: "Inverkehrbringen einer neuen Maschinen-Variante oder eines Major-Updates; behoerdlicher Konformitaetsnachweis.",
HarmDE: "Auslieferung von Maschinen mit bekannten Schwachstellen; Verstoss gegen CRA Anhang I (Cybersecurity-Anforderungen).",
AffectedDE: "Hersteller, Betreiber",
ZoneDE: "Entwicklungs-/Auslieferungsprozess",
ISO12100Section: "6.3.5.7",
ClarificationQuestionsDE: []string{
"Wird vor jedem Major-Release eine dokumentierte Sicherheitsbewertung durchgefuehrt?",
"Sind statische Analyse und Dependency-Scan in den CI-Pipeline integriert?",
"Werden bei erhoehtem Risiko Penetrationstests durch unabhaengige Stellen durchgefuehrt?",
},
DefaultSeverity: 3, DefaultExposure: 2, DefaultAvoidability: 1,
},
{
ID: "HP1918", NameDE: "AI-Komponente ohne Cybersecurity-Risikobetrachtung", NameEN: "AI component without cybersecurity risk assessment",
RequiredComponentTags: []string{"has_ai"},
GeneratedHazardCats: []string{"cyber_resilience"},
SuggestedMeasureIDs: []string{"M548", "M545"},
Priority: 75,
ApplicableLifecycles: []string{"normal_operation"},
ScenarioDE: "Die Maschine enthaelt KI-Komponenten (z.B. Bildverarbeitung, anomalie-basierte Wartung). Cybersecurity-spezifische KI-Risiken (Model Inversion, Adversarial Examples, Poisoning des Trainingsdatensatzes) wurden nicht geprueft.",
TriggerDE: "Adversarial Input wird ueber Sensor-/HMI-Schnittstelle eingespielt; manipuliertes Update-Modell wird ausgespielt.",
HarmDE: "Fehlsteuerung der Maschine durch manipuliertes KI-Modell; mittelbare Sicherheitsfolgen, Datenleckage aus Modell.",
AffectedDE: "Bedienpersonal, Betreiber",
ZoneDE: "KI-Modul, Modell-Auslieferungspfad",
ISO12100Section: "6.3.5.7",
ClarificationQuestionsDE: []string{
"Sind KI-spezifische Cybersecurity-Risiken (Adversarial Inputs, Poisoning, Model Inversion) im Risikobeurteilungsprozess betrachtet?",
"Wie wird die Integritaet ausgelieferter KI-Modelle sichergestellt (Signatur, Hash, sichere Auslieferungs-Kanal)?",
"Existiert ein Monitoring auf ungewoehnliche Eingangsmuster im Betrieb?",
},
DefaultSeverity: 3, DefaultExposure: 2, DefaultAvoidability: 1,
},
}
}
@@ -23,7 +23,7 @@ func builtinCyberPatterns() []HazardPattern {
RequiredComponentTags: []string{"networked", "it_component"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"communication_failure"},
SuggestedMeasureIDs: []string{"M114", "M115"},
SuggestedMeasureIDs: []string{"M113", "M106", "M119", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E17"},
Priority: 80,
ScenarioDE: "Netzwerkverbindung zwischen Steuerungskomponenten faellt aus; Maschine verliert Synchronisation.",
@@ -68,7 +68,7 @@ func builtinCyberPatterns() []HazardPattern {
RequiredComponentTags: []string{"it_component", "has_software"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"unauthorized_access", "firmware_corruption"},
SuggestedMeasureIDs: []string{"M116", "M118"},
SuggestedMeasureIDs: []string{"M186", "M188", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E18", "E19"},
Priority: 75,
ScenarioDE: "Kompromittierte Komponente oder Bibliothek wird in der Lieferkette eingeschleust.",
@@ -13,7 +13,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"has_software", "programmable"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"software_fault"},
SuggestedMeasureIDs: []string{"M101", "M102", "M103"},
SuggestedMeasureIDs: []string{"M111", "M107", "M106", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E14"},
Priority: 85,
ScenarioDE: "Dynamischer Speicher der Steuerung laeuft voll; Steuerungsprogramm verhaelt sich undefiniert oder stuerzt ab.",
@@ -28,7 +28,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"has_software", "programmable"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"software_fault"},
SuggestedMeasureIDs: []string{"M101", "M102"},
SuggestedMeasureIDs: []string{"M040", "M044", "M106", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E14"},
Priority: 85,
ScenarioDE: "Zwei Tasks greifen gleichzeitig auf gemeinsame Ressource zu; Zustandsinformation wird inkonsistent.",
@@ -43,7 +43,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"has_software", "programmable"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"software_fault"},
SuggestedMeasureIDs: []string{"M101", "M103"},
SuggestedMeasureIDs: []string{"M107", "M214", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E14"},
Priority: 80,
ScenarioDE: "Regelkreis teilt durch einen Sensorwert, der unerwartet Null wird; Stellgroesse laeuft ins Unendliche.",
@@ -58,7 +58,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"has_software"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"software_fault"},
SuggestedMeasureIDs: []string{"M101", "M102"},
SuggestedMeasureIDs: []string{"M111", "M214", "M107", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E14"},
Priority: 75,
ScenarioDE: "Integer-Ueberlauf in der Steuerung wandelt grossen Positivwert in negativen Wert und kehrt Bewegungsrichtung um.",
@@ -88,7 +88,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"has_software", "programmable"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"software_fault"},
SuggestedMeasureIDs: []string{"M101", "M102", "M103"},
SuggestedMeasureIDs: []string{"M106", "M107", "M044", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E14"},
Priority: 80,
ScenarioDE: "Zwei oder mehr Tasks der Steuerung blockieren sich gegenseitig; Steuerung reagiert nicht mehr.",
@@ -148,7 +148,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"sensor_part", "has_software"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"software_fault"},
SuggestedMeasureIDs: []string{"M101", "M102"},
SuggestedMeasureIDs: []string{"M214", "M213", "M119", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E14"},
Priority: 75,
ScenarioDE: "Sensor liefert Wert ausserhalb seines Messbereichs; Steuerungssoftware interpretiert ihn falsch.",
@@ -178,7 +178,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"has_software", "networked"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"software_fault"},
SuggestedMeasureIDs: []string{"M101", "M114"},
SuggestedMeasureIDs: []string{"M040", "M106", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E14"},
Priority: 70,
ScenarioDE: "Fehlerhafte Zeitstempel fuehren dazu, dass Prozessschritte in falscher Reihenfolge ausgefuehrt werden.",
@@ -193,7 +193,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"has_software"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"software_fault"},
SuggestedMeasureIDs: []string{"M101", "M102"},
SuggestedMeasureIDs: []string{"M111", "M107", "M188", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E14", "E16"},
Priority: 85,
ScenarioDE: "Pufferueberlauf in der Steuerungssoftware ueberschreibt angrenzende Speicherbereiche und fuehrt zu undefiniertem Verhalten.",
@@ -208,7 +208,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"has_software", "programmable"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"software_fault"},
SuggestedMeasureIDs: []string{"M101", "M103"},
SuggestedMeasureIDs: []string{"M107", "M105", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E14"},
Priority: 80,
ScenarioDE: "Nicht abgefangene Programmausnahme fuehrt zum Absturz der Steuerung ohne geordnete Abschaltung.",
@@ -379,7 +379,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"it_component", "has_software"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"unauthorized_access", "firmware_corruption"},
SuggestedMeasureIDs: []string{"M116", "M118"},
SuggestedMeasureIDs: []string{"M186", "M188", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E18", "E19"},
Priority: 75,
ScenarioDE: "Kompromittierte Hardware oder Software wird ueber die Lieferkette in die Anlage eingebracht.",
@@ -424,7 +424,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"has_software"},
RequiredEnergyTags: []string{"cyber"},
GeneratedHazardCats: []string{"unauthorized_access"},
SuggestedMeasureIDs: []string{"M116", "M138"},
SuggestedMeasureIDs: []string{"M188", "M186", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E18", "E19"},
Priority: 80,
ScenarioDE: "Steuerungssoftware oder Betriebssystem wird nicht gepatcht; bekannte CVEs sind ausnutzbar.",
@@ -439,7 +439,7 @@ func GetCyberExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"has_software", "it_component"},
RequiredEnergyTags: []string{"cyber"},
GeneratedHazardCats: []string{"firmware_corruption"},
SuggestedMeasureIDs: []string{"M116", "M142"},
SuggestedMeasureIDs: []string{"M186", "M188", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E16"},
Priority: 70,
ScenarioDE: "Angreifer manipuliert Backup-Daten; bei Wiederherstellung wird kompromittierte Konfiguration eingespielt.",
@@ -27,7 +27,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
RequiredComponentTags: []string{"has_ai"},
RequiredEnergyTags: []string{"ai_model"},
GeneratedHazardCats: []string{"unintended_bias", "false_classification"},
SuggestedMeasureIDs: []string{"M101"},
SuggestedMeasureIDs: []string{"M133", "M227", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E15"},
Priority: 80,
ScenarioDE: "Einseitige Trainingsdaten fuehren dazu, dass das KI-Modell bestimmte Varianten systematisch falsch klassifiziert.",
@@ -42,7 +42,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
RequiredComponentTags: []string{"has_ai", "sensor_part"},
RequiredEnergyTags: []string{"ai_model", "cyber"},
GeneratedHazardCats: []string{"data_poisoning", "sensor_spoofing"},
SuggestedMeasureIDs: []string{"M101", "M116"},
SuggestedMeasureIDs: []string{"M133", "M214", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E15", "E16"},
Priority: 85,
ScenarioDE: "Gezielt veraenderte Eingabedaten (Adversarial Patches) taeuschen das Bilderkennungssystem und erzwingen Fehlklassifikation.",
@@ -57,7 +57,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
RequiredComponentTags: []string{"has_ai"},
RequiredEnergyTags: []string{"ai_model"},
GeneratedHazardCats: []string{"model_drift"},
SuggestedMeasureIDs: []string{"M103"},
SuggestedMeasureIDs: []string{"M133", "M227", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E15"},
Priority: 75,
ScenarioDE: "KI-Modell verliert durch veraenderte Prozessbedingungen ueber Wochen schleichend an Genauigkeit.",
@@ -72,7 +72,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
RequiredComponentTags: []string{"has_ai", "programmable"},
RequiredEnergyTags: []string{"ai_model"},
GeneratedHazardCats: []string{"software_fault", "safety_function_failure"},
SuggestedMeasureIDs: []string{"M101", "M104"},
SuggestedMeasureIDs: []string{"M044", "M227", "M105", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E07", "E15"},
Priority: 95,
RequiresExpertCalculation: true,
@@ -89,7 +89,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
RequiredComponentTags: []string{"has_ai", "user_interface"},
RequiredEnergyTags: []string{"ai_model"},
GeneratedHazardCats: []string{"false_classification"},
SuggestedMeasureIDs: []string{"M101"},
SuggestedMeasureIDs: []string{"M133", "M204", "M227", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E15"},
Priority: 70,
ScenarioDE: "KI-System gibt Empfehlung ohne Begruendung; Bediener folgt blindlings einer fehlerhaften Empfehlung.",
@@ -104,7 +104,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
RequiredComponentTags: []string{"has_ai"},
RequiredEnergyTags: []string{"ai_model"},
GeneratedHazardCats: []string{"false_classification"},
SuggestedMeasureIDs: []string{"M101", "M102"},
SuggestedMeasureIDs: []string{"M133", "M227", "M214", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E15"},
Priority: 80,
ScenarioDE: "KI trifft bei Eingabedaten nahe der Entscheidungsgrenze unzuverlaessige Entscheidungen mit schwankender Konfidenz.",
@@ -119,7 +119,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
RequiredComponentTags: []string{"has_ai", "programmable"},
RequiredEnergyTags: []string{"ai_model"},
GeneratedHazardCats: []string{"software_fault"},
SuggestedMeasureIDs: []string{"M101", "M102", "M103"},
SuggestedMeasureIDs: []string{"M133", "M227", "M105", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E15"},
Priority: 85,
ScenarioDE: "RL-Agent entdeckt unbeabsichtigte Strategie zur Reward-Maximierung, die gefaehrliches Verhalten einschliesst.",
@@ -134,7 +134,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
RequiredComponentTags: []string{"has_ai"},
RequiredEnergyTags: []string{"ai_model"},
GeneratedHazardCats: []string{"unintended_bias"},
SuggestedMeasureIDs: []string{"M101"},
SuggestedMeasureIDs: []string{"M186", "M187", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E15"},
Priority: 70,
ScenarioDE: "KI-System verarbeitet Kamerabilder mit erkennbaren Personen ohne Einwilligung oder Rechtsgrundlage.",
@@ -149,7 +149,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
RequiredComponentTags: []string{"has_ai", "safety_device"},
RequiredEnergyTags: []string{"ai_model"},
GeneratedHazardCats: []string{"safety_function_failure"},
SuggestedMeasureIDs: []string{"M101", "M104"},
SuggestedMeasureIDs: []string{"M105", "M227", "M044", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E07", "E15"},
Priority: 95,
RequiresExpertCalculation: true,
@@ -166,7 +166,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
RequiredComponentTags: []string{"has_ai"},
RequiredEnergyTags: []string{"ai_model"},
GeneratedHazardCats: []string{"model_drift"},
SuggestedMeasureIDs: []string{"M103"},
SuggestedMeasureIDs: []string{"M133", "M227", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E15"},
Priority: 75,
ScenarioDE: "Grundlegende Aenderung des Produktionsprozesses macht das KI-Modell ungueltig, da es auf alten Zusammenhaengen basiert.",
@@ -181,7 +181,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
RequiredComponentTags: []string{"has_ai", "user_interface"},
RequiredEnergyTags: []string{"ai_model"},
GeneratedHazardCats: []string{"false_classification"},
SuggestedMeasureIDs: []string{"M101", "M102"},
SuggestedMeasureIDs: []string{"M133", "M214", "M227", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E15"},
Priority: 80,
ScenarioDE: "KI-System meldet einen Zustand mit hoher Konfidenz, der in Wirklichkeit nicht vorliegt (Halluzination).",
@@ -196,7 +196,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
RequiredComponentTags: []string{"has_ai", "networked"},
RequiredEnergyTags: []string{"ai_model"},
GeneratedHazardCats: []string{"communication_failure", "safety_function_failure"},
SuggestedMeasureIDs: []string{"M101", "M104", "M115"},
SuggestedMeasureIDs: []string{"M109", "M113", "M106", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E15", "E17"},
Priority: 90,
ScenarioDE: "Sicherheitsrelevante KI-Funktion benoetigt Cloud-Verbindung; bei Netzwerkausfall ist die Sicherheit nicht gewaehrleistet.",
@@ -211,7 +211,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
RequiredComponentTags: []string{"has_ai"},
RequiredEnergyTags: []string{"ai_model"},
GeneratedHazardCats: []string{"false_classification"},
SuggestedMeasureIDs: []string{"M101", "M102"},
SuggestedMeasureIDs: []string{"M044", "M119", "M133", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E15"},
Priority: 75,
ScenarioDE: "KI-System ist alleiniger Qualitaetsgate ohne Backup-Pruefung; bei KI-Ausfall passieren alle Teile unkontrolliert.",
@@ -226,7 +226,7 @@ func GetCyberExtendedPatterns2() []HazardPattern {
RequiredComponentTags: []string{"has_ai"},
RequiredEnergyTags: []string{"ai_model"},
GeneratedHazardCats: []string{"model_drift"},
SuggestedMeasureIDs: []string{"M103"},
SuggestedMeasureIDs: []string{"M133", "M227", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E15", "E21"},
Priority: 80,
ScenarioDE: "KI-basierte Wartungsvorhersage unterschaetzt Verschleiss und empfiehlt Wartung zu spaet.",
@@ -13,7 +13,7 @@ func GetCyberExtendedPatterns3() []HazardPattern {
RequiredComponentTags: []string{"networked", "it_component"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"communication_failure"},
SuggestedMeasureIDs: []string{"M114", "M115"},
SuggestedMeasureIDs: []string{"M113", "M106", "M119", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E17"},
Priority: 85,
ScenarioDE: "Kompletter Feldbusausfall trennt SPS von allen Antrieben und Sensoren; Maschine verliert Kontrolle.",
@@ -28,7 +28,7 @@ func GetCyberExtendedPatterns3() []HazardPattern {
RequiredComponentTags: []string{"networked"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"communication_failure"},
SuggestedMeasureIDs: []string{"M114", "M115"},
SuggestedMeasureIDs: []string{"M113", "M106", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E17"},
Priority: 80,
ScenarioDE: "Einzelne Telegramme im Echtzeit-Bussystem gehen verloren; Antrieb erhaelt keinen neuen Sollwert und behlt den alten.",
@@ -58,7 +58,7 @@ func GetCyberExtendedPatterns3() []HazardPattern {
RequiredComponentTags: []string{"networked", "it_component"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"communication_failure"},
SuggestedMeasureIDs: []string{"M115"},
SuggestedMeasureIDs: []string{"M113", "M106", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E17"},
Priority: 70,
ScenarioDE: "Gateway zwischen Feldbus und Leitebene faellt aus; SCADA verliert Sicht auf Prozess, Alarme kommen nicht durch.",
@@ -88,7 +88,7 @@ func GetCyberExtendedPatterns3() []HazardPattern {
RequiredComponentTags: []string{"networked"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"communication_failure"},
SuggestedMeasureIDs: []string{"M114"},
SuggestedMeasureIDs: []string{"M113", "M186", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E17"},
Priority: 65,
ScenarioDE: "Zwei Geraete im Maschinennetzwerk haben dieselbe IP-Adresse; Kommunikation ist unzuverlaessig.",
@@ -103,7 +103,7 @@ func GetCyberExtendedPatterns3() []HazardPattern {
RequiredComponentTags: []string{"networked", "has_software"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"communication_failure", "software_fault"},
SuggestedMeasureIDs: []string{"M114"},
SuggestedMeasureIDs: []string{"M040", "M106", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E14", "E17"},
Priority: 75,
ScenarioDE: "PTP/NTP-Synchronisation im Netzwerk geht verloren; zeitgesteuerte Aktionen werden asynchron ausgefuehrt.",
@@ -118,7 +118,7 @@ func GetCyberExtendedPatterns3() []HazardPattern {
RequiredComponentTags: []string{"networked", "safety_device"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"communication_failure", "safety_function_failure"},
SuggestedMeasureIDs: []string{"M114", "M115"},
SuggestedMeasureIDs: []string{"M113", "M106", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E07", "E17"},
Priority: 85,
ScenarioDE: "Netzwerk-Ueberlastung verzoegert sicherheitsrelevante Telegramme ueber die maximale Reaktionszeit hinaus.",
@@ -133,7 +133,7 @@ func GetCyberExtendedPatterns3() []HazardPattern {
RequiredComponentTags: []string{"networked", "it_component"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"communication_failure"},
SuggestedMeasureIDs: []string{"M114"},
SuggestedMeasureIDs: []string{"M113", "M186", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E17"},
Priority: 60,
ScenarioDE: "Falsche Routing-Konfiguration leitet Steuerbefehle an falsches Teilnetz oder laesst sie ins Leere laufen.",
@@ -148,7 +148,7 @@ func GetCyberExtendedPatterns3() []HazardPattern {
RequiredComponentTags: []string{"networked", "it_component"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"communication_failure"},
SuggestedMeasureIDs: []string{"M115"},
SuggestedMeasureIDs: []string{"M113", "M119", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E17"},
Priority: 75,
ScenarioDE: "Managed Switch in Ring-Topologie faellt aus; Ring-Redundanz uebernimmt, aber Umschaltzeit stoert Echtzeit-Kommunikation.",
@@ -212,7 +212,7 @@ func GetCyberExtendedPatterns3() []HazardPattern {
RequiredComponentTags: []string{"user_interface", "has_software"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"software_fault"},
SuggestedMeasureIDs: []string{"M101", "M103"},
SuggestedMeasureIDs: []string{"M205", "M204", "M214", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E14"},
Priority: 75,
ScenarioDE: "HMI zeigt falschen Messwert (z. B. falsche Zuordnung von Sensor zu Anzeige) und Bediener reagiert falsch.",
@@ -8,7 +8,7 @@ func builtinElectricalPatterns() []HazardPattern {
RequiredComponentTags: []string{"high_voltage"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M061", "M062", "M063", "M121"},
SuggestedMeasureIDs: []string{"M481", "M482", "M088", "M265", "M522"},
SuggestedEvidenceIDs: []string{"E01", "E04", "E10"},
Priority: 95,
ScenarioDE: "Person beruehrt spannungsfuehrende Teile durch defekte Isolation oder ungesicherten Zugang.",
@@ -16,6 +16,7 @@ func builtinElectricalPatterns() []HazardPattern {
HarmDE: "Stromschlag, Herzkammerflimmern, Verbrennungen, Todesfolge bei Hochspannung.",
AffectedDE: "Wartungspersonal, Elektrofachkraefte, Bedienpersonal",
ZoneDE: "Schaltschrank, Klemmenkasten, Motoranschluss, Frequenzumrichter",
ISO12100Section: "6.2.9",
DefaultSeverity: 5, DefaultExposure: 2,
},
{
@@ -23,7 +24,7 @@ func builtinElectricalPatterns() []HazardPattern {
RequiredComponentTags: []string{"electrical_part"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M061", "M064", "M121"},
SuggestedMeasureIDs: []string{"M482", "M481", "M089", "M508", "M522"},
SuggestedEvidenceIDs: []string{"E01", "E04", "E10"},
Priority: 85,
ScenarioDE: "Elektrische Bauteile (Motoren, Netzteile, Schaltgeraete) stellen bei Defekt oder offenem Gehaeuse eine Beruehrungsgefahr dar.",
@@ -31,6 +32,7 @@ func builtinElectricalPatterns() []HazardPattern {
HarmDE: "Stromschlag, lokale Verbrennungen, Muskelverkrampfung.",
AffectedDE: "Bedienpersonal, Wartungspersonal",
ZoneDE: "Motorgehaeuse, Anschlussklemmen, Netzteile, Kabelkanaele",
ISO12100Section: "6.2.9",
DefaultSeverity: 4, DefaultExposure: 2,
},
{
@@ -38,7 +40,7 @@ func builtinElectricalPatterns() []HazardPattern {
RequiredComponentTags: []string{"stored_energy"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M062", "M063", "M121", "M123"},
SuggestedMeasureIDs: []string{"M046", "M047", "M138", "M522", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E10"},
Priority: 85,
ScenarioDE: "Kondensatoren, Batterien oder Frequenzumrichter halten nach Abschalten gefaehrliche Restspannung.",
@@ -46,6 +48,7 @@ func builtinElectricalPatterns() []HazardPattern {
HarmDE: "Stromschlag, Verbrennungen durch Lichtbogen, Explosion bei Lithium-Akkus.",
AffectedDE: "Elektrofachkraefte, Wartungspersonal",
ZoneDE: "Zwischenkreiskondensatoren, Batteriefaecher, USV-Anlagen",
ISO12100Section: "6.2.10",
DefaultSeverity: 4, DefaultExposure: 2,
},
{
@@ -68,7 +71,7 @@ func builtinElectricalPatterns() []HazardPattern {
RequiredComponentTags: []string{"electrical_part"},
RequiredEnergyTags: []string{"electromagnetic"},
GeneratedHazardCats: []string{"emc_hazard"},
SuggestedMeasureIDs: []string{"M066", "M121"},
SuggestedMeasureIDs: []string{"M478", "M479", "M044", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E10"},
Priority: 70,
ScenarioDE: "Elektromagnetische Stoerungen beeinflussen Steuerungssignale und loesen unerwartete Maschinenbewegungen aus.",
@@ -76,6 +79,7 @@ func builtinElectricalPatterns() []HazardPattern {
HarmDE: "Fehlausloesung von Aktoren, unerwartete Bewegung, Ausfall von Sicherheitsfunktionen.",
AffectedDE: "Bedienpersonal, Personen im Maschinenbereich",
ZoneDE: "Gesamte Maschinenumgebung, insbesondere Signalleitungen und Sensorik",
ISO12100Section: "6.3.3",
DefaultSeverity: 3, DefaultExposure: 2,
},
}
@@ -264,7 +264,7 @@ func GetElevatorPatterns() []HazardPattern {
RequiredComponentTags: []string{"elevator_car", "moving_part"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M054", "M106"},
SuggestedMeasureIDs: []string{"M008", "M001", "M141"},
SuggestedEvidenceIDs: []string{"E01"},
Priority: 75,
ScenarioDE: "Fahrkorb steht nicht buendig mit dem Stockwerksboden. Stufenbildung von mehr als 20mm.",
@@ -363,7 +363,7 @@ func GetElevatorPatterns() []HazardPattern {
MachineTypes: []string{"elevator", "lift", "escalator"},
RequiredComponentTags: []string{"elevator_traction", "noise_source"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"noise_vibration"},
GeneratedHazardCats: []string{"noise_hazard"},
SuggestedMeasureIDs: []string{"M141"},
SuggestedEvidenceIDs: []string{"E20"},
Priority: 55,
@@ -412,7 +412,7 @@ func GetElevatorPatterns() []HazardPattern {
RequiredComponentTags: []string{"elevator_door", "moving_part"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M054", "M106"},
SuggestedMeasureIDs: []string{"M061", "M002", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E08"},
Priority: 78,
ScenarioDE: "Tuerschliessmechanismus uebt zu grosse Kraft aus. Passagiere werden beim Schliessen der Tueren getroffen.",
@@ -8,7 +8,7 @@ func builtinEnvironmentPatterns() []HazardPattern {
ID: "HP023", NameDE: "Laermgefahr", NameEN: "Noise hazard",
RequiredComponentTags: []string{"noise_source"},
RequiredEnergyTags: []string{"acoustic"},
GeneratedHazardCats: []string{"noise_vibration"},
GeneratedHazardCats: []string{"noise_hazard"},
SuggestedMeasureIDs: []string{"M091", "M131"},
SuggestedEvidenceIDs: []string{"E01", "E12"},
Priority: 70,
@@ -17,13 +17,14 @@ func builtinEnvironmentPatterns() []HazardPattern {
HarmDE: "Laermschwerhoerigkeit (BK 2301), Tinnitus, Konzentrationsstoerung.",
AffectedDE: "Bedienpersonal, Personen im Umfeld",
ZoneDE: "Gesamter Arbeitsbereich um die Maschine, insbesondere Auslassseite",
ISO12100Section: "6.3.3.2.1",
DefaultSeverity: 3, DefaultExposure: 4,
},
{
ID: "HP024", NameDE: "Vibrationsgefahr", NameEN: "Vibration hazard",
RequiredComponentTags: []string{"vibration_source"},
RequiredEnergyTags: []string{"vibration"},
GeneratedHazardCats: []string{"noise_vibration"},
GeneratedHazardCats: []string{"vibration_hazard"},
SuggestedMeasureIDs: []string{"M092", "M131"},
SuggestedEvidenceIDs: []string{"E01", "E13"},
Priority: 65,
@@ -32,13 +33,14 @@ func builtinEnvironmentPatterns() []HazardPattern {
HarmDE: "Hand-Arm-Vibrationssyndrom (BK 2104), Durchblutungsstoerung, Gelenkschaeden.",
AffectedDE: "Bedienpersonal, Maschinenfuehrer",
ZoneDE: "Griffe, Bedienelemente, Standfussbereich, Fahrersitz",
ISO12100Section: "6.3.3.2.1",
DefaultSeverity: 3, DefaultExposure: 4,
},
{
ID: "HP025", NameDE: "Laerm durch rotierende Hochgeschwindigkeitsteile", NameEN: "Noise from high-speed rotating parts",
RequiredComponentTags: []string{"rotating_part", "high_speed"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"noise_vibration"},
GeneratedHazardCats: []string{"noise_hazard"},
SuggestedMeasureIDs: []string{"M091", "M092", "M131"},
SuggestedEvidenceIDs: []string{"E01", "E12", "E13"},
Priority: 70,
@@ -47,6 +49,7 @@ func builtinEnvironmentPatterns() []HazardPattern {
HarmDE: "Gehoerschaedigung, Tinnitus, erhoehtes Unfallrisiko durch Konzentrationsverlust.",
AffectedDE: "Bedienpersonal, Personen im Hallenbereich",
ZoneDE: "Umgebung der Spindel/Schleifscheibe, Maschinengehaeuse, offene Bearbeitungszone",
ISO12100Section: "6.3.3.2.1",
DefaultSeverity: 3, DefaultExposure: 4,
},
// Ergonomic
@@ -55,7 +58,7 @@ func builtinEnvironmentPatterns() []HazardPattern {
RequiredComponentTags: []string{"user_interface"},
RequiredEnergyTags: []string{"ergonomic"},
GeneratedHazardCats: []string{"ergonomic"},
SuggestedMeasureIDs: []string{"M126", "M121"},
SuggestedMeasureIDs: []string{"M029", "M030", "M032", "M033", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E24"},
Priority: 50,
ScenarioDE: "Bediener arbeitet in unguenstiger Koerperhaltung (stehend, gebeugt, verdreht) ueber laengere Schichtdauer.",
@@ -85,7 +88,7 @@ func builtinEnvironmentPatterns() []HazardPattern {
RequiredComponentTags: []string{"user_interface", "programmable"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"hmi_error", "mode_confusion"},
SuggestedMeasureIDs: []string{"M126", "M127", "M121"},
SuggestedMeasureIDs: []string{"M204", "M205", "M206", "M141"},
SuggestedEvidenceIDs: []string{"E01", "E14", "E24"},
Priority: 70,
ScenarioDE: "Bediener verwechselt Betriebsarten oder aktiviert falsche Funktion durch unklare HMI-Gestaltung.",
@@ -12,73 +12,73 @@ func GetExtendedHazardPatterns() []HazardPattern {
func getExtendedHazardPatternsA() []HazardPattern {
return []HazardPattern{
{
ID: "HP045", NameDE: "Aktor — elektrisch", NameEN: "Actuator — electrical",
ID: "HP1800", NameDE: "Aktor — elektrisch", NameEN: "Actuator — electrical",
RequiredComponentTags: []string{"actuator_part"},
RequiredEnergyTags: []string{"electrical_energy"},
RequiredLifecycles: []string{"maintenance"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M121"},
SuggestedMeasureIDs: []string{"M141"},
SuggestedEvidenceIDs: []string{"E21"},
Priority: 80,
// Source: R341
},
{
ID: "HP046", NameDE: "Aktor — mechanisch", NameEN: "Actuator — mechanical",
ID: "HP1801", NameDE: "Aktor — mechanisch", NameEN: "Actuator — mechanical",
RequiredComponentTags: []string{"actuator_part"},
RequiredEnergyTags: []string{"kinetic"},
RequiredLifecycles: []string{"operation"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M106"},
SuggestedMeasureIDs: []string{"M141"},
SuggestedEvidenceIDs: []string{"E08"},
Priority: 80,
// Source: R340
},
{
ID: "HP047", NameDE: "KI-Steuerung — Software", NameEN: "Ai Controller — software",
ID: "HP1802", NameDE: "KI-Steuerung — Software", NameEN: "Ai Controller — software",
RequiredComponentTags: []string{"has_ai", "has_software", "programmable"},
RequiredEnergyTags: []string{},
RequiredLifecycles: []string{"operation"},
GeneratedHazardCats: []string{"ai_misclassification"},
SuggestedMeasureIDs: []string{"M103"},
SuggestedMeasureIDs: []string{"M133", "M227", "M141"},
SuggestedEvidenceIDs: []string{"E15"},
Priority: 75,
// Source: R590, R1090
},
{
ID: "HP048", NameDE: "Kabelbaum — elektrisch", NameEN: "Cable Harness — electrical",
ID: "HP1803", NameDE: "Kabelbaum — elektrisch", NameEN: "Cable Harness — electrical",
RequiredComponentTags: []string{"electrical_part"},
RequiredEnergyTags: []string{"electrical_energy"},
RequiredLifecycles: []string{"installation", "operation"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M062"},
SuggestedMeasureIDs: []string{"M481", "M141"},
SuggestedEvidenceIDs: []string{"E20"},
Priority: 80,
// Source: R065, R570, R1070
},
{
ID: "HP049", NameDE: "Kabelsystem — elektrisch", NameEN: "Cable System — electrical",
ID: "HP1804", NameDE: "Kabelsystem — elektrisch", NameEN: "Cable System — electrical",
RequiredComponentTags: []string{"electrical_part"},
RequiredEnergyTags: []string{"electrical_energy"},
RequiredLifecycles: []string{"installation", "operation"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M062"},
SuggestedMeasureIDs: []string{"M481", "M141"},
SuggestedEvidenceIDs: []string{"E20"},
Priority: 80,
// Source: R317, R318
},
{
ID: "HP050", NameDE: "Kamerasystem — elektrisch", NameEN: "Camera System — electrical",
ID: "HP1805", NameDE: "Kamerasystem — elektrisch", NameEN: "Camera System — electrical",
RequiredComponentTags: []string{"sensor_part"},
RequiredEnergyTags: []string{"electrical_energy"},
RequiredLifecycles: []string{"operation"},
GeneratedHazardCats: []string{"ai_misclassification"},
SuggestedMeasureIDs: []string{"M082"},
SuggestedMeasureIDs: []string{"M133", "M227", "M141"},
SuggestedEvidenceIDs: []string{"E20"},
Priority: 75,
// Source: R074, R328
},
{
ID: "HP051", NameDE: "Druckluftleitung — pneumatisch", NameEN: "Compressed Air Line — pneumatic",
ID: "HP1806", NameDE: "Druckluftleitung — pneumatisch", NameEN: "Compressed Air Line — pneumatic",
RequiredComponentTags: []string{"pneumatic_part"},
RequiredEnergyTags: []string{"pneumatic_pressure"},
RequiredLifecycles: []string{"maintenance"},
@@ -89,7 +89,7 @@ func getExtendedHazardPatternsA() []HazardPattern {
// Source: R070
},
{
ID: "HP052", NameDE: "Kompressor — pneumatisch", NameEN: "Compressor — pneumatic",
ID: "HP1807", NameDE: "Kompressor — pneumatisch", NameEN: "Compressor — pneumatic",
RequiredComponentTags: []string{"high_pressure", "noise_source", "pneumatic_part"},
RequiredEnergyTags: []string{"pneumatic_pressure"},
RequiredLifecycles: []string{"operation"},
@@ -100,18 +100,18 @@ func getExtendedHazardPatternsA() []HazardPattern {
// Source: R578, R1078
},
{
ID: "HP053", NameDE: "Schaltschrank — elektrisch", NameEN: "Control Cabinet — electrical",
ID: "HP1808", NameDE: "Schaltschrank — elektrisch", NameEN: "Control Cabinet — electrical",
RequiredComponentTags: []string{"electrical_part", "high_voltage"},
RequiredEnergyTags: []string{"electrical_energy"},
RequiredLifecycles: []string{"maintenance", "operation"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M061", "M063", "M121"},
SuggestedMeasureIDs: []string{"M481", "M482", "M141"},
SuggestedEvidenceIDs: []string{"E10", "E20"},
Priority: 80,
// Source: R061, R062, R315, R316, R566, R567, R1066, R1067
},
{
ID: "HP054", NameDE: "Steuerungsschnittstelle — Software", NameEN: "Control Interface — software",
ID: "HP1809", NameDE: "Steuerungsschnittstelle — Software", NameEN: "Control Interface — software",
RequiredComponentTags: []string{"has_software", "user_interface"},
RequiredEnergyTags: []string{},
RequiredLifecycles: []string{"operation"},
@@ -122,51 +122,51 @@ func getExtendedHazardPatternsA() []HazardPattern {
// Source: R080, R334
},
{
ID: "HP055", NameDE: "Steuerung — elektrisch", NameEN: "Controller — electrical",
ID: "HP1810", NameDE: "Steuerung — elektrisch", NameEN: "Controller — electrical",
RequiredComponentTags: []string{"has_software", "programmable"},
RequiredEnergyTags: []string{"electrical_energy"},
RequiredLifecycles: []string{"restart"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M106"},
SuggestedMeasureIDs: []string{"M061", "M141"},
SuggestedEvidenceIDs: []string{"E08"},
Priority: 80,
// Source: R339, R598, R1098
},
{
ID: "HP056", NameDE: "Foerderband — mechanisch", NameEN: "Conveyor Belt — mechanical",
ID: "HP1811", NameDE: "Foerderband — mechanisch", NameEN: "Conveyor Belt — mechanical",
RequiredComponentTags: []string{"entanglement_risk", "moving_part", "rotating_part"},
RequiredEnergyTags: []string{"kinetic"},
RequiredLifecycles: []string{"automatic_operation", "cleaning", "operation"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M051", "M054", "M121"},
SuggestedMeasureIDs: []string{"M061", "M002", "M141"},
SuggestedEvidenceIDs: []string{"E20", "E21"},
Priority: 80,
// Source: R053, R054, R556, R557, R1056, R1057
},
{
ID: "HP057", NameDE: "Foerdersystem — mechanisch", NameEN: "Conveyor System — mechanical",
ID: "HP1812", NameDE: "Foerdersystem — mechanisch", NameEN: "Conveyor System — mechanical",
RequiredComponentTags: []string{"entanglement_risk", "moving_part", "rotating_part"},
RequiredEnergyTags: []string{"kinetic"},
RequiredLifecycles: []string{"cleaning", "operation"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M051", "M054", "M121"},
SuggestedMeasureIDs: []string{"M061", "M002", "M141"},
SuggestedEvidenceIDs: []string{"E20", "E21"},
Priority: 80,
// Source: R305, R306
},
{
ID: "HP058", NameDE: "Kuehlgeraet — thermisch", NameEN: "Cooling Unit — thermal",
ID: "HP1813", NameDE: "Kuehlgeraet — thermisch", NameEN: "Cooling Unit — thermal",
RequiredComponentTags: []string{"high_temperature"},
RequiredEnergyTags: []string{"thermal"},
RequiredLifecycles: []string{"operation"},
GeneratedHazardCats: []string{"thermal_hazard"},
SuggestedMeasureIDs: []string{"M022"},
SuggestedMeasureIDs: []string{"M141"},
SuggestedEvidenceIDs: []string{"E14"},
Priority: 70,
// Source: R581, R1081
},
{
ID: "HP059", NameDE: "Kupplung — mechanisch", NameEN: "Coupling — mechanical",
ID: "HP1814", NameDE: "Kupplung — mechanisch", NameEN: "Coupling — mechanical",
RequiredComponentTags: []string{"rotating_part"},
RequiredEnergyTags: []string{"kinetic"},
RequiredLifecycles: []string{"operation"},
@@ -177,62 +177,62 @@ func getExtendedHazardPatternsA() []HazardPattern {
// Source: R056, R564, R1064
},
{
ID: "HP060", NameDE: "Diagnosemodul — Software", NameEN: "Diagnostic Module — software",
ID: "HP1815", NameDE: "Diagnosemodul — Software", NameEN: "Diagnostic Module — software",
RequiredComponentTags: []string{"has_software", "safety_device"},
RequiredEnergyTags: []string{},
RequiredLifecycles: []string{"operation"},
GeneratedHazardCats: []string{"software_fault"},
SuggestedMeasureIDs: []string{"M103"},
SuggestedMeasureIDs: []string{"M105", "M141"},
SuggestedEvidenceIDs: []string{"E14"},
Priority: 70,
// Source: R596, R1096
},
{
ID: "HP061", NameDE: "Firewall — Software", NameEN: "Firewall — software",
ID: "HP1816", NameDE: "Firewall — Software", NameEN: "Firewall — software",
RequiredComponentTags: []string{"networked", "security_device"},
RequiredEnergyTags: []string{},
RequiredLifecycles: []string{"operation"},
GeneratedHazardCats: []string{"unauthorized_access"},
SuggestedMeasureIDs: []string{"M116"},
SuggestedMeasureIDs: []string{"M188", "M186", "M141"},
SuggestedEvidenceIDs: []string{"E16"},
Priority: 85,
// Source: R587, R1087
},
{
ID: "HP062", NameDE: "Firmware — Software", NameEN: "Firmware — software",
ID: "HP1817", NameDE: "Firmware — Software", NameEN: "Firmware — software",
RequiredComponentTags: []string{"has_software", "programmable"},
RequiredEnergyTags: []string{},
RequiredLifecycles: []string{"software_update"},
GeneratedHazardCats: []string{"update_failure"},
SuggestedMeasureIDs: []string{"M104"},
SuggestedMeasureIDs: []string{"M188", "M186", "M141"},
SuggestedEvidenceIDs: []string{"E18"},
Priority: 70,
// Source: R338, R597, R1097
},
{
ID: "HP063", NameDE: "Ofen — thermisch", NameEN: "Furnace — thermal",
ID: "HP1818", NameDE: "Ofen — thermisch", NameEN: "Furnace — thermal",
RequiredComponentTags: []string{"high_temperature"},
RequiredEnergyTags: []string{"thermal"},
RequiredLifecycles: []string{"operation"},
GeneratedHazardCats: []string{"thermal_hazard"},
SuggestedMeasureIDs: []string{"M015"},
SuggestedMeasureIDs: []string{"M141"},
SuggestedEvidenceIDs: []string{"E20"},
Priority: 70,
// Source: R326, R580, R1080
},
{
ID: "HP064", NameDE: "Ofenkammer — thermisch", NameEN: "Furnace Chamber — thermal",
ID: "HP1819", NameDE: "Ofenkammer — thermisch", NameEN: "Furnace Chamber — thermal",
RequiredComponentTags: []string{"high_temperature"},
RequiredEnergyTags: []string{"thermal"},
RequiredLifecycles: []string{"operation"},
GeneratedHazardCats: []string{"thermal_hazard"},
SuggestedMeasureIDs: []string{"M015"},
SuggestedMeasureIDs: []string{"M141"},
SuggestedEvidenceIDs: []string{"E20"},
Priority: 70,
// Source: R072
},
{
ID: "HP065", NameDE: "Getriebe — mechanisch", NameEN: "Gearbox — mechanical",
ID: "HP1820", NameDE: "Getriebe — mechanisch", NameEN: "Gearbox — mechanical",
RequiredComponentTags: []string{"pinch_point", "rotating_part"},
RequiredEnergyTags: []string{"kinetic"},
RequiredLifecycles: []string{"operation"},
@@ -243,18 +243,18 @@ func getExtendedHazardPatternsA() []HazardPattern {
// Source: R055, R563, R1063
},
{
ID: "HP066", NameDE: "Heizelement — thermisch", NameEN: "Heating Element — thermal",
ID: "HP1821", NameDE: "Heizelement — thermisch", NameEN: "Heating Element — thermal",
RequiredComponentTags: []string{"high_temperature"},
RequiredEnergyTags: []string{"thermal"},
RequiredLifecycles: []string{"operation"},
GeneratedHazardCats: []string{"thermal_hazard"},
SuggestedMeasureIDs: []string{"M015"},
SuggestedMeasureIDs: []string{"M141"},
SuggestedEvidenceIDs: []string{"E10"},
Priority: 70,
// Source: R071, R325, R579, R1079
},
{
ID: "HP067", NameDE: "HMI-Bedienterminal — elektrisch", NameEN: "Hmi — electrical",
ID: "HP1822", NameDE: "HMI-Bedienterminal — elektrisch", NameEN: "Hmi — electrical",
RequiredComponentTags: []string{"has_software", "user_interface"},
RequiredEnergyTags: []string{"electrical_energy"},
RequiredLifecycles: []string{"operation"},
@@ -265,7 +265,7 @@ func getExtendedHazardPatternsA() []HazardPattern {
// Source: R333
},
{
ID: "HP068", NameDE: "HMI-Panel — elektrisch", NameEN: "Hmi Panel — electrical",
ID: "HP1823", NameDE: "HMI-Panel — elektrisch", NameEN: "Hmi Panel — electrical",
RequiredComponentTags: []string{"has_software", "user_interface"},
RequiredEnergyTags: []string{"electrical_energy"},
RequiredLifecycles: []string{"operation"},
@@ -276,7 +276,7 @@ func getExtendedHazardPatternsA() []HazardPattern {
// Source: R079, R591, R1091
},
{
ID: "HP069", NameDE: "Hydraulikzylinder — hydraulisch", NameEN: "Hydraulic Cylinder — hydraulic",
ID: "HP1824", NameDE: "Hydraulikzylinder — hydraulisch", NameEN: "Hydraulic Cylinder — hydraulic",
RequiredComponentTags: []string{"high_force", "high_pressure", "hydraulic_part", "moving_part"},
RequiredEnergyTags: []string{"hydraulic_pressure"},
RequiredLifecycles: []string{"maintenance", "operation"},
@@ -287,7 +287,7 @@ func getExtendedHazardPatternsA() []HazardPattern {
// Source: R066, R319, R320, R572, R1072
},
{
ID: "HP070", NameDE: "Hydraulikschlauch — hydraulisch", NameEN: "Hydraulic Hose — hydraulic",
ID: "HP1825", NameDE: "Hydraulikschlauch — hydraulisch", NameEN: "Hydraulic Hose — hydraulic",
RequiredComponentTags: []string{"high_pressure", "hydraulic_part"},
RequiredEnergyTags: []string{"hydraulic_pressure"},
RequiredLifecycles: []string{"operation"},
@@ -298,7 +298,7 @@ func getExtendedHazardPatternsA() []HazardPattern {
// Source: R067, R321, R573, R1073
},
{
ID: "HP071", NameDE: "Hydraulikpumpe — hydraulisch", NameEN: "Hydraulic Pump — hydraulic",
ID: "HP1826", NameDE: "Hydraulikpumpe — hydraulisch", NameEN: "Hydraulic Pump — hydraulic",
RequiredComponentTags: []string{"high_pressure", "hydraulic_part"},
RequiredEnergyTags: []string{"hydraulic_pressure"},
RequiredLifecycles: []string{"operation"},
@@ -309,7 +309,7 @@ func getExtendedHazardPatternsA() []HazardPattern {
// Source: R068, R322, R571, R1071
},
{
ID: "HP072", NameDE: "Hydrauliksystem — hydraulisch", NameEN: "Hydraulic System — hydraulic",
ID: "HP1827", NameDE: "Hydrauliksystem — hydraulisch", NameEN: "Hydraulic System — hydraulic",
RequiredComponentTags: []string{"high_pressure", "hydraulic_part"},
RequiredEnergyTags: []string{"hydraulic_pressure"},
RequiredLifecycles: []string{"maintenance"},
@@ -320,12 +320,12 @@ func getExtendedHazardPatternsA() []HazardPattern {
// Source: R575, R1075
},
{
ID: "HP073", NameDE: "Hydraulikventil — hydraulisch", NameEN: "Hydraulic Valve — hydraulic",
ID: "HP1828", NameDE: "Hydraulikventil — hydraulisch", NameEN: "Hydraulic Valve — hydraulic",
RequiredComponentTags: []string{"high_pressure", "hydraulic_part"},
RequiredEnergyTags: []string{"hydraulic_pressure"},
RequiredLifecycles: []string{"operation"},
GeneratedHazardCats: []string{"pneumatic_hydraulic"},
SuggestedMeasureIDs: []string{"M022"},
SuggestedMeasureIDs: []string{"M522", "M539", "M141"},
SuggestedEvidenceIDs: []string{"E14"},
Priority: 70,
// Source: R574, R1074
@@ -5,18 +5,18 @@ package iace
func getExtendedHazardPatternsB() []HazardPattern {
return []HazardPattern{
{
ID: "HP074", NameDE: "Industrie-Switch — elektrisch", NameEN: "Industrial Switch — electrical",
ID: "HP1830", NameDE: "Industrie-Switch — elektrisch", NameEN: "Industrial Switch — electrical",
RequiredComponentTags: []string{"networked", "security_device"},
RequiredEnergyTags: []string{"electrical_energy"},
RequiredLifecycles: []string{"operation"},
GeneratedHazardCats: []string{"communication_failure"},
SuggestedMeasureIDs: []string{"M116"},
SuggestedMeasureIDs: []string{"M113", "M106", "M141"},
SuggestedEvidenceIDs: []string{"E08"},
Priority: 70,
// Source: R075, R329, R585, R1085
},
{
ID: "HP075", NameDE: "Laserscanner — elektrisch", NameEN: "Laser Scanner — electrical",
ID: "HP1831", NameDE: "Laserscanner — elektrisch", NameEN: "Laser Scanner — electrical",
RequiredComponentTags: []string{"sensor_part"},
RequiredEnergyTags: []string{"electrical_energy"},
RequiredLifecycles: []string{"operation"},
@@ -27,7 +27,7 @@ func getExtendedHazardPatternsB() []HazardPattern {
// Source: R583, R1083
},
{
ID: "HP076", NameDE: "Hubwerk — mechanisch", NameEN: "Lifting Device — mechanical",
ID: "HP1832", NameDE: "Hubwerk — mechanisch", NameEN: "Lifting Device — mechanical",
RequiredComponentTags: []string{"gravity_risk", "high_force", "moving_part"},
RequiredEnergyTags: []string{"kinetic"},
RequiredLifecycles: []string{"operation", "transport"},
@@ -38,18 +38,18 @@ func getExtendedHazardPatternsB() []HazardPattern {
// Source: R307, R308
},
{
ID: "HP077", NameDE: "Hubtisch — hydraulisch", NameEN: "Lifting Table — hydraulic",
ID: "HP1833", NameDE: "Hubtisch — hydraulisch", NameEN: "Lifting Table — hydraulic",
RequiredComponentTags: []string{"gravity_risk", "moving_part"},
RequiredEnergyTags: []string{"hydraulic_pressure"},
RequiredLifecycles: []string{"operation"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M021"},
SuggestedMeasureIDs: []string{"M482", "M481", "M522", "M141"},
SuggestedEvidenceIDs: []string{"E11"},
Priority: 80,
// Source: R560, R1060
},
{
ID: "HP078", NameDE: "Linearachse — mechanisch", NameEN: "Linear Axis — mechanical",
ID: "HP1834", NameDE: "Linearachse — mechanisch", NameEN: "Linear Axis — mechanical",
RequiredComponentTags: []string{"crush_point", "moving_part"},
RequiredEnergyTags: []string{"kinetic"},
RequiredLifecycles: []string{"automatic_operation", "maintenance", "setup"},
@@ -60,7 +60,7 @@ func getExtendedHazardPatternsB() []HazardPattern {
// Source: R051, R052, R301, R302
},
{
ID: "HP079", NameDE: "Maschinenrahmen — mechanisch", NameEN: "Machine Frame — mechanical",
ID: "HP1835", NameDE: "Maschinenrahmen — mechanisch", NameEN: "Machine Frame — mechanical",
RequiredComponentTags: []string{"structural_part"},
RequiredEnergyTags: []string{"kinetic"},
RequiredLifecycles: []string{"operation"},
@@ -71,18 +71,18 @@ func getExtendedHazardPatternsB() []HazardPattern {
// Source: R335, R593, R1093
},
{
ID: "HP080", NameDE: "ML-Modell — Software", NameEN: "Ml Model — software",
ID: "HP1836", NameDE: "ML-Modell — Software", NameEN: "Ml Model — software",
RequiredComponentTags: []string{"has_ai", "has_software"},
RequiredEnergyTags: []string{},
RequiredLifecycles: []string{"operation"},
GeneratedHazardCats: []string{"model_drift"},
SuggestedMeasureIDs: []string{"M103"},
SuggestedMeasureIDs: []string{"M133", "M227", "M141"},
SuggestedEvidenceIDs: []string{"E15"},
Priority: 75,
// Source: R078, R332, R589, R1089
},
{
ID: "HP081", NameDE: "Ueberwachungssystem — elektrisch", NameEN: "Monitoring System — electrical",
ID: "HP1837", NameDE: "Ueberwachungssystem — elektrisch", NameEN: "Monitoring System — electrical",
RequiredComponentTags: []string{"has_software", "safety_device"},
RequiredEnergyTags: []string{"electrical_energy"},
RequiredLifecycles: []string{"operation"},
@@ -93,7 +93,7 @@ func getExtendedHazardPatternsB() []HazardPattern {
// Source: R337, R595, R1095
},
{
ID: "HP082", NameDE: "Palettierer — mechanisch", NameEN: "Palletizer — mechanical",
ID: "HP1838", NameDE: "Palettierer — mechanisch", NameEN: "Palletizer — mechanical",
RequiredComponentTags: []string{"high_force", "moving_part"},
RequiredEnergyTags: []string{"kinetic"},
RequiredLifecycles: []string{"automatic_operation"},
@@ -104,18 +104,18 @@ func getExtendedHazardPatternsB() []HazardPattern {
// Source: R559, R1059
},
{
ID: "HP083", NameDE: "Plattform — mechanisch", NameEN: "Platform — mechanical",
ID: "HP1839", NameDE: "Plattform — mechanisch", NameEN: "Platform — mechanical",
RequiredComponentTags: []string{"gravity_risk", "structural_part"},
RequiredEnergyTags: []string{"kinetic"},
RequiredLifecycles: []string{"operation"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M052"},
SuggestedMeasureIDs: []string{"M061", "M002", "M141"},
SuggestedEvidenceIDs: []string{"E20"},
Priority: 80,
// Source: R336, R594, R1094
},
{
ID: "HP084", NameDE: "Pneumatikzylinder — pneumatisch", NameEN: "Pneumatic Cylinder — pneumatic",
ID: "HP1840", NameDE: "Pneumatikzylinder — pneumatisch", NameEN: "Pneumatic Cylinder — pneumatic",
RequiredComponentTags: []string{"moving_part", "pneumatic_part", "stored_energy"},
RequiredEnergyTags: []string{"pneumatic_pressure"},
RequiredLifecycles: []string{"operation"},
@@ -126,7 +126,7 @@ func getExtendedHazardPatternsB() []HazardPattern {
// Source: R069, R323, R576, R1076
},
{
ID: "HP085", NameDE: "Pneumatikleitung — pneumatisch", NameEN: "Pneumatic Line — pneumatic",
ID: "HP1841", NameDE: "Pneumatikleitung — pneumatisch", NameEN: "Pneumatic Line — pneumatic",
RequiredComponentTags: []string{"pneumatic_part"},
RequiredEnergyTags: []string{"pneumatic_pressure"},
RequiredLifecycles: []string{"maintenance"},
@@ -137,29 +137,29 @@ func getExtendedHazardPatternsB() []HazardPattern {
// Source: R324, R577, R1077
},
{
ID: "HP086", NameDE: "Stromversorgung — elektrisch", NameEN: "Power Supply — electrical",
ID: "HP1842", NameDE: "Stromversorgung — elektrisch", NameEN: "Power Supply — electrical",
RequiredComponentTags: []string{"electrical_part", "high_voltage"},
RequiredEnergyTags: []string{"electrical_energy"},
RequiredLifecycles: []string{"maintenance", "operation"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M061", "M121"},
SuggestedMeasureIDs: []string{"M481", "M482", "M141"},
SuggestedEvidenceIDs: []string{"E14", "E20"},
Priority: 80,
// Source: R063, R311, R312, R568, R1068
},
{
ID: "HP087", NameDE: "Naeherungssensor — elektrisch", NameEN: "Proximity Sensor — electrical",
ID: "HP1843", NameDE: "Naeherungssensor — elektrisch", NameEN: "Proximity Sensor — electrical",
RequiredComponentTags: []string{"sensor_part"},
RequiredEnergyTags: []string{"electrical_energy"},
RequiredLifecycles: []string{"operation"},
GeneratedHazardCats: []string{"sensor_fault"},
SuggestedMeasureIDs: []string{"M082"},
SuggestedMeasureIDs: []string{"M214", "M119", "M141"},
SuggestedEvidenceIDs: []string{"E08"},
Priority: 70,
// Source: R073, R327, R582, R1082
},
{
ID: "HP088", NameDE: "Roboterarm — mechanisch", NameEN: "Robot Arm — mechanical",
ID: "HP1844", NameDE: "Roboterarm — mechanisch", NameEN: "Robot Arm — mechanical",
RequiredComponentTags: []string{"high_force", "moving_part", "rotating_part"},
RequiredEnergyTags: []string{"kinetic"},
RequiredLifecycles: []string{"automatic_operation", "maintenance", "teach"},
@@ -170,18 +170,18 @@ func getExtendedHazardPatternsB() []HazardPattern {
// Source: R303, R304, R551, R552, R1051, R1052
},
{
ID: "HP089", NameDE: "Robotersteuerung — elektrisch", NameEN: "Robot Controller — electrical",
ID: "HP1845", NameDE: "Robotersteuerung — elektrisch", NameEN: "Robot Controller — electrical",
RequiredComponentTags: []string{"has_software", "programmable"},
RequiredEnergyTags: []string{"electrical_energy"},
RequiredLifecycles: []string{"operation"},
GeneratedHazardCats: []string{"software_fault"},
SuggestedMeasureIDs: []string{"M103"},
SuggestedMeasureIDs: []string{"M533", "M141"},
SuggestedEvidenceIDs: []string{"E14"},
Priority: 70,
// Source: R553, R1053
},
{
ID: "HP090", NameDE: "Greifer — mechanisch", NameEN: "Robot Gripper — mechanical",
ID: "HP1846", NameDE: "Greifer — mechanisch", NameEN: "Robot Gripper — mechanical",
RequiredComponentTags: []string{"clamping_part", "moving_part", "pinch_point"},
RequiredEnergyTags: []string{"kinetic"},
RequiredLifecycles: []string{"automatic_operation", "operation", "setup"},
@@ -192,7 +192,7 @@ func getExtendedHazardPatternsB() []HazardPattern {
// Source: R057, R058, R554
},
{
ID: "HP091", NameDE: "Greifer — pneumatisch", NameEN: "Robot Gripper — pneumatic",
ID: "HP1847", NameDE: "Greifer — pneumatisch", NameEN: "Robot Gripper — pneumatic",
RequiredComponentTags: []string{"clamping_part", "moving_part", "pinch_point"},
RequiredEnergyTags: []string{"pneumatic_pressure"},
RequiredLifecycles: []string{"maintenance", "operation"},
@@ -203,18 +203,18 @@ func getExtendedHazardPatternsB() []HazardPattern {
// Source: R555, R1054, R1055
},
{
ID: "HP092", NameDE: "Rollenfoerderer — mechanisch", NameEN: "Roller Conveyor — mechanical",
ID: "HP1848", NameDE: "Rollenfoerderer — mechanisch", NameEN: "Roller Conveyor — mechanical",
RequiredComponentTags: []string{"entanglement_risk", "moving_part", "rotating_part"},
RequiredEnergyTags: []string{"kinetic"},
RequiredLifecycles: []string{"operation"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M051"},
SuggestedMeasureIDs: []string{"M061", "M002", "M141"},
SuggestedEvidenceIDs: []string{"E20"},
Priority: 80,
// Source: R558, R1058
},
{
ID: "HP093", NameDE: "Drehtisch — mechanisch", NameEN: "Rotary Table — mechanical",
ID: "HP1849", NameDE: "Drehtisch — mechanisch", NameEN: "Rotary Table — mechanical",
RequiredComponentTags: []string{"high_force", "rotating_part"},
RequiredEnergyTags: []string{"kinetic"},
RequiredLifecycles: []string{"automatic_operation", "maintenance"},
@@ -225,18 +225,18 @@ func getExtendedHazardPatternsB() []HazardPattern {
// Source: R309, R310
},
{
ID: "HP094", NameDE: "Drehscheibe — mechanisch", NameEN: "Rotating Disc — mechanical",
ID: "HP1850", NameDE: "Drehscheibe — mechanisch", NameEN: "Rotating Disc — mechanical",
RequiredComponentTags: []string{"high_speed", "rotating_part"},
RequiredEnergyTags: []string{"kinetic"},
RequiredLifecycles: []string{"operation"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M051"},
SuggestedMeasureIDs: []string{"M061", "M002", "M141"},
SuggestedEvidenceIDs: []string{"E20"},
Priority: 80,
// Source: R565, R1065
},
{
ID: "HP095", NameDE: "Spindel — mechanisch", NameEN: "Rotating Spindle — mechanical",
ID: "HP1851", NameDE: "Spindel — mechanisch", NameEN: "Rotating Spindle — mechanical",
RequiredComponentTags: []string{"cutting_part", "high_speed", "rotating_part"},
RequiredEnergyTags: []string{"kinetic"},
RequiredLifecycles: []string{"maintenance", "operation"},
@@ -247,7 +247,7 @@ func getExtendedHazardPatternsB() []HazardPattern {
// Source: R561, R562, R1061, R1062
},
{
ID: "HP096", NameDE: "Router — elektrisch", NameEN: "Router — electrical",
ID: "HP1852", NameDE: "Router — elektrisch", NameEN: "Router — electrical",
RequiredComponentTags: []string{"networked", "security_device"},
RequiredEnergyTags: []string{"electrical_energy"},
RequiredLifecycles: []string{"operation"},
@@ -258,7 +258,7 @@ func getExtendedHazardPatternsB() []HazardPattern {
// Source: R076, R330, R586, R1086
},
{
ID: "HP097", NameDE: "Gesamtsystem — gemischt", NameEN: "System — mixed",
ID: "HP1853", NameDE: "Gesamtsystem — gemischt", NameEN: "System — mixed",
RequiredComponentTags: []string{"has_software"},
RequiredEnergyTags: []string{},
RequiredLifecycles: []string{"operation", "safety_validation"},
@@ -269,18 +269,18 @@ func getExtendedHazardPatternsB() []HazardPattern {
// Source: R599, R600, R1099, R1100
},
{
ID: "HP098", NameDE: "Werkzeugwechsler — mechanisch", NameEN: "Tool Changer — mechanical",
ID: "HP1854", NameDE: "Werkzeugwechsler — mechanisch", NameEN: "Tool Changer — mechanical",
RequiredComponentTags: []string{"moving_part", "pinch_point"},
RequiredEnergyTags: []string{"kinetic"},
RequiredLifecycles: []string{"maintenance", "operation"},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M051"},
SuggestedMeasureIDs: []string{"M061", "M002", "M141"},
SuggestedEvidenceIDs: []string{"E14", "E20"},
Priority: 80,
// Source: R059, R060
},
{
ID: "HP099", NameDE: "Touch-Bedienfeld — Software", NameEN: "Touch Interface — software",
ID: "HP1855", NameDE: "Touch-Bedienfeld — Software", NameEN: "Touch Interface — software",
RequiredComponentTags: []string{"has_software", "user_interface"},
RequiredEnergyTags: []string{},
RequiredLifecycles: []string{"operation"},
@@ -291,34 +291,34 @@ func getExtendedHazardPatternsB() []HazardPattern {
// Source: R592, R1092
},
{
ID: "HP100", NameDE: "Transformator — elektrisch", NameEN: "Transformer — electrical",
ID: "HP1856", NameDE: "Transformator — elektrisch", NameEN: "Transformer — electrical",
RequiredComponentTags: []string{"electrical_part", "high_voltage"},
RequiredEnergyTags: []string{"electrical_energy"},
RequiredLifecycles: []string{"inspection", "operation"},
GeneratedHazardCats: []string{"electrical_hazard", "thermal_hazard"},
SuggestedMeasureIDs: []string{"M014", "M062"},
SuggestedMeasureIDs: []string{"M481", "M477", "M141"},
SuggestedEvidenceIDs: []string{"E10"},
Priority: 80,
// Source: R064, R313, R314, R569, R1069
},
{
ID: "HP101", NameDE: "KI-Bilderkennung — Software", NameEN: "Vision Ai — software",
ID: "HP1857", NameDE: "KI-Bilderkennung — Software", NameEN: "Vision Ai — software",
RequiredComponentTags: []string{"has_ai", "sensor_part"},
RequiredEnergyTags: []string{},
RequiredLifecycles: []string{"operation"},
GeneratedHazardCats: []string{"sensor_fault"},
SuggestedMeasureIDs: []string{"M103"},
SuggestedMeasureIDs: []string{"M214", "M119", "M141"},
SuggestedEvidenceIDs: []string{"E15"},
Priority: 70,
// Source: R077, R331, R588, R1088
},
{
ID: "HP102", NameDE: "Vision-Kamera — elektrisch", NameEN: "Vision Camera — electrical",
ID: "HP1858", NameDE: "Vision-Kamera — elektrisch", NameEN: "Vision Camera — electrical",
RequiredComponentTags: []string{"sensor_part"},
RequiredEnergyTags: []string{"electrical_energy"},
RequiredLifecycles: []string{"operation"},
GeneratedHazardCats: []string{"ai_misclassification"},
SuggestedMeasureIDs: []string{"M082"},
SuggestedMeasureIDs: []string{"M133", "M227", "M141"},
SuggestedEvidenceIDs: []string{"E20"},
Priority: 75,
// Source: R584, R1084
@@ -95,7 +95,7 @@ func GetExtendedHazardPatterns2() []HazardPattern {
RequiredEnergyTags: []string{},
RequiredLifecycles: []string{"maintenance"},
GeneratedHazardCats: []string{"maintenance_hazard"},
SuggestedMeasureIDs: []string{"M121"},
SuggestedMeasureIDs: []string{"M186", "M141"},
SuggestedEvidenceIDs: []string{"E14", "E20"},
Priority: 70,
ScenarioDE: "Nicht-originale Ersatzteile oder improvisierte Reparaturen beeintraechtigen die Sicherheit.",
@@ -146,7 +146,7 @@ func GetExtendedHazardPatterns2() []HazardPattern {
RequiredComponentTags: []string{"electrical_part"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M014"},
SuggestedMeasureIDs: []string{"M088", "M329", "M141"},
SuggestedEvidenceIDs: []string{"E10"},
Priority: 55,
ScenarioDE: "Statische Aufladung bei Folientransport oder Granulat fuehrt zu Funkenbildung.",
@@ -161,7 +161,7 @@ func GetExtendedHazardPatterns2() []HazardPattern {
RequiredComponentTags: []string{"electrical_part", "high_voltage"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M061", "M062"},
SuggestedMeasureIDs: []string{"M515", "M511", "M514", "M512"},
SuggestedEvidenceIDs: []string{"E10", "E14"},
Priority: 90,
ScenarioDE: "Korrodierte oder unterbrochene Schutzleiter fuehren bei Isolationsfehler zu Koerperdurchstroemung.",
@@ -176,7 +176,7 @@ func GetExtendedHazardPatterns2() []HazardPattern {
RequiredComponentTags: []string{"electrical_part", "stored_energy"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M061", "M121"},
SuggestedMeasureIDs: []string{"M047", "M046", "M522", "M141"},
SuggestedEvidenceIDs: []string{"E10", "E14"},
Priority: 90,
ScenarioDE: "Kondensatoren oder Zwischenkreise halten Spannung nach Abschaltung.",
@@ -423,7 +423,7 @@ func GetExtendedHazardPatterns2() []HazardPattern {
RequiredComponentTags: []string{"has_software", "programmable"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"safety_function_failure"},
SuggestedMeasureIDs: []string{"M103"},
SuggestedMeasureIDs: []string{"M186", "M187", "M141"},
SuggestedEvidenceIDs: []string{"E14", "E15"},
Priority: 75,
ScenarioDE: "Steuerungsparameter gehen bei Spannungsausfall verloren, Maschine startet mit Werkseinstellungen.",
@@ -101,7 +101,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"gravity_risk", "moving_part"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"mechanical_hazard"},
SuggestedMeasureIDs: []string{"M051"},
SuggestedMeasureIDs: []string{"M008", "M002", "M061", "M141"},
Priority: 80, MachineTypes: []string{"crane", "construction"},
ScenarioDE: "Unkontrolliertes Schwingen einer angehobenen Last", HarmDE: "Quetschung, Erschlagen durch pendelnde Last",
TriggerDE: "Schraeger Zug oder ploetzliches Abstoppen", AffectedDE: "Kranfuehrer, Anschlaeger", ZoneDE: "Schwenkbereich des Krans", DefaultSeverity: 4, DefaultExposure: 3,
@@ -125,7 +125,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"high_voltage", "electrical_part"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard", "thermal_hazard"},
SuggestedMeasureIDs: []string{"M054"},
SuggestedMeasureIDs: []string{"M520", "M477", "M138", "M141"},
Priority: 95,
ExpertHintDE: "Lichtbogenschutz (Arc Flash) — PSA Kategorie und Schutzabstand berechnen.",
ScenarioDE: "Lichtbogenbildung bei Kurzschluss in Schaltanlage", HarmDE: "Schwere Verbrennungen, Augenverletzungen, Gehoerschaden",
@@ -136,7 +136,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"electrical_part", "stored_energy"},
RequiredEnergyTags: []string{"stored_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M054"},
SuggestedMeasureIDs: []string{"M047", "M522", "M141"},
Priority: 88,
ExpertHintDE: "Entladezeit abwarten oder Entladewiderstand vorsehen. Spannungsfreiheit messen.",
ScenarioDE: "Elektrischer Schlag durch geladenen Kondensator nach Abschaltung", HarmDE: "Elektrischer Schlag, Herzrhythmusstoerungen",
@@ -147,7 +147,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"electrical_part"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M054"},
SuggestedMeasureIDs: []string{"M088", "M329", "M141"},
Priority: 72,
ScenarioDE: "Statische Entladung zuendet brennbare Atmosphaere", HarmDE: "Verbrennung, Explosion",
TriggerDE: "Funkenentladung bei ungeerdetem Material", AffectedDE: "Bedienpersonal", ZoneDE: "Ex-Bereich, Lackierzone", DefaultSeverity: 4, DefaultExposure: 2,
@@ -157,7 +157,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"high_voltage"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M054"},
SuggestedMeasureIDs: []string{"M088", "M515", "M514", "M518"},
Priority: 92,
ScenarioDE: "Koerperdurchstroemung bei defekter Schutzerdung", HarmDE: "Elektrischer Schlag, Herzkammerflimmern, Tod",
TriggerDE: "Beruehrung eines fehlerhaft geerdeten Gehaeuses", AffectedDE: "Bedienpersonal", ZoneDE: "Maschinengehaeuse, Schaltschrank", DefaultSeverity: 5, DefaultExposure: 3,
@@ -167,7 +167,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"electrical_part"},
RequiredEnergyTags: []string{"electromagnetic"},
GeneratedHazardCats: []string{"electrical_hazard"},
SuggestedMeasureIDs: []string{"M054"},
SuggestedMeasureIDs: []string{"M046", "M047", "M141"},
Priority: 70,
ScenarioDE: "Gefaehrliche Induktionsspannung in abgeschalteter Leitung", HarmDE: "Elektrischer Schlag",
TriggerDE: "Parallelfuehrung zu aktiven Hochspannungsleitungen", AffectedDE: "Elektrofachkraft", ZoneDE: "Kabeltrasse, Freileitungsbereich", DefaultSeverity: 3, DefaultExposure: 2,
@@ -177,7 +177,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"electrical_part"},
RequiredEnergyTags: []string{"electrical_energy"},
GeneratedHazardCats: []string{"electrical_hazard", "thermal_hazard"},
SuggestedMeasureIDs: []string{"M054"},
SuggestedMeasureIDs: []string{"M520", "M519", "M141"},
Priority: 85,
ScenarioDE: "Kabelbrand durch Ueberstrom bei fehlender Absicherung", HarmDE: "Verbrennungen, Rauchvergiftung, Anlagenbrand",
TriggerDE: "Ueberlast oder defekte Sicherung", AffectedDE: "Alle Personen im Gebaeude", ZoneDE: "Kabelkanal, Verteiler", DefaultSeverity: 4, DefaultExposure: 2,
@@ -191,7 +191,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"chemical_risk"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"material_environmental"},
SuggestedMeasureIDs: []string{"M054", "M124"},
SuggestedMeasureIDs: []string{"M388", "M386", "M385", "M384", "M141"},
Priority: 95,
RequiresExpertCalculation: true,
ExpertHintDE: "Explosionsschutz-Dokument erforderlich. Zoneneinteilung und Zuendquellenanalyse.",
@@ -310,7 +310,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"electrical_part"},
RequiredEnergyTags: []string{"electromagnetic"},
GeneratedHazardCats: []string{"material_environmental"},
SuggestedMeasureIDs: []string{"M054"},
SuggestedMeasureIDs: []string{"M141", "M533"},
Priority: 70,
ScenarioDE: "Exposition gegenueber elektromagnetischen Feldern bei Induktionsanlage", HarmDE: "Erwaermung von Implantaten, Herzschrittmacher-Stoerung",
TriggerDE: "Aufenthalt im Nahfeld ohne Abschirmung", AffectedDE: "Bedienpersonal, Implantattraeger", ZoneDE: "Induktionsanlage", DefaultSeverity: 3, DefaultExposure: 3,
@@ -320,7 +320,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"sensor_part"},
RequiredEnergyTags: []string{"radiation"},
GeneratedHazardCats: []string{"material_environmental"},
SuggestedMeasureIDs: []string{"M001", "M054"},
SuggestedMeasureIDs: []string{"M392", "M141", "M533"},
Priority: 95,
RequiresExpertCalculation: true,
ExpertHintDE: "Strahlenschutzbeauftragter und Genehmigung erforderlich.",
@@ -356,7 +356,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"chemical_risk"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"material_environmental"},
SuggestedMeasureIDs: []string{"M054", "M124"},
SuggestedMeasureIDs: []string{"M388", "M385", "M386", "M141"},
Priority: 96,
RequiresExpertCalculation: true,
ExpertHintDE: "Explosionsschutz-Dokument. ATEX-Zoneneinteilung erforderlich.",
@@ -378,7 +378,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"chemical_risk", "cutting_part"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"material_environmental"},
SuggestedMeasureIDs: []string{"M054", "M124"},
SuggestedMeasureIDs: []string{"M386", "M387", "M141"},
Priority: 92,
ExpertHintDE: "Metallbraende nur mit Spezialloeschmittel (Klasse D). Kein Wasser!",
ScenarioDE: "Metallbrand bei Schleifen/Fraesen von Leichtmetallstaub", HarmDE: "Unkontrollierbarer Brand, Explosion bei Wasserloeschversuch",
@@ -401,7 +401,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
RequiredComponentTags: []string{"chemical_risk"},
RequiredEnergyTags: []string{},
GeneratedHazardCats: []string{"material_environmental"},
SuggestedMeasureIDs: []string{"M054"},
SuggestedMeasureIDs: []string{"M385", "M386", "M141"},
Priority: 80,
ScenarioDE: "Erhoehte Entzuendbarkeit aller Materialien bei Sauerstoffanreicherung", HarmDE: "Schnelle Brandausbreitung, schwere Verbrennungen",
TriggerDE: "Sauerstoffleckage in geschlossenem Raum", AffectedDE: "Alle Personen im Bereich", ZoneDE: "Sauerstoff-Versorgungsanlage", DefaultSeverity: 4, DefaultExposure: 2,
@@ -434,8 +434,8 @@ func GetDGUVExtendedPatterns() []HazardPattern {
ID: "HP132", NameDE: "Ganzkoepervibrration bei Fahrzeugen/Maschinen", NameEN: "Whole-body vibration from vehicles/machines",
RequiredComponentTags: []string{"vibration_source"},
RequiredEnergyTags: []string{"vibration"},
GeneratedHazardCats: []string{"noise_vibration"},
SuggestedMeasureIDs: []string{"M054"},
GeneratedHazardCats: []string{"vibration_hazard"},
SuggestedMeasureIDs: []string{"M141"},
Priority: 65,
ScenarioDE: "Ganzkoerpervibration bei Fahren von Flurfoerderfahrzeugen", HarmDE: "Wirbelsaeulenschaeden, Bandscheibenvorfall",
TriggerDE: "Langzeitexposition auf ungefedertem Sitz", AffectedDE: "Fahrpersonal", ZoneDE: "Fahrzeugfuehrerstand", DefaultSeverity: 3, DefaultExposure: 4,
@@ -444,7 +444,7 @@ func GetDGUVExtendedPatterns() []HazardPattern {
ID: "HP133", NameDE: "Hand-Arm-Vibration bei handgefuehrten Werkzeugen", NameEN: "Hand-arm vibration from handheld tools",
RequiredComponentTags: []string{"vibration_source"},
RequiredEnergyTags: []string{"vibration"},
GeneratedHazardCats: []string{"noise_vibration"},
GeneratedHazardCats: []string{"vibration_hazard"},
SuggestedMeasureIDs: []string{"M054", "M141"},
Priority: 70,
ScenarioDE: "Hand-Arm-Vibration durch handgefuehrtes Schlagwerkzeug", HarmDE: "Durchblutungsstoerungen, Weissfingerkrankheit",

Some files were not shown because too many files have changed in this diff Show More