24 Commits

Author SHA1 Message Date
Sharang Parnerkar
aabfd0aecd docs: fix CI comment and remove dead gitea remote from CLAUDE.md
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 36s
CI/CD / test-python-backend-compliance (push) Successful in 33s
CI/CD / test-python-document-crawler (push) Successful in 24s
CI/CD / test-python-dsms-gateway (push) Successful in 19s
CI/CD / validate-canonical-controls (push) Successful in 13s
- build-push-deploy.yml: correct comment (orca runs if any build succeeds)
- CLAUDE.md: remove git push gitea main (only origin remote exists)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 10:47:52 +02:00
Sharang Parnerkar
11f13b3f74 docs: replace all Coolify references with Orca across compliance repo
All checks were successful
Build + Deploy / build-ai-sdk (push) Successful in 31s
Build + Deploy / build-developer-portal (push) Successful in 7s
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-python-backend-compliance (push) Successful in 35s
CI/CD / test-python-dsms-gateway (push) Successful in 19s
Build + Deploy / build-admin-compliance (push) Successful in 8s
Build + Deploy / build-backend-compliance (push) Successful in 8s
Build + Deploy / build-tts (push) Successful in 7s
Build + Deploy / build-document-crawler (push) Successful in 7s
Build + Deploy / build-dsms-gateway (push) Successful in 7s
CI/CD / go-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 34s
CI/CD / test-python-document-crawler (push) Successful in 23s
CI/CD / validate-canonical-controls (push) Successful in 12s
Build + Deploy / trigger-orca (push) Successful in 2m11s
CI/CD pipeline now uses Orca (build-push-deploy.yml) not Coolify.
Updated CLAUDE.md, workflow comments, docs-src, and hetzner compose.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 10:39:45 +02:00
Sharang Parnerkar
20fbfc197e fix: rename types.ts→tsx for JSX support; orca triggers on any build success
Some checks failed
Build + Deploy / build-admin-compliance (push) Successful in 1m26s
Build + Deploy / build-document-crawler (push) Has been cancelled
Build + Deploy / build-dsms-gateway (push) Has been cancelled
Build + Deploy / trigger-orca (push) Has been cancelled
Build + Deploy / build-backend-compliance (push) Successful in 7s
Build + Deploy / build-ai-sdk (push) Successful in 7s
Build + Deploy / build-developer-portal (push) Successful in 7s
Build + Deploy / build-tts (push) Has been cancelled
CI/CD / python-lint (push) Has been cancelled
CI/CD / test-python-document-crawler (push) Has been cancelled
CI/CD / test-python-dsms-gateway (push) Has been cancelled
CI/CD / go-lint (push) Has been cancelled
CI/CD / nodejs-lint (push) Has been cancelled
CI/CD / test-go-ai-compliance (push) Has been cancelled
CI/CD / test-python-backend-compliance (push) Has been cancelled
CI/CD / validate-canonical-controls (push) Has been cancelled
- types.ts had JSX (SVG icons) but .ts extension → Next.js build error
- trigger-orca now runs if at least one service build succeeds (not all)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 10:37:47 +02:00
Sharang Parnerkar
b5d20a4c1d fix: add missing training page components to fix admin-compliance Docker build
Some checks failed
Build + Deploy / build-admin-compliance (push) Failing after 34s
Build + Deploy / build-developer-portal (push) Successful in 56s
Build + Deploy / build-tts (push) Successful in 1m8s
CI/CD / go-lint (push) Has been skipped
Build + Deploy / trigger-orca (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 38s
CI/CD / test-python-backend-compliance (push) Successful in 32s
Build + Deploy / build-backend-compliance (push) Successful in 7s
Build + Deploy / build-ai-sdk (push) Successful in 7s
Build + Deploy / build-document-crawler (push) Successful in 33s
Build + Deploy / build-dsms-gateway (push) Successful in 20s
CI/CD / test-python-dsms-gateway (push) Has been cancelled
CI/CD / validate-canonical-controls (push) Has been cancelled
CI/CD / test-python-document-crawler (push) Has been cancelled
All 8 components imported by app/sdk/training/page.tsx were missing.
Docker build was failing with Module not found errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 10:32:35 +02:00
Sharang Parnerkar
54add75eb0 ci: trigger build-push-deploy for compliance services
Some checks failed
Build + Deploy / build-developer-portal (push) Failing after 14s
Build + Deploy / build-tts (push) Failing after 4s
Build + Deploy / build-document-crawler (push) Failing after 3s
Build + Deploy / build-dsms-gateway (push) Failing after 4s
Build + Deploy / trigger-orca (push) Has been skipped
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Failing after 3s
CI/CD / test-python-backend-compliance (push) Failing after 9s
CI/CD / test-python-document-crawler (push) Failing after 9s
CI/CD / test-python-dsms-gateway (push) Failing after 9s
CI/CD / validate-canonical-controls (push) Failing after 2s
Build + Deploy / build-admin-compliance (push) Failing after 53s
Build + Deploy / build-backend-compliance (push) Successful in 3m2s
Build + Deploy / build-ai-sdk (push) Successful in 50s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 09:27:40 +02:00
Sharang Parnerkar
c34f8528a7 ci: replace Coolify webhook with orca build+push+deploy pipeline
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 46s
CI/CD / test-python-backend-compliance (push) Successful in 41s
CI/CD / test-python-document-crawler (push) Successful in 31s
CI/CD / test-python-dsms-gateway (push) Successful in 31s
CI/CD / validate-canonical-controls (push) Successful in 22s
Mirror the pitch-deck pattern: each service builds its Docker image,
pushes to registry.meghsakha.com/breakpilot/compliance-*, then triggers
orca redeploy via HMAC-signed webhook.

Requires secrets: REGISTRY_USERNAME, REGISTRY_PASSWORD, ORCA_WEBHOOK_SECRET

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 09:11:08 +02:00
Sharang Parnerkar
90d14eb546 refactor(admin): split SDKSidebar and ScopeWizardTab components
Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 49s
CI/CD / test-python-backend-compliance (push) Successful in 46s
CI/CD / test-python-document-crawler (push) Successful in 30s
CI/CD / test-python-dsms-gateway (push) Successful in 31s
CI/CD / validate-canonical-controls (push) Successful in 23s
CI/CD / Deploy (push) Failing after 7s
- SDKSidebar (918→236 LOC): extracted icons to SidebarIcons, sub-components
  (ProgressBar, PackageIndicator, StepItem, CorpusStalenessInfo, AdditionalModuleItem)
  to SidebarSubComponents, and the full module nav list to SidebarModuleNav
- ScopeWizardTab (794→339 LOC): extracted DatenkategorienBlock9 and its
  dept mapping constants to DatenkategorienBlock, and question rendering
  (all switch-case types + help text) to ScopeQuestionRenderer
- All files now under 500 LOC hard cap; zero behavior changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 23:03:46 +02:00
Sharang Parnerkar
0125199c76 refactor(admin): split controls, training, control-provenance, iace/verification pages
Each page.tsx exceeded the 500-LOC hard cap. Extracted components and hooks into
colocated _components/ and _hooks/ directories; page.tsx is now a thin orchestrator.

- controls/page.tsx: 944 → 180 LOC; extracted ControlCard, AddControlForm,
  LoadingSkeleton, TransitionErrorBanner, StatsCards, FilterBar, RAGPanel into
  _components/ and useControlsData, useRAGSuggestions into _hooks/; types into _types.ts
- training/page.tsx: 780 → 288 LOC; extracted ContentTab (inline content generator tab)
  into _components/ContentTab.tsx
- control-provenance/page.tsx: 739 → 122 LOC; extracted MarkdownRenderer, UsageBadge,
  PermBadge, LicenseMatrix, SourceRegistry into _components/; PROVENANCE_SECTIONS
  static data into _data/provenance-sections.ts
- iace/[projectId]/verification/page.tsx: 673 → 196 LOC; extracted StatusBadge,
  VerificationForm, CompleteModal, SuggestEvidenceModal, VerificationTable into _components/

Zero behavior changes; logic relocated verbatim.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 22:50:15 +02:00
Sharang Parnerkar
cfd4fc347f refactor(admin): split control-library, iace/mitigations, iace/components pages
Extract hooks, sub-components, and constants into colocated files to bring
all three page.tsx files under the 500-LOC hard cap (225, 134, 111 LOC).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 22:47:16 +02:00
Sharang Parnerkar
2adbacf267 revert: remove <en> mixed-language TTS approach
Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 36s
CI/CD / test-python-backend-compliance (push) Successful in 42s
CI/CD / test-python-document-crawler (push) Successful in 38s
CI/CD / test-python-dsms-gateway (push) Successful in 23s
CI/CD / Deploy (push) Has been cancelled
CI/CD / validate-canonical-controls (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 22:38:05 +02:00
Sharang Parnerkar
9d96330a54 fix(tts): add missing re and subprocess imports for <en> tag handling
Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Failing after 46s
CI/CD / test-python-backend-compliance (push) Successful in 50s
CI/CD / test-python-document-crawler (push) Successful in 33s
CI/CD / test-python-dsms-gateway (push) Successful in 29s
CI/CD / validate-canonical-controls (push) Successful in 17s
CI/CD / Deploy (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 22:30:49 +02:00
Sharang Parnerkar
c50e57fd85 feat(tts): mixed-language synthesis via <en> tags
Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 59s
CI/CD / test-python-document-crawler (push) Has been cancelled
CI/CD / test-python-dsms-gateway (push) Has been cancelled
CI/CD / validate-canonical-controls (push) Has been cancelled
CI/CD / Deploy (push) Has been cancelled
CI/CD / test-python-backend-compliance (push) Has started running
Parse <en>word</en> markers in text, synthesise English segments with
en-US-GuyNeural and German segments with de-DE-ConradNeural, then
ffmpeg-concat into a single MP3. Fallback to plain synthesis if no tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 22:28:59 +02:00
Benjamin Admin
712fa8cb74 feat: Pass 0b quality — negative actions, container detection, session object classes
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 33s
CI/CD / test-python-backend-compliance (push) Successful in 30s
CI/CD / test-python-document-crawler (push) Successful in 21s
CI/CD / test-python-dsms-gateway (push) Successful in 16s
CI/CD / validate-canonical-controls (push) Successful in 10s
CI/CD / Deploy (push) Successful in 2s
4 error class fixes from AUTH-1052 quality review:
1. Prohibitive action types (prevent/exclude/forbid) for "dürfen keine", "verboten" etc.
2. Container object detection (Sitzungsverwaltung, Token-Schutz → _requires_decomposition)
3. Session-specific object classes (session, cookie, jwt, federated_assertion)
4. Session lifecycle actions (invalidate, issue, rotate, enforce) with templates + severity caps

76 new tests (303 total), all passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 17:24:19 +01:00
Benjamin Admin
447ec08509 Add migration 082: widen source_article to TEXT, fix pass0b query filters
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 40s
CI/CD / test-python-backend-compliance (push) Successful in 31s
CI/CD / test-python-document-crawler (push) Successful in 21s
CI/CD / test-python-dsms-gateway (push) Successful in 18s
CI/CD / validate-canonical-controls (push) Successful in 10s
CI/CD / Deploy (push) Successful in 5s
- source_article/source_regulation VARCHAR(100) → TEXT for long NIST refs
- Pass 0b NOT EXISTS queries now skip deprecated/duplicate controls
- Duplicate Guard excludes deprecated/duplicate from existence check

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 12:47:26 +01:00
Benjamin Admin
8cb1dc1108 Fix pass0b queries to skip deprecated/duplicate controls
The NOT EXISTS check and Duplicate Guard now exclude deprecated and
duplicate controls, enabling clean re-runs after invalidation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 09:09:16 +01:00
Benjamin Admin
f8d9919b97 Improve object normalization: shorter keys, synonym expansion, qualifier stripping
- Truncate object keys to 40 chars (was 80) at underscore boundary
- Strip German qualifying prepositional phrases (bei/für/gemäß/von/zur/...)
- Add 65 new synonym mappings for near-duplicate patterns found in analysis
- Strip trailing noise tokens (articles/prepositions)
- Add _truncate_at_boundary() helper and _QUALIFYING_PHRASE_RE regex
- 11 new tests for normalization improvements (227 total pass)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 08:55:48 +01:00
Benjamin Admin
fb2cf29b34 fix: Pass 0b — Duplicate Guard, Severity-Kalibrierung, Title-Truncation
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 55s
CI/CD / test-python-backend-compliance (push) Successful in 36s
CI/CD / test-python-document-crawler (push) Successful in 23s
CI/CD / test-python-dsms-gateway (push) Successful in 20s
CI/CD / validate-canonical-controls (push) Successful in 11s
CI/CD / Deploy (push) Successful in 4s
1. Duplicate Guard: merge_hint-Lookup vor INSERT in _write_atomic_control()
   verhindert semantisch identische Controls unter demselben Parent.
2. Severity-Kalibrierung: action_type-basiert statt blind vom Parent.
   define/review/test → max medium, implement/monitor → max high.
3. Title-Truncation: Schnitt am Wortende statt mitten im Wort.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 08:38:33 +01:00
Benjamin Admin
f39e5a71af feat: Obligation-Deduplizierung — 34.617 Duplikate als 'duplicate' markiert
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 33s
CI/CD / test-python-backend-compliance (push) Successful in 35s
CI/CD / test-python-document-crawler (push) Successful in 30s
CI/CD / test-python-dsms-gateway (push) Successful in 20s
CI/CD / validate-canonical-controls (push) Successful in 13s
CI/CD / Deploy (push) Successful in 3s
Neue Endpunkte POST /obligations/dedup und GET /obligations/dedup-stats.
Pro candidate_id wird der aelteste Eintrag behalten, alle weiteren erhalten
release_state='duplicate' mit merged_into_id + quality_flags fuer Traceability.
Detail-View filtert Duplikate aus. MKDocs aktualisiert.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 20:13:00 +01:00
Benjamin Admin
ac42a0aaa0 fix: Faceted Counts — NULL-Werte einbeziehen + AbortController fuer Race Conditions
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 34s
CI/CD / test-python-backend-compliance (push) Successful in 32s
CI/CD / test-python-document-crawler (push) Successful in 21s
CI/CD / test-python-dsms-gateway (push) Successful in 17s
CI/CD / validate-canonical-controls (push) Successful in 11s
CI/CD / Deploy (push) Successful in 2s
Backend: Facets zaehlen jetzt Controls OHNE Wert (z.B. "Ohne Nachweis")
als __none__. Filter unterstuetzen __none__ fuer verification_method,
category, evidence_type. Counts addieren sich immer zum Total.

Frontend: "Ohne X" Optionen in Dropdowns. AbortController verhindert
dass aeltere API-Antworten neuere ueberschreiben.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 17:35:52 +01:00
Benjamin Admin
52e463a7c8 feat: Faceted Search — Dropdown-Counts passen sich aktiven Filtern an
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 36s
CI/CD / test-python-backend-compliance (push) Successful in 42s
CI/CD / test-python-document-crawler (push) Successful in 30s
CI/CD / test-python-dsms-gateway (push) Successful in 21s
CI/CD / validate-canonical-controls (push) Successful in 13s
CI/CD / Deploy (push) Successful in 2s
Backend: controls-meta akzeptiert alle Filter-Parameter und berechnet
Faceted Counts (jede Dimension zaehlt mit allen ANDEREN Filtern).
Neue Facets: severity, verification_method, category, evidence_type,
release_state — zusaetzlich zu domains, sources, type_counts.

Frontend: loadMeta laedt bei jeder Filteraenderung neu, alle Dropdowns
zeigen kontextsensitive Zahlen. Proxy leitet Filter an controls-meta weiter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 15:00:40 +01:00
Benjamin Admin
2dee62fa6f feat: Eigenentwicklung-Filter im Typ-Dropdown mit Counts
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 36s
CI/CD / test-python-backend-compliance (push) Successful in 36s
CI/CD / test-python-document-crawler (push) Successful in 27s
CI/CD / test-python-dsms-gateway (push) Successful in 18s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Successful in 2s
Backend: control_type=eigenentwicklung in list_controls + count_controls,
type_counts (rich/atomic/eigenentwicklung) in controls-meta Endpoint.
Frontend: Typ-Dropdown zeigt Eigenentwicklung mit Anzahl.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 14:33:00 +01:00
Benjamin Admin
3fb07e201f fix: V1 Enrichment Threshold auf 0.70 gesenkt (typische Top-Scores 0.70-0.77)
Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Failing after 46s
CI/CD / test-python-backend-compliance (push) Successful in 35s
CI/CD / test-python-document-crawler (push) Successful in 24s
CI/CD / test-python-dsms-gateway (push) Successful in 19s
CI/CD / validate-canonical-controls (push) Successful in 13s
CI/CD / Deploy (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 11:13:37 +01:00
Benjamin Admin
81c9ce5de3 fix: V1 Enrichment — Qdrant Collection + Parent-Resolution fuer regulatorische Matches
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 33s
CI/CD / test-python-backend-compliance (push) Successful in 30s
CI/CD / test-python-document-crawler (push) Successful in 21s
CI/CD / test-python-dsms-gateway (push) Successful in 16s
CI/CD / validate-canonical-controls (push) Successful in 9s
CI/CD / Deploy (push) Successful in 1s
Die atomic_controls_dedup Collection (51k Punkte) enthaelt nur atomare
Controls ohne source_citation. Jetzt wird der Parent-Control aufgeloest,
der die Rechtsgrundlage traegt. Deduplizierung nach Parent-UUID verhindert
mehrfache Eintraege fuer die gleiche Regulation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 10:52:41 +01:00
Benjamin Admin
db7c207464 feat: V1 Control Enrichment — Eigenentwicklung-Label, regulatorisches Matching & Vergleichsansicht
All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 39s
CI/CD / test-python-backend-compliance (push) Successful in 32s
CI/CD / test-python-document-crawler (push) Successful in 20s
CI/CD / test-python-dsms-gateway (push) Successful in 16s
CI/CD / validate-canonical-controls (push) Successful in 9s
CI/CD / Deploy (push) Successful in 4s
863 v1-Controls (manuell geschrieben, ohne Rechtsgrundlage) werden als
"Eigenentwicklung" gekennzeichnet und automatisch mit regulatorischen
Controls (DSGVO, NIS2, OWASP etc.) per Embedding-Similarity abgeglichen.

Backend:
- Migration 080: v1_control_matches Tabelle (Cross-Reference)
- v1_enrichment.py: Batch-Matching via BGE-M3 + Qdrant (Threshold 0.75)
- 3 neue API-Endpoints: enrich-v1-matches, v1-matches, v1-enrichment-stats
- 6 Tests (dry-run, execution, matches, pagination, detection)

Frontend:
- Orange "Eigenentwicklung"-Badge statt grauem "v1" (wenn kein Source)
- "Regulatorische Abdeckung"-Sektion im ControlDetail mit Match-Karten
- Side-by-Side V1CompareView (Eigenentwicklung vs. regulatorisch gedeckt)
- Prev/Next Navigation durch alle Matches

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 10:32:08 +01:00
88 changed files with 9175 additions and 6931 deletions

View File

@@ -2,32 +2,32 @@
## Entwicklungsumgebung (WICHTIG - IMMER ZUERST LESEN)
### Zwei-Rechner-Setup + Coolify
### Zwei-Rechner-Setup + Orca
| Geraet | Rolle | Aufgaben |
|--------|-------|----------|
| **MacBook** | Entwicklung | Claude Terminal, Code-Entwicklung, Browser (Frontend-Tests) |
| **Mac Mini** | Lokaler Server | Docker fuer lokale Dev/Tests (NICHT fuer Production!) |
| **Coolify** | Production | Automatisches Build + Deploy bei Push auf gitea |
| **Orca** | Production | Automatisches Build + Deploy bei Push auf gitea |
**WICHTIG:** Code wird auf dem MacBook bearbeitet. Production-Deployment laeuft automatisch ueber Coolify.
**WICHTIG:** Code wird auf dem MacBook bearbeitet. Production-Deployment laeuft automatisch ueber Orca.
### Entwicklungsworkflow (CI/CD — Coolify)
### Entwicklungsworkflow (CI/CD — Orca)
```bash
# 1. Code auf MacBook bearbeiten (dieses Verzeichnis)
# 2. Committen und zu BEIDEN Remotes pushen:
git push origin main && git push gitea main
git push origin main
# 3. FERTIG! Push auf gitea triggert automatisch:
# - Gitea Actions: Lint → Tests → Validierung
# - Coolify: Build → Deploy
# - Orca: Build → Deploy
# Dauer: ca. 3 Minuten
# Status pruefen: https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions
```
**NICHT MEHR NOETIG:** Manuelles `ssh macmini "docker compose build"` fuer Production.
**NIEMALS** manuell in Coolify auf "Redeploy" klicken — Gitea Actions triggert Coolify automatisch.
**NIEMALS** manuell in Orca auf "Redeploy" klicken — Gitea Actions triggert Orca automatisch.
### Post-Push Deploy-Monitoring (PFLICHT nach jedem Push auf gitea)
@@ -42,17 +42,17 @@ git push origin main && git push gitea main
```
3. Sobald ALLE Endpoints healthy sind, dem User im Chat melden:
**"Deploy abgeschlossen! Du kannst jetzt testen: https://admin-dev.breakpilot.ai"**
4. Falls nach 5 Minuten noch nicht healthy → Fehlermeldung mit Hinweis auf Coolify-Logs.
4. Falls nach 5 Minuten noch nicht healthy → Fehlermeldung mit Hinweis auf Orca-Logs.
**Ablauf im Terminal:**
```
> git push gitea main ✓
> git push origin main ✓
> "Deploy gestartet, ich ueberwache den Status..."
> [Hintergrund-Polling laeuft]
> "Deploy abgeschlossen! Alle Services healthy. Du kannst jetzt testen."
```
### CI/CD Pipeline (Gitea Actions → Coolify)
### CI/CD Pipeline (Gitea Actions → Orca)
```
Push auf gitea main → go-lint/python-lint/nodejs-lint (nur PRs)
@@ -61,13 +61,13 @@ Push auf gitea main → go-lint/python-lint/nodejs-lint (nur PRs)
→ test-python-document-crawler
→ test-python-dsms-gateway
→ validate-canonical-controls
Coolify: Build + Deploy (automatisch bei Push)
Orca: Build + Deploy (automatisch bei Push)
```
**Dateien:**
- `.gitea/workflows/ci.yaml` — Pipeline-Definition (Tests + Validierung)
- `docker-compose.yml` — Haupt-Compose
- `docker-compose.hetzner.yml` — Override: arm64→amd64 fuer Coolify Production (x86_64)
- `docker-compose.hetzner.yml` — Override: arm64→amd64 fuer Orca Production (x86_64)
### Lokale Entwicklung (Mac Mini — optional)
@@ -106,7 +106,7 @@ Config via `.env` (nicht im Repo): `COMPLIANCE_DATABASE_URL`, `QDRANT_URL`, `QDR
## Haupt-URLs
### Production (Coolify-deployed)
### Production (Orca-deployed)
| URL | Service | Beschreibung |
|-----|---------|--------------|
@@ -207,7 +207,7 @@ breakpilot-compliance/
├── dsms-gateway/ # IPFS Gateway
├── scripts/ # Helper Scripts
├── docker-compose.yml # Compliance Compose (~10 Services, platform: arm64)
├── docker-compose.hetzner.yml # Override: arm64→amd64 fuer Coolify Production
├── docker-compose.hetzner.yml # Override: arm64→amd64 fuer Orca Production
└── .gitea/workflows/ci.yaml # CI/CD Pipeline (Lint → Tests → Validierung)
```
@@ -218,8 +218,8 @@ breakpilot-compliance/
### Deployment (CI/CD — Standardweg)
```bash
# Committen und pushen → Coolify deployt automatisch:
git push origin main && git push gitea main
# Committen und pushen → Orca deployt automatisch:
git push origin main
# CI-Status pruefen (im Browser):
# https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions
@@ -233,11 +233,11 @@ curl -sf https://sdk-dev.breakpilot.ai/health
```bash
# Zu BEIDEN Remotes pushen (PFLICHT! — vom MacBook):
git push origin main && git push gitea main
git push origin main
# Remotes:
# origin: lokale Gitea (macmini:3003)
# gitea: gitea.meghsakha.com:22222
```
### Lokale Docker-Befehle (Mac Mini — nur fuer Dev/Tests)

View File

@@ -0,0 +1,222 @@
# Build + push compliance service images to registry.meghsakha.com
# and trigger orca redeploy on every push to main that touches a service.
#
# Requires Gitea Actions secrets:
# REGISTRY_USERNAME / REGISTRY_PASSWORD — registry.meghsakha.com credentials
# ORCA_WEBHOOK_SECRET — must match webhooks.json on orca master
name: Build + Deploy
on:
push:
branches: [main]
paths:
- 'admin-compliance/**'
- 'backend-compliance/**'
- 'ai-compliance-sdk/**'
- 'developer-portal/**'
- 'compliance-tts-service/**'
- 'document-crawler/**'
- 'dsms-gateway/**'
- 'dsms-node/**'
jobs:
# ── per-service builds run in parallel ────────────────────────────────────
build-admin-compliance:
runs-on: docker
container: docker:27-cli
steps:
- name: Checkout
run: |
apk add --no-cache git
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
- name: Login
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
- name: Build + push
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
docker build \
-t registry.meghsakha.com/breakpilot/compliance-admin:latest \
-t registry.meghsakha.com/breakpilot/compliance-admin:${SHORT_SHA} \
admin-compliance/
docker push registry.meghsakha.com/breakpilot/compliance-admin:latest
docker push registry.meghsakha.com/breakpilot/compliance-admin:${SHORT_SHA}
build-backend-compliance:
runs-on: docker
container: docker:27-cli
steps:
- name: Checkout
run: |
apk add --no-cache git
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
- name: Login
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
- name: Build + push
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
docker build \
-t registry.meghsakha.com/breakpilot/compliance-backend:latest \
-t registry.meghsakha.com/breakpilot/compliance-backend:${SHORT_SHA} \
backend-compliance/
docker push registry.meghsakha.com/breakpilot/compliance-backend:latest
docker push registry.meghsakha.com/breakpilot/compliance-backend:${SHORT_SHA}
build-ai-sdk:
runs-on: docker
container: docker:27-cli
steps:
- name: Checkout
run: |
apk add --no-cache git
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
- name: Login
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
- name: Build + push
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
docker build \
-t registry.meghsakha.com/breakpilot/compliance-sdk:latest \
-t registry.meghsakha.com/breakpilot/compliance-sdk:${SHORT_SHA} \
ai-compliance-sdk/
docker push registry.meghsakha.com/breakpilot/compliance-sdk:latest
docker push registry.meghsakha.com/breakpilot/compliance-sdk:${SHORT_SHA}
build-developer-portal:
runs-on: docker
container: docker:27-cli
steps:
- name: Checkout
run: |
apk add --no-cache git
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
- name: Login
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
- name: Build + push
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
docker build \
-t registry.meghsakha.com/breakpilot/compliance-portal:latest \
-t registry.meghsakha.com/breakpilot/compliance-portal:${SHORT_SHA} \
developer-portal/
docker push registry.meghsakha.com/breakpilot/compliance-portal:latest
docker push registry.meghsakha.com/breakpilot/compliance-portal:${SHORT_SHA}
build-tts:
runs-on: docker
container: docker:27-cli
steps:
- name: Checkout
run: |
apk add --no-cache git
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
- name: Login
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
- name: Build + push
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
docker build \
-t registry.meghsakha.com/breakpilot/compliance-tts:latest \
-t registry.meghsakha.com/breakpilot/compliance-tts:${SHORT_SHA} \
compliance-tts-service/
docker push registry.meghsakha.com/breakpilot/compliance-tts:latest
docker push registry.meghsakha.com/breakpilot/compliance-tts:${SHORT_SHA}
build-document-crawler:
runs-on: docker
container: docker:27-cli
steps:
- name: Checkout
run: |
apk add --no-cache git
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
- name: Login
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
- name: Build + push
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
docker build \
-t registry.meghsakha.com/breakpilot/compliance-crawler:latest \
-t registry.meghsakha.com/breakpilot/compliance-crawler:${SHORT_SHA} \
document-crawler/
docker push registry.meghsakha.com/breakpilot/compliance-crawler:latest
docker push registry.meghsakha.com/breakpilot/compliance-crawler:${SHORT_SHA}
build-dsms-gateway:
runs-on: docker
container: docker:27-cli
steps:
- name: Checkout
run: |
apk add --no-cache git
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
- name: Login
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
- name: Build + push
run: |
SHORT_SHA=$(git rev-parse --short HEAD)
docker build \
-t registry.meghsakha.com/breakpilot/compliance-dsms-gateway:latest \
-t registry.meghsakha.com/breakpilot/compliance-dsms-gateway:${SHORT_SHA} \
dsms-gateway/
docker push registry.meghsakha.com/breakpilot/compliance-dsms-gateway:latest
docker push registry.meghsakha.com/breakpilot/compliance-dsms-gateway:${SHORT_SHA}
# ── orca redeploy (runs if at least one build succeeded) ─────────────────
trigger-orca:
runs-on: docker
container: docker:27-cli
if: always() && (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-admin-compliance
- build-backend-compliance
- build-ai-sdk
- build-developer-portal
- build-tts
- build-document-crawler
- build-dsms-gateway
steps:
- name: Checkout (for SHA)
run: |
apk add --no-cache git curl openssl
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
- name: Trigger orca redeploy
env:
ORCA_WEBHOOK_SECRET: ${{ secrets.ORCA_WEBHOOK_SECRET }}
ORCA_WEBHOOK_URL: http://46.225.100.82:6880/api/v1/webhooks/github
run: |
SHA=$(git rev-parse HEAD)
PAYLOAD="{\"ref\":\"refs/heads/main\",\"repository\":{\"full_name\":\"${GITHUB_REPOSITORY}\"},\"head_commit\":{\"id\":\"$SHA\",\"message\":\"ci: compliance images built\"}}"
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$ORCA_WEBHOOK_SECRET" -r | awk '{print $1}')
curl -sSf -k \
-X POST \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: push" \
-H "X-Hub-Signature-256: sha256=$SIG" \
-d "$PAYLOAD" \
"$ORCA_WEBHOOK_URL" \
|| { echo "Orca redeploy failed"; exit 1; }
echo "Orca redeploy triggered for compliance services"

View File

@@ -7,7 +7,7 @@
# Node.js: admin-compliance, developer-portal
#
# Workflow:
# Push auf main → Tests → Deploy (Coolify)
# Push auf main → Tests → Deploy (Orca)
# Pull Request → Lint + Tests (kein Deploy)
name: CI/CD
@@ -185,25 +185,5 @@ jobs:
run: |
python scripts/validate-controls.py
# ========================================
# Deploy via Coolify (nur main, kein PR)
# ========================================
deploy-coolify:
name: Deploy
runs-on: docker
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- test-go-ai-compliance
- test-python-backend-compliance
- test-python-document-crawler
- test-python-dsms-gateway
- validate-canonical-controls
container:
image: alpine:latest
steps:
- name: Trigger Coolify deploy
run: |
apk add --no-cache curl
curl -sf "${{ secrets.COOLIFY_WEBHOOK }}" \
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
# Deploy is handled by .gitea/workflows/build-push-deploy.yml
# which builds images, pushes to registry.meghsakha.com, and triggers orca.

View File

@@ -5,8 +5,8 @@
#
# Phasen: gesetze, eu, templates, datenschutz, verbraucherschutz, verify, version, all
#
# Voraussetzung: RAG-Service und Qdrant muessen auf Coolify laufen.
# Die BreakPilot-Services muessen deployed sein (ci.yaml deploy-coolify).
# Voraussetzung: RAG-Service und Qdrant muessen auf Orca laufen.
# Die BreakPilot-Services muessen deployed sein (ci.yaml deploy-orca).
name: RAG Ingestion

View File

@@ -50,9 +50,18 @@ export async function GET(request: NextRequest) {
break
}
case 'controls-meta':
backendPath = '/api/compliance/v1/canonical/controls-meta'
case 'controls-meta': {
const metaParams = new URLSearchParams()
const metaPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates']
for (const key of metaPassthrough) {
const val = searchParams.get(key)
if (val) metaParams.set(key, val)
}
const metaQs = metaParams.toString()
backendPath = `/api/compliance/v1/canonical/controls-meta${metaQs ? `?${metaQs}` : ''}`
break
}
case 'control': {
const controlId = searchParams.get('id')
@@ -135,6 +144,23 @@ export async function GET(request: NextRequest) {
backendPath = '/api/compliance/v1/canonical/blocked-sources'
break
case 'v1-matches': {
const matchId = searchParams.get('id')
if (!matchId) {
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
}
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(matchId)}/v1-matches`
break
}
case 'v1-enrichment-stats':
backendPath = '/api/compliance/v1/canonical/controls/v1-enrichment-stats'
break
case 'obligation-dedup-stats':
backendPath = '/api/compliance/v1/canonical/obligations/dedup-stats'
break
case 'controls-customer': {
const custSeverity = searchParams.get('severity')
const custDomain = searchParams.get('domain')
@@ -201,6 +227,16 @@ export async function POST(request: NextRequest) {
backendPath = '/api/compliance/v1/canonical/generate/bulk-review'
} else if (endpoint === 'blocked-sources-cleanup') {
backendPath = '/api/compliance/v1/canonical/blocked-sources/cleanup'
} else if (endpoint === 'enrich-v1-matches') {
const dryRun = searchParams.get('dry_run') ?? 'true'
const batchSize = searchParams.get('batch_size') ?? '100'
const enrichOffset = searchParams.get('offset') ?? '0'
backendPath = `/api/compliance/v1/canonical/controls/enrich-v1-matches?dry_run=${dryRun}&batch_size=${batchSize}&offset=${enrichOffset}`
} else if (endpoint === 'obligation-dedup') {
const dryRun = searchParams.get('dry_run') ?? 'true'
const batchSize = searchParams.get('batch_size') ?? '0'
const dedupOffset = searchParams.get('offset') ?? '0'
backendPath = `/api/compliance/v1/canonical/obligations/dedup?dry_run=${dryRun}&batch_size=${batchSize}&offset=${dedupOffset}`
} else if (endpoint === 'similarity-check') {
const controlId = searchParams.get('id')
if (!controlId) {

View File

@@ -308,7 +308,7 @@ export default function AtomicControlsPage() {
<StateBadge state={ctrl.release_state} />
<CategoryBadge category={ctrl.category} />
<TargetAudienceBadge audience={ctrl.target_audience} />
<GenerationStrategyBadge strategy={ctrl.generation_strategy} />
<GenerationStrategyBadge strategy={ctrl.generation_strategy} pipelineInfo={ctrl} />
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
</div>
<h3 className="text-sm font-medium text-gray-900 group-hover:text-violet-700">{ctrl.title}</h3>

View File

@@ -9,7 +9,7 @@ import {
import {
CanonicalControl, EFFORT_LABELS, BACKEND_URL,
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, EvidenceTypeBadge, TargetAudienceBadge,
ObligationTypeBadge, GenerationStrategyBadge,
ObligationTypeBadge, GenerationStrategyBadge, isEigenentwicklung,
ExtractionMethodBadge, RegulationCountBadge,
VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS,
ObligationInfo, DocumentReference, MergedDuplicate, RegulationSummary,
@@ -65,6 +65,20 @@ interface TraceabilityData {
regulations_summary?: RegulationSummary[]
}
interface V1Match {
matched_control_id: string
matched_title: string
matched_objective: string
matched_severity: string
matched_category: string
matched_source: string | null
matched_article: string | null
matched_source_citation: Record<string, string> | null
similarity_score: number
match_rank: number
match_method: string
}
interface ControlDetailProps {
ctrl: CanonicalControl
onBack: () => void
@@ -73,6 +87,7 @@ interface ControlDetailProps {
onReview: (controlId: string, action: string) => void
onRefresh?: () => void
onNavigateToControl?: (controlId: string) => void
onCompare?: (ctrl: CanonicalControl, matches: V1Match[]) => void
// Review mode navigation
reviewMode?: boolean
reviewIndex?: number
@@ -89,6 +104,7 @@ export function ControlDetail({
onReview,
onRefresh,
onNavigateToControl,
onCompare,
reviewMode,
reviewIndex = 0,
reviewTotal = 0,
@@ -101,6 +117,9 @@ export function ControlDetail({
const [merging, setMerging] = useState(false)
const [traceability, setTraceability] = useState<TraceabilityData | null>(null)
const [loadingTrace, setLoadingTrace] = useState(false)
const [v1Matches, setV1Matches] = useState<V1Match[]>([])
const [loadingV1, setLoadingV1] = useState(false)
const eigenentwicklung = isEigenentwicklung(ctrl)
const loadTraceability = useCallback(async () => {
setLoadingTrace(true)
@@ -117,9 +136,21 @@ export function ControlDetail({
finally { setLoadingTrace(false) }
}, [ctrl.control_id])
const loadV1Matches = useCallback(async () => {
if (!eigenentwicklung) { setV1Matches([]); return }
setLoadingV1(true)
try {
const res = await fetch(`${BACKEND_URL}?endpoint=v1-matches&id=${ctrl.control_id}`)
if (res.ok) setV1Matches(await res.json())
else setV1Matches([])
} catch { setV1Matches([]) }
finally { setLoadingV1(false) }
}, [ctrl.control_id, eigenentwicklung])
useEffect(() => {
loadSimilarControls()
loadTraceability()
loadV1Matches()
setSelectedDuplicates(new Set())
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ctrl.control_id])
@@ -187,7 +218,7 @@ export function ControlDetail({
<CategoryBadge category={ctrl.category} />
<EvidenceTypeBadge type={ctrl.evidence_type} />
<TargetAudienceBadge audience={ctrl.target_audience} />
<GenerationStrategyBadge strategy={ctrl.generation_strategy} />
<GenerationStrategyBadge strategy={ctrl.generation_strategy} pipelineInfo={ctrl} />
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
</div>
<h2 className="text-lg font-semibold text-gray-900 mt-1">{ctrl.title}</h2>
@@ -303,6 +334,75 @@ export function ControlDetail({
</section>
)}
{/* Regulatorische Abdeckung (Eigenentwicklung) */}
{eigenentwicklung && (
<section className="bg-orange-50 border border-orange-200 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<Scale className="w-4 h-4 text-orange-600" />
<h3 className="text-sm font-semibold text-orange-900">
Regulatorische Abdeckung
</h3>
{loadingV1 && <span className="text-xs text-orange-400">Laden...</span>}
</div>
{v1Matches.length > 0 ? (
<div className="space-y-2">
{v1Matches.map((match, i) => (
<div key={i} className="bg-white/60 border border-orange-100 rounded-lg p-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
{match.matched_source && (
<span className="text-xs font-semibold text-blue-800 bg-blue-100 px-1.5 py-0.5 rounded">
{match.matched_source}
</span>
)}
{match.matched_article && (
<span className="text-xs text-blue-700 bg-blue-50 px-1.5 py-0.5 rounded">
{match.matched_article}
</span>
)}
<span className={`text-xs font-medium px-1.5 py-0.5 rounded ${
match.similarity_score >= 0.85 ? 'bg-green-100 text-green-700' :
match.similarity_score >= 0.80 ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-600'
}`}>
{(match.similarity_score * 100).toFixed(0)}%
</span>
</div>
<p className="text-sm text-gray-800">
{onNavigateToControl ? (
<button
onClick={() => onNavigateToControl(match.matched_control_id)}
className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded hover:bg-purple-100 hover:underline mr-1.5"
>
{match.matched_control_id}
</button>
) : (
<span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded mr-1.5">
{match.matched_control_id}
</span>
)}
{match.matched_title}
</p>
</div>
{onCompare && (
<button
onClick={() => onCompare(ctrl, v1Matches)}
className="text-xs text-orange-600 border border-orange-300 rounded px-2 py-1 hover:bg-orange-100 whitespace-nowrap flex-shrink-0"
>
Vergleichen
</button>
)}
</div>
</div>
))}
</div>
) : !loadingV1 ? (
<p className="text-sm text-orange-600">Keine regulatorische Abdeckung gefunden. Dieses Control ist eine reine Eigenentwicklung.</p>
) : null}
</section>
)}
{/* Rechtsgrundlagen / Traceability (atomic controls) */}
{traceability && traceability.parent_links.length > 0 && (
<section className="bg-violet-50 border border-violet-200 rounded-lg p-4">

View File

@@ -0,0 +1,74 @@
'use client'
import { ChevronRight, BookOpen, Clock } from 'lucide-react'
import { CanonicalControl, SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, EvidenceTypeBadge, TargetAudienceBadge, GenerationStrategyBadge, ObligationTypeBadge } from './helpers'
interface ControlListItemProps {
ctrl: CanonicalControl
sortBy: string
prevSource: string | null
onClick: () => void
}
export function ControlListItem({ ctrl, sortBy, prevSource, onClick }: ControlListItemProps) {
const curSource = ctrl.source_citation?.source || 'Ohne Quelle'
const showSourceHeader = sortBy === 'source' && curSource !== prevSource
return (
<div key={ctrl.control_id}>
{showSourceHeader && (
<div className="flex items-center gap-2 pt-3 pb-1">
<div className="h-px flex-1 bg-blue-200" />
<span className="text-xs font-semibold text-blue-700 bg-blue-50 px-2 py-0.5 rounded whitespace-nowrap">{curSource}</span>
<div className="h-px flex-1 bg-blue-200" />
</div>
)}
<button
onClick={onClick}
className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:border-purple-300 hover:shadow-sm transition-all group"
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{ctrl.control_id}</span>
<SeverityBadge severity={ctrl.severity} />
<StateBadge state={ctrl.release_state} />
<LicenseRuleBadge rule={ctrl.license_rule} />
<VerificationMethodBadge method={ctrl.verification_method} />
<CategoryBadge category={ctrl.category} />
<EvidenceTypeBadge type={ctrl.evidence_type} />
<TargetAudienceBadge audience={ctrl.target_audience} />
<GenerationStrategyBadge strategy={ctrl.generation_strategy} pipelineInfo={ctrl} />
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
{ctrl.risk_score !== null && (
<span className="text-xs text-gray-400">Score: {ctrl.risk_score}</span>
)}
</div>
<h3 className="text-sm font-medium text-gray-900 group-hover:text-purple-700">{ctrl.title}</h3>
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{ctrl.objective}</p>
<div className="flex items-center gap-2 mt-2">
<BookOpen className="w-3 h-3 text-green-600" />
<span className="text-xs text-green-700">{ctrl.open_anchors.length} Referenzen</span>
{ctrl.source_citation?.source && (
<>
<span className="text-gray-300">|</span>
<span className="text-xs text-blue-600">
{ctrl.source_citation.source}
{ctrl.source_citation.article && ` ${ctrl.source_citation.article}`}
{ctrl.source_citation.paragraph && ` ${ctrl.source_citation.paragraph}`}
</span>
</>
)}
<span className="text-gray-300">|</span>
<Clock className="w-3 h-3 text-gray-400" />
<span className="text-xs text-gray-400" title={ctrl.created_at}>
{ctrl.created_at ? new Date(ctrl.created_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
</div>
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-purple-500 flex-shrink-0 mt-1 ml-4" />
</div>
</button>
</div>
)
}

View File

@@ -0,0 +1,232 @@
'use client'
import { Shield, Lock, ListChecks, Trash2, BarChart3, Zap, Plus, RefreshCw, Search, Filter, ArrowUpDown } from 'lucide-react'
import { Framework } from './helpers'
import { ControlsMeta } from './types'
import { VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS } from './helpers'
interface ControlsHeaderProps {
frameworks: Framework[]
meta: ControlsMeta | null
reviewCount: number
loading: boolean
bulkProcessing: boolean
showStats: boolean
processedStats: Array<Record<string, unknown>>
searchQuery: string
severityFilter: string
domainFilter: string
stateFilter: string
hideDuplicates: boolean
verificationFilter: string
categoryFilter: string
evidenceTypeFilter: string
audienceFilter: string
sourceFilter: string
typeFilter: string
sortBy: string
onSearchChange: (v: string) => void
onSeverityChange: (v: string) => void
onDomainChange: (v: string) => void
onStateChange: (v: string) => void
onHideDuplicatesChange: (v: boolean) => void
onVerificationChange: (v: string) => void
onCategoryChange: (v: string) => void
onEvidenceTypeChange: (v: string) => void
onAudienceChange: (v: string) => void
onSourceChange: (v: string) => void
onTypeChange: (v: string) => void
onSortChange: (v: string) => void
onRefresh: () => void
onEnterReviewMode: () => void
onBulkReject: (state: string) => void
onToggleStats: () => void
onOpenGenerator: () => void
onCreateNew: () => void
}
export function ControlsHeader({
frameworks, meta, reviewCount, loading, bulkProcessing, showStats, processedStats,
searchQuery, severityFilter, domainFilter, stateFilter, hideDuplicates,
verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, sortBy,
onSearchChange, onSeverityChange, onDomainChange, onStateChange, onHideDuplicatesChange,
onVerificationChange, onCategoryChange, onEvidenceTypeChange, onAudienceChange, onSourceChange, onTypeChange, onSortChange,
onRefresh, onEnterReviewMode, onBulkReject, onToggleStats, onOpenGenerator, onCreateNew,
}: ControlsHeaderProps) {
return (
<div className="border-b border-gray-200 bg-white px-6 py-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Shield className="w-6 h-6 text-purple-600" />
<div>
<h1 className="text-lg font-semibold text-gray-900">Canonical Control Library</h1>
<p className="text-xs text-gray-500">{meta?.total ?? 0} Security Controls</p>
</div>
</div>
<div className="flex items-center gap-2">
{reviewCount > 0 && (
<>
<button onClick={onEnterReviewMode} className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-yellow-600 rounded-lg hover:bg-yellow-700">
<ListChecks className="w-4 h-4" />
Review ({reviewCount})
</button>
<button onClick={() => onBulkReject('needs_review')} disabled={bulkProcessing}
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50">
<Trash2 className="w-4 h-4" />
{bulkProcessing ? 'Wird verarbeitet...' : `Alle ${reviewCount} ablehnen`}
</button>
</>
)}
<button onClick={onToggleStats} className="flex items-center gap-1.5 px-3 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
<BarChart3 className="w-4 h-4" />Stats
</button>
<button onClick={onOpenGenerator} className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-amber-600 rounded-lg hover:bg-amber-700">
<Zap className="w-4 h-4" />Generator
</button>
<button onClick={onCreateNew} className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-purple-600 rounded-lg hover:bg-purple-700">
<Plus className="w-4 h-4" />Neues Control
</button>
</div>
</div>
{frameworks.length > 0 && (
<div className="mb-4 p-3 bg-purple-50 rounded-lg">
<div className="flex items-center gap-2 text-xs text-purple-700">
<Lock className="w-3 h-3" />
<span className="font-medium">{frameworks[0]?.name} v{frameworks[0]?.version}</span>
<span className="text-purple-500"></span>
<span>{frameworks[0]?.description}</span>
</div>
</div>
)}
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input type="text" placeholder="Controls durchsuchen (ID, Titel, Objective)..." value={searchQuery}
onChange={e => onSearchChange(e.target.value)}
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500" />
</div>
<button onClick={onRefresh} className="p-2 text-gray-400 hover:text-purple-600" title="Aktualisieren">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Filter className="w-4 h-4 text-gray-400" />
<select value={severityFilter} onChange={e => onSeverityChange(e.target.value)}
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
<option value="">Schweregrad</option>
<option value="critical">Kritisch{meta?.severity_counts?.critical ? ` (${meta.severity_counts.critical})` : ''}</option>
<option value="high">Hoch{meta?.severity_counts?.high ? ` (${meta.severity_counts.high})` : ''}</option>
<option value="medium">Mittel{meta?.severity_counts?.medium ? ` (${meta.severity_counts.medium})` : ''}</option>
<option value="low">Niedrig{meta?.severity_counts?.low ? ` (${meta.severity_counts.low})` : ''}</option>
</select>
<select value={domainFilter} onChange={e => onDomainChange(e.target.value)}
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
<option value="">Domain</option>
{(meta?.domains || []).map(d => <option key={d.domain} value={d.domain}>{d.domain} ({d.count})</option>)}
</select>
<select value={stateFilter} onChange={e => onStateChange(e.target.value)}
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
<option value="">Status</option>
<option value="draft">Draft{meta?.release_state_counts?.draft ? ` (${meta.release_state_counts.draft})` : ''}</option>
<option value="approved">Approved{meta?.release_state_counts?.approved ? ` (${meta.release_state_counts.approved})` : ''}</option>
<option value="needs_review">Review noetig{meta?.release_state_counts?.needs_review ? ` (${meta.release_state_counts.needs_review})` : ''}</option>
<option value="too_close">Zu aehnlich{meta?.release_state_counts?.too_close ? ` (${meta.release_state_counts.too_close})` : ''}</option>
<option value="duplicate">Duplikat{meta?.release_state_counts?.duplicate ? ` (${meta.release_state_counts.duplicate})` : ''}</option>
<option value="deprecated">Deprecated{meta?.release_state_counts?.deprecated ? ` (${meta.release_state_counts.deprecated})` : ''}</option>
</select>
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer whitespace-nowrap">
<input type="checkbox" checked={hideDuplicates} onChange={e => onHideDuplicatesChange(e.target.checked)}
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
Duplikate ausblenden
</label>
<select value={verificationFilter} onChange={e => onVerificationChange(e.target.value)}
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
<option value="">Nachweis</option>
{Object.entries(VERIFICATION_METHODS).map(([k, v]) => (
<option key={k} value={k}>{v.label}{meta?.verification_method_counts?.[k] ? ` (${meta.verification_method_counts[k]})` : ''}</option>
))}
{meta?.verification_method_counts?.['__none__'] ? <option value="__none__">Ohne Nachweis ({meta.verification_method_counts['__none__']})</option> : null}
</select>
<select value={categoryFilter} onChange={e => onCategoryChange(e.target.value)}
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
<option value="">Kategorie</option>
{CATEGORY_OPTIONS.map(c => (
<option key={c.value} value={c.value}>{c.label}{meta?.category_counts?.[c.value] ? ` (${meta.category_counts[c.value]})` : ''}</option>
))}
{meta?.category_counts?.['__none__'] ? <option value="__none__">Ohne Kategorie ({meta.category_counts['__none__']})</option> : null}
</select>
<select value={evidenceTypeFilter} onChange={e => onEvidenceTypeChange(e.target.value)}
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
<option value="">Nachweisart</option>
{EVIDENCE_TYPE_OPTIONS.map(c => (
<option key={c.value} value={c.value}>{c.label}{meta?.evidence_type_counts?.[c.value] ? ` (${meta.evidence_type_counts[c.value]})` : ''}</option>
))}
{meta?.evidence_type_counts?.['__none__'] ? <option value="__none__">Ohne Nachweisart ({meta.evidence_type_counts['__none__']})</option> : null}
</select>
<select value={audienceFilter} onChange={e => onAudienceChange(e.target.value)}
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
<option value="">Zielgruppe</option>
<option value="unternehmen">Unternehmen</option>
<option value="behoerden">Behoerden</option>
<option value="entwickler">Entwickler</option>
<option value="datenschutzbeauftragte">DSB</option>
<option value="geschaeftsfuehrung">Geschaeftsfuehrung</option>
<option value="it-abteilung">IT-Abteilung</option>
<option value="rechtsabteilung">Rechtsabteilung</option>
<option value="compliance-officer">Compliance Officer</option>
<option value="personalwesen">Personalwesen</option>
<option value="einkauf">Einkauf</option>
<option value="produktion">Produktion</option>
<option value="vertrieb">Vertrieb</option>
<option value="gesundheitswesen">Gesundheitswesen</option>
<option value="finanzwesen">Finanzwesen</option>
<option value="oeffentlicher_dienst">Oeffentl. Dienst</option>
</select>
<select value={sourceFilter} onChange={e => onSourceChange(e.target.value)}
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500 max-w-[220px]">
<option value="">Dokumentenursprung</option>
{meta && <option value="__none__">Ohne Quelle ({meta.no_source_count})</option>}
{(meta?.sources || []).map(s => <option key={s.source} value={s.source}>{s.source} ({s.count})</option>)}
</select>
<select value={typeFilter} onChange={e => onTypeChange(e.target.value)}
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
<option value="">Alle Typen</option>
<option value="rich">Rich Controls{meta?.type_counts ? ` (${meta.type_counts.rich})` : ''}</option>
<option value="atomic">Atomare Controls{meta?.type_counts ? ` (${meta.type_counts.atomic})` : ''}</option>
<option value="eigenentwicklung">Eigenentwicklung{meta?.type_counts ? ` (${meta.type_counts.eigenentwicklung})` : ''}</option>
</select>
<span className="text-gray-300 mx-1">|</span>
<ArrowUpDown className="w-4 h-4 text-gray-400" />
<select value={sortBy} onChange={e => onSortChange(e.target.value)}
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-purple-500">
<option value="id">Sortierung: ID</option>
<option value="source">Nach Quelle</option>
<option value="newest">Neueste zuerst</option>
<option value="oldest">Aelteste zuerst</option>
</select>
</div>
</div>
{showStats && processedStats.length > 0 && (
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
<h4 className="text-xs font-semibold text-gray-700 mb-2">Verarbeitungsfortschritt</h4>
<div className="grid grid-cols-3 gap-3">
{processedStats.map((s, i) => (
<div key={i} className="text-xs">
<span className="font-medium text-gray-700">{String(s.collection)}</span>
<div className="flex gap-2 mt-1 text-gray-500">
<span>{String(s.processed_chunks)} verarbeitet</span>
<span>{String(s.direct_adopted)} direkt</span>
<span>{String(s.llm_reformed)} reformuliert</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@@ -15,7 +15,7 @@ import {
// Compact Control Panel (used on both sides of the comparison)
// =============================================================================
function ControlPanel({ ctrl, label, highlight }: { ctrl: CanonicalControl; label: string; highlight?: boolean }) {
export function ControlPanel({ ctrl, label, highlight }: { ctrl: CanonicalControl; label: string; highlight?: boolean }) {
return (
<div className={`flex flex-col h-full overflow-y-auto ${highlight ? 'bg-yellow-50' : 'bg-white'}`}>
{/* Panel Header */}

View File

@@ -0,0 +1,155 @@
'use client'
import { useState, useEffect } from 'react'
import {
ArrowLeft, ChevronLeft, SkipForward, Scale,
} from 'lucide-react'
import { CanonicalControl, BACKEND_URL } from './helpers'
import { ControlPanel } from './ReviewCompare'
interface V1Match {
matched_control_id: string
matched_title: string
matched_objective: string
matched_severity: string
matched_category: string
matched_source: string | null
matched_article: string | null
matched_source_citation: Record<string, string> | null
similarity_score: number
match_rank: number
match_method: string
}
interface V1CompareViewProps {
v1Control: CanonicalControl
matches: V1Match[]
onBack: () => void
onNavigateToControl?: (controlId: string) => void
}
export function V1CompareView({ v1Control, matches, onBack, onNavigateToControl }: V1CompareViewProps) {
const [currentMatchIndex, setCurrentMatchIndex] = useState(0)
const [matchedControl, setMatchedControl] = useState<CanonicalControl | null>(null)
const [loading, setLoading] = useState(false)
const currentMatch = matches[currentMatchIndex]
// Load the full matched control when index changes
useEffect(() => {
if (!currentMatch) return
const load = async () => {
setLoading(true)
try {
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${encodeURIComponent(currentMatch.matched_control_id)}`)
if (res.ok) {
setMatchedControl(await res.json())
} else {
setMatchedControl(null)
}
} catch {
setMatchedControl(null)
} finally {
setLoading(false)
}
}
load()
}, [currentMatch])
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b border-gray-200 bg-white px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<button onClick={onBack} className="text-gray-400 hover:text-gray-600">
<ArrowLeft className="w-5 h-5" />
</button>
<div>
<div className="flex items-center gap-2">
<Scale className="w-4 h-4 text-orange-500" />
<span className="text-sm font-semibold text-gray-900">V1-Vergleich</span>
{currentMatch && (
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
currentMatch.similarity_score >= 0.85 ? 'bg-green-100 text-green-700' :
currentMatch.similarity_score >= 0.80 ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-600'
}`}>
{(currentMatch.similarity_score * 100).toFixed(1)}% Aehnlichkeit
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{/* Navigation */}
<div className="flex items-center gap-1">
<button
onClick={() => setCurrentMatchIndex(Math.max(0, currentMatchIndex - 1))}
disabled={currentMatchIndex === 0}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-xs text-gray-500 font-medium">
{currentMatchIndex + 1} / {matches.length}
</span>
<button
onClick={() => setCurrentMatchIndex(Math.min(matches.length - 1, currentMatchIndex + 1))}
disabled={currentMatchIndex >= matches.length - 1}
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
>
<SkipForward className="w-4 h-4" />
</button>
</div>
{/* Navigate to matched control */}
{onNavigateToControl && matchedControl && (
<button
onClick={() => { onBack(); onNavigateToControl(matchedControl.control_id) }}
className="px-3 py-1.5 text-sm text-purple-600 border border-purple-300 rounded-lg hover:bg-purple-50"
>
Zum Control
</button>
)}
</div>
</div>
{/* Source info bar */}
{currentMatch && (currentMatch.matched_source || currentMatch.matched_article) && (
<div className="px-6 py-2 bg-blue-50 border-b border-blue-200 flex items-center gap-2 text-sm">
<Scale className="w-3.5 h-3.5 text-blue-600" />
{currentMatch.matched_source && (
<span className="font-semibold text-blue-900">{currentMatch.matched_source}</span>
)}
{currentMatch.matched_article && (
<span className="text-blue-700">{currentMatch.matched_article}</span>
)}
</div>
)}
{/* Side-by-Side Panels */}
<div className="flex-1 flex overflow-hidden">
{/* Left: V1 Eigenentwicklung */}
<div className="w-1/2 border-r border-gray-200 overflow-y-auto">
<ControlPanel ctrl={v1Control} label="Eigenentwicklung" highlight />
</div>
{/* Right: Regulatory match */}
<div className="w-1/2 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-purple-600 border-t-transparent" />
</div>
) : matchedControl ? (
<ControlPanel ctrl={matchedControl} label="Regulatorisch gedeckt" />
) : (
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
Control konnte nicht geladen werden
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -52,6 +52,7 @@ export interface CanonicalControl {
parent_control_id?: string | null
parent_control_title?: string | null
decomposition_method?: string | null
pipeline_version?: number | string | null
created_at: string
updated_at: string
}
@@ -293,7 +294,29 @@ export function TargetAudienceBadge({ audience }: { audience: string | string[]
)
}
export function GenerationStrategyBadge({ strategy }: { strategy: string | null | undefined }) {
export interface CanonicalControlPipelineInfo {
pipeline_version?: number | string | null
source_citation?: Record<string, string> | null
parent_control_uuid?: string | null
}
export function isEigenentwicklung(ctrl: CanonicalControlPipelineInfo & { generation_strategy?: string | null }): boolean {
return (
(!ctrl.generation_strategy || ctrl.generation_strategy === 'ungrouped') &&
(!ctrl.pipeline_version || String(ctrl.pipeline_version) === '1') &&
!ctrl.source_citation &&
!ctrl.parent_control_uuid
)
}
export function GenerationStrategyBadge({ strategy, pipelineInfo }: {
strategy: string | null | undefined
pipelineInfo?: CanonicalControlPipelineInfo & { generation_strategy?: string | null }
}) {
// Eigenentwicklung detection: v1 + no source + no parent
if (pipelineInfo && isEigenentwicklung(pipelineInfo)) {
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-orange-100 text-orange-700">Eigenentwicklung</span>
}
if (!strategy || strategy === 'ungrouped') {
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500">v1</span>
}

View File

@@ -0,0 +1,38 @@
// Shared types for control-library page
export interface ControlsMeta {
total: number
domains: Array<{ domain: string; count: number }>
sources: Array<{ source: string; count: number }>
no_source_count: number
type_counts?: {
rich: number
atomic: number
eigenentwicklung: number
}
severity_counts?: Record<string, number>
verification_method_counts?: Record<string, number>
category_counts?: Record<string, number>
evidence_type_counts?: Record<string, number>
release_state_counts?: Record<string, number>
}
export interface ControlFormData {
title: string
objective: string
severity: string
domain: string
release_state: string
verification_method: string
category: string
evidence_type: string
target_audience: string
license_rule: string
risk_score: number | null
implementation_effort: number | null
open_anchors: Array<{ framework: string; ref: string; url: string }>
requirements: string[]
test_procedure: string[]
evidence: Array<{ type: string; description: string }>
[key: string]: unknown
}

View File

@@ -0,0 +1,292 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { CanonicalControl, Framework, BACKEND_URL } from './helpers'
import { ControlsMeta, ControlFormData } from './types'
const PAGE_SIZE = 50
export function useControlLibrary() {
const [frameworks, setFrameworks] = useState<Framework[]>([])
const [controls, setControls] = useState<CanonicalControl[]>([])
const [totalCount, setTotalCount] = useState(0)
const [meta, setMeta] = useState<ControlsMeta | null>(null)
const [selectedControl, setSelectedControl] = useState<CanonicalControl | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Filters
const [searchQuery, setSearchQuery] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [severityFilter, setSeverityFilter] = useState<string>('')
const [domainFilter, setDomainFilter] = useState<string>('')
const [stateFilter, setStateFilter] = useState<string>('')
const [verificationFilter, setVerificationFilter] = useState<string>('')
const [categoryFilter, setCategoryFilter] = useState<string>('')
const [evidenceTypeFilter, setEvidenceTypeFilter] = useState<string>('')
const [audienceFilter, setAudienceFilter] = useState<string>('')
const [sourceFilter, setSourceFilter] = useState<string>('')
const [typeFilter, setTypeFilter] = useState<string>('')
const [hideDuplicates, setHideDuplicates] = useState(true)
const [sortBy, setSortBy] = useState<'id' | 'newest' | 'oldest' | 'source'>('id')
// CRUD state
const [mode, setMode] = useState<'list' | 'detail' | 'create' | 'edit'>('list')
const [saving, setSaving] = useState(false)
// Generator state
const [showGenerator, setShowGenerator] = useState(false)
const [processedStats, setProcessedStats] = useState<Array<Record<string, unknown>>>([])
const [showStats, setShowStats] = useState(false)
// Pagination
const [currentPage, setCurrentPage] = useState(1)
// Review mode
const [reviewMode, setReviewMode] = useState(false)
const [reviewIndex, setReviewIndex] = useState(0)
const [reviewItems, setReviewItems] = useState<CanonicalControl[]>([])
const [reviewCount, setReviewCount] = useState(0)
const [reviewTab, setReviewTab] = useState<'duplicates' | 'rule3'>('duplicates')
const [reviewDuplicates, setReviewDuplicates] = useState<CanonicalControl[]>([])
const [reviewRule3, setReviewRule3] = useState<CanonicalControl[]>([])
// V1 Compare mode
const [compareMode, setCompareMode] = useState(false)
const [compareV1Control, setCompareV1Control] = useState<CanonicalControl | null>(null)
const [compareMatches, setCompareMatches] = useState<Array<{
matched_control_id: string; matched_title: string; matched_objective: string
matched_severity: string; matched_category: string
matched_source: string | null; matched_article: string | null
matched_source_citation: Record<string, string> | null
similarity_score: number; match_rank: number; match_method: string
}>>([])
const [bulkProcessing, setBulkProcessing] = useState(false)
// Abort controllers
const metaAbortRef = useRef<AbortController | null>(null)
const controlsAbortRef = useRef<AbortController | null>(null)
// Debounce search
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (searchTimer.current) clearTimeout(searchTimer.current)
searchTimer.current = setTimeout(() => setDebouncedSearch(searchQuery), 400)
return () => { if (searchTimer.current) clearTimeout(searchTimer.current) }
}, [searchQuery])
const buildParams = useCallback((extra?: Record<string, string>) => {
const p = new URLSearchParams()
if (severityFilter) p.set('severity', severityFilter)
if (domainFilter) p.set('domain', domainFilter)
if (stateFilter) p.set('release_state', stateFilter)
if (verificationFilter) p.set('verification_method', verificationFilter)
if (categoryFilter) p.set('category', categoryFilter)
if (evidenceTypeFilter) p.set('evidence_type', evidenceTypeFilter)
if (audienceFilter) p.set('target_audience', audienceFilter)
if (sourceFilter) p.set('source', sourceFilter)
if (typeFilter) p.set('control_type', typeFilter)
if (hideDuplicates) p.set('exclude_duplicates', 'true')
if (debouncedSearch) p.set('search', debouncedSearch)
if (extra) for (const [k, v] of Object.entries(extra)) p.set(k, v)
return p.toString()
}, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch])
const loadFrameworks = useCallback(async () => {
try {
const res = await fetch(`${BACKEND_URL}?endpoint=frameworks`)
if (res.ok) setFrameworks(await res.json())
} catch { /* ignore */ }
}, [])
const loadMeta = useCallback(async () => {
if (metaAbortRef.current) metaAbortRef.current.abort()
const controller = new AbortController()
metaAbortRef.current = controller
try {
const qs = buildParams()
const res = await fetch(`${BACKEND_URL}?endpoint=controls-meta${qs ? `&${qs}` : ''}`, { signal: controller.signal })
if (res.ok && !controller.signal.aborted) setMeta(await res.json())
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return
}
}, [buildParams])
const loadControls = useCallback(async () => {
if (controlsAbortRef.current) controlsAbortRef.current.abort()
const controller = new AbortController()
controlsAbortRef.current = controller
try {
setLoading(true)
const sortField = sortBy === 'id' ? 'control_id' : sortBy === 'source' ? 'source' : 'created_at'
const sortOrder = sortBy === 'newest' ? 'desc' : sortBy === 'oldest' ? 'asc' : 'asc'
const offset = (currentPage - 1) * PAGE_SIZE
const qs = buildParams({ sort: sortField, order: sortOrder, limit: String(PAGE_SIZE), offset: String(offset) })
const countQs = buildParams()
const [ctrlRes, countRes] = await Promise.all([
fetch(`${BACKEND_URL}?endpoint=controls&${qs}`, { signal: controller.signal }),
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`, { signal: controller.signal }),
])
if (!controller.signal.aborted) {
if (ctrlRes.ok) setControls(await ctrlRes.json())
if (countRes.ok) {
const data = await countRes.json()
setTotalCount(data.total || 0)
}
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
} finally {
if (!controller.signal.aborted) setLoading(false)
}
}, [buildParams, sortBy, currentPage])
const loadReviewCount = useCallback(async () => {
try {
const res = await fetch(`${BACKEND_URL}?endpoint=controls-count&release_state=needs_review`)
if (res.ok) {
const data = await res.json()
setReviewCount(data.total || 0)
}
} catch { /* ignore */ }
}, [])
useEffect(() => { loadFrameworks(); loadReviewCount() }, [loadFrameworks, loadReviewCount])
useEffect(() => { loadMeta() }, [loadMeta])
useEffect(() => { loadControls() }, [loadControls])
useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, evidenceTypeFilter, audienceFilter, sourceFilter, typeFilter, hideDuplicates, debouncedSearch, sortBy])
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
const fullReload = useCallback(async () => {
await Promise.all([loadControls(), loadMeta(), loadFrameworks(), loadReviewCount()])
}, [loadControls, loadMeta, loadFrameworks, loadReviewCount])
const handleCreate = async (data: ControlFormData) => {
setSaving(true)
try {
const res = await fetch(`${BACKEND_URL}?endpoint=create-control`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
})
if (!res.ok) { const err = await res.json(); alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`); return }
await fullReload(); setMode('list')
} catch { alert('Netzwerkfehler') } finally { setSaving(false) }
}
const handleUpdate = async (data: ControlFormData) => {
if (!selectedControl) return
setSaving(true)
try {
const res = await fetch(`${BACKEND_URL}?endpoint=update-control&id=${selectedControl.control_id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
})
if (!res.ok) { const err = await res.json(); alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`); return }
await fullReload(); setSelectedControl(null); setMode('list')
} catch { alert('Netzwerkfehler') } finally { setSaving(false) }
}
const handleDelete = async (controlId: string) => {
if (!confirm(`Control ${controlId} wirklich loeschen?`)) return
try {
const res = await fetch(`${BACKEND_URL}?id=${controlId}`, { method: 'DELETE' })
if (!res.ok && res.status !== 204) { alert('Fehler beim Loeschen'); return }
await fullReload(); setSelectedControl(null); setMode('list')
} catch { alert('Netzwerkfehler') }
}
const handleReview = async (controlId: string, action: string) => {
try {
const res = await fetch(`${BACKEND_URL}?endpoint=review&id=${controlId}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action }),
})
if (res.ok) {
await fullReload()
if (reviewMode) {
const remaining = reviewItems.filter(c => c.control_id !== controlId)
setReviewItems(remaining)
if (remaining.length > 0) {
const nextIdx = Math.min(reviewIndex, remaining.length - 1)
setReviewIndex(nextIdx); setSelectedControl(remaining[nextIdx])
} else { setReviewMode(false); setSelectedControl(null); setMode('list') }
} else { setSelectedControl(null); setMode('list') }
}
} catch { /* ignore */ }
}
const handleBulkReject = async (sourceState: string) => {
const count = stateFilter === sourceState ? totalCount : reviewCount
if (!confirm(`Alle ${count} Controls mit Status "${sourceState}" auf "deprecated" setzen? Diese Aktion kann nicht rueckgaengig gemacht werden.`)) return
setBulkProcessing(true)
try {
const res = await fetch(`${BACKEND_URL}?endpoint=bulk-review`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ release_state: sourceState, action: 'reject' }),
})
if (res.ok) { const data = await res.json(); alert(`${data.affected_count} Controls auf "deprecated" gesetzt.`); await fullReload() }
else { const err = await res.json(); alert(`Fehler: ${err.error || err.details || 'Unbekannt'}`) }
} catch { alert('Netzwerkfehler') } finally { setBulkProcessing(false) }
}
const loadProcessedStats = async () => {
try {
const res = await fetch(`${BACKEND_URL}?endpoint=processed-stats`)
if (res.ok) { const data = await res.json(); setProcessedStats(data.stats || []) }
} catch { /* ignore */ }
}
const enterReviewMode = async () => {
try {
const res = await fetch(`${BACKEND_URL}?endpoint=controls&release_state=needs_review&limit=1000`)
if (res.ok) {
const items: CanonicalControl[] = await res.json()
if (items.length > 0) {
const dupes = items.filter(c =>
c.generation_metadata?.similar_controls &&
Array.isArray(c.generation_metadata.similar_controls) &&
(c.generation_metadata.similar_controls as unknown[]).length > 0
)
const rule3 = items.filter(c =>
!c.generation_metadata?.similar_controls ||
!Array.isArray(c.generation_metadata.similar_controls) ||
(c.generation_metadata.similar_controls as unknown[]).length === 0
)
setReviewDuplicates(dupes); setReviewRule3(rule3)
const startTab = dupes.length > 0 ? 'duplicates' : 'rule3'
const startItems = startTab === 'duplicates' ? dupes : rule3
setReviewTab(startTab); setReviewItems(startItems); setReviewMode(true)
setReviewIndex(0); setSelectedControl(startItems[0]); setMode('detail')
}
}
} catch { /* ignore */ }
}
const switchReviewTab = (tab: 'duplicates' | 'rule3') => {
const items = tab === 'duplicates' ? reviewDuplicates : reviewRule3
setReviewTab(tab); setReviewItems(items); setReviewIndex(0)
if (items.length > 0) setSelectedControl(items[0])
}
return {
// State
frameworks, controls, totalCount, meta, selectedControl, setSelectedControl,
loading, error, searchQuery, setSearchQuery, debouncedSearch,
severityFilter, setSeverityFilter, domainFilter, setDomainFilter,
stateFilter, setStateFilter, verificationFilter, setVerificationFilter,
categoryFilter, setCategoryFilter, evidenceTypeFilter, setEvidenceTypeFilter,
audienceFilter, setAudienceFilter, sourceFilter, setSourceFilter,
typeFilter, setTypeFilter, hideDuplicates, setHideDuplicates,
sortBy, setSortBy, mode, setMode, saving,
showGenerator, setShowGenerator, processedStats, showStats, setShowStats,
currentPage, setCurrentPage, totalPages,
reviewMode, setReviewMode, reviewIndex, setReviewIndex,
reviewItems, setReviewItems, reviewCount, reviewTab, setReviewTab,
reviewDuplicates, reviewRule3, bulkProcessing,
compareMode, setCompareMode, compareV1Control, setCompareV1Control, compareMatches, setCompareMatches,
// Actions
fullReload, loadControls, loadMeta, loadFrameworks, loadReviewCount,
loadProcessedStats, handleCreate, handleUpdate, handleDelete,
handleReview, handleBulkReject, enterReviewMode, switchReviewTab,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
import { UsageBadge } from './UsageBadge'
interface LicenseInfo {
license_id: string
name: string
terms_url: string | null
commercial_use: string
ai_training_restriction: string | null
tdm_allowed_under_44b: string | null
deletion_required: boolean
notes: string | null
}
export function LicenseMatrix({ licenses, loading }: { licenses: LicenseInfo[]; loading: boolean }) {
return (
<div>
<h2 className="text-xl font-bold text-gray-900 mb-4">Lizenz-Matrix</h2>
<p className="text-sm text-gray-600 mb-4">Uebersicht aller Lizenzen mit ihren erlaubten Nutzungsarten.</p>
{loading ? (
<div className="animate-pulse h-32 bg-gray-100 rounded" />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-50">
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Lizenz</th>
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Kommerziell</th>
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">AI-Training</th>
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">TDM (§44b)</th>
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Loeschpflicht</th>
</tr>
</thead>
<tbody>
{licenses.map(lic => (
<tr key={lic.license_id} className="hover:bg-gray-50">
<td className="px-3 py-2 border-b">
<div className="font-medium text-gray-900">{lic.license_id}</div>
<div className="text-xs text-gray-500">{lic.name}</div>
</td>
<td className="px-3 py-2 border-b"><UsageBadge value={lic.commercial_use} /></td>
<td className="px-3 py-2 border-b"><UsageBadge value={lic.ai_training_restriction || 'n/a'} /></td>
<td className="px-3 py-2 border-b"><UsageBadge value={lic.tdm_allowed_under_44b || 'unclear'} /></td>
<td className="px-3 py-2 border-b">
{lic.deletion_required
? <span className="text-red-600 text-xs font-medium">Ja</span>
: <span className="text-green-600 text-xs font-medium">Nein</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,38 @@
export function MarkdownRenderer({ content }: { content: string }) {
let html = content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
html = html.replace(
/^```[\w]*\n([\s\S]*?)^```$/gm,
(_m, code: string) => `<pre class="bg-gray-50 border rounded p-3 my-3 text-xs font-mono overflow-x-auto whitespace-pre">${code.trimEnd()}</pre>`
)
html = html.replace(
/^(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)*)/gm,
(_m, header: string, _sep: string, body: string) => {
const ths = header.split('|').filter((c: string) => c.trim()).map((c: string) =>
`<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase border-b">${c.trim()}</th>`
).join('')
const rows = body.trim().split('\n').map((row: string) => {
const tds = row.split('|').filter((c: string) => c.trim()).map((c: string) =>
`<td class="px-3 py-2 text-sm text-gray-700 border-b border-gray-100">${c.trim()}</td>`
).join('')
return `<tr>${tds}</tr>`
}).join('')
return `<table class="w-full border-collapse my-3 text-sm"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`
}
)
html = html.replace(/^### (.+)$/gm, '<h4 class="text-sm font-semibold text-gray-800 mt-4 mb-2">$1</h4>')
html = html.replace(/^## (.+)$/gm, '<h3 class="text-base font-semibold text-gray-900 mt-5 mb-2">$1</h3>')
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/`([^`]+)`/g, '<code class="bg-gray-100 px-1 py-0.5 rounded text-xs font-mono">$1</code>')
html = html.replace(/^- (.+)$/gm, '<li class="ml-4 text-sm text-gray-700 list-disc">$1</li>')
html = html.replace(/((?:<li[^>]*>.*<\/li>\n?)+)/g, '<ul class="my-2 space-y-1">$1</ul>')
html = html.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 text-sm text-gray-700 list-decimal">$2</li>')
html = html.replace(/^(?!<[hultdp]|$)(.+)$/gm, '<p class="text-sm text-gray-700 my-2">$1</p>')
return <div dangerouslySetInnerHTML={{ __html: html }} />
}

View File

@@ -0,0 +1,57 @@
import { ExternalLink } from 'lucide-react'
import { PermBadge } from './UsageBadge'
interface SourceInfo {
source_id: string
title: string
publisher: string
url: string | null
version_label: string | null
language: string
license_id: string
license_name: string
commercial_use: string
allowed_analysis: boolean
allowed_store_excerpt: boolean
allowed_ship_embeddings: boolean
allowed_ship_in_product: boolean
vault_retention_days: number
vault_access_tier: string
}
export function SourceRegistry({ sources, loading }: { sources: SourceInfo[]; loading: boolean }) {
return (
<div>
<h2 className="text-xl font-bold text-gray-900 mb-4">Quellenregister</h2>
<p className="text-sm text-gray-600 mb-4">Alle registrierten Quellen mit ihren Berechtigungen.</p>
{loading ? (
<div className="animate-pulse h-32 bg-gray-100 rounded" />
) : (
<div className="space-y-3">
{sources.map(src => (
<div key={src.source_id} className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="text-sm font-medium text-gray-900">{src.title}</h3>
<p className="text-xs text-gray-500">{src.publisher} {src.license_name}</p>
</div>
{src.url && (
<a href={src.url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800">
<ExternalLink className="w-3 h-3" />
Quelle
</a>
)}
</div>
<div className="flex items-center gap-3 mt-2">
<PermBadge label="Analyse" allowed={src.allowed_analysis} />
<PermBadge label="Excerpt" allowed={src.allowed_store_excerpt} />
<PermBadge label="Embeddings" allowed={src.allowed_ship_embeddings} />
<PermBadge label="Produkt" allowed={src.allowed_ship_in_product} />
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,25 @@
import { CheckCircle2, Lock } from 'lucide-react'
const USAGE_CONFIG: Record<string, { bg: string; label: string }> = {
allowed: { bg: 'bg-green-100 text-green-800', label: 'Erlaubt' },
restricted: { bg: 'bg-yellow-100 text-yellow-800', label: 'Eingeschraenkt' },
prohibited: { bg: 'bg-red-100 text-red-800', label: 'Verboten' },
unclear: { bg: 'bg-gray-100 text-gray-600', label: 'Unklar' },
yes: { bg: 'bg-green-100 text-green-800', label: 'Ja' },
no: { bg: 'bg-red-100 text-red-800', label: 'Nein' },
'n/a': { bg: 'bg-gray-100 text-gray-400', label: 'k.A.' },
}
export function UsageBadge({ value }: { value: string }) {
const c = USAGE_CONFIG[value] || USAGE_CONFIG.unclear
return <span className={`inline-flex px-1.5 py-0.5 rounded text-xs font-medium ${c.bg}`}>{c.label}</span>
}
export function PermBadge({ label, allowed }: { label: string; allowed: boolean }) {
return (
<div className="flex items-center gap-1">
{allowed ? <CheckCircle2 className="w-3 h-3 text-green-500" /> : <Lock className="w-3 h-3 text-red-400" />}
<span className={`text-xs ${allowed ? 'text-green-700' : 'text-red-500'}`}>{label}</span>
</div>
)
}

View File

@@ -0,0 +1,404 @@
export interface ProvenanceSection {
id: string
title: string
content: string
}
export const PROVENANCE_SECTIONS: ProvenanceSection[] = [
{
id: 'methodology',
title: 'Methodik der Control-Erstellung',
content: `## Unabhaengige Formulierung
Alle Controls in der Canonical Control Library wurden **eigenstaendig formuliert** und folgen einer
**unabhaengigen Taxonomie**. Es werden keine proprietaeren Bezeichner, Nummern oder Strukturen
aus geschuetzten Quellen uebernommen.
### Dreistufiger Prozess
1. **Offene Recherche** — Identifikation von Security-Anforderungen aus oeffentlichen, frei zugaenglichen
Frameworks (OWASP, NIST, ENISA). Jede Anforderung wird aus mindestens 2 unabhaengigen offenen Quellen belegt.
2. **Eigenstaendige Formulierung** — Jedes Control wird mit eigener Sprache, eigener Struktur und eigener
Taxonomie (z.B. AUTH-001, NET-001) verfasst. Kein Copy-Paste, keine Paraphrase geschuetzter Texte.
3. **Too-Close-Pruefung** — Automatisierte Aehnlichkeitspruefung gegen Quelltexte mit 5 Metriken
(Token Overlap, N-Gram Jaccard, Embedding Cosine, LCS Ratio, Exact-Phrase). Nur Controls mit
Status PASS oder WARN (+ Human Review) werden freigegeben.
### Rechtliche Grundlage
- **UrhG §44b** — Text & Data Mining erlaubt fuer Analyse; Kopien werden danach geloescht
- **UrhG §23** — Hinreichender Abstand zum Originalwerk durch eigene Formulierung
- **BSI Nutzungsbedingungen** — Kommerzielle Nutzung nur mit Zustimmung; wir nutzen BSI-Dokumente
ausschliesslich als Analysegrundlage, nicht im Produkt`,
},
{
id: 'filters',
title: 'Filter in der Control Library',
content: `## Dropdown-Filter
Die Control Library bietet 7 Filter-Dropdowns, um die ueber 3.000 Controls effizient zu durchsuchen:
### Schweregrad (Severity)
| Stufe | Farbe | Bedeutung |
|-------|-------|-----------|
| **Kritisch** | Rot | Sicherheitskritische Controls — Verstoesse fuehren zu schwerwiegenden Risiken |
| **Hoch** | Orange | Wichtige Controls — sollten zeitnah umgesetzt werden |
| **Mittel** | Gelb | Standardmaessige Controls — empfohlene Umsetzung |
| **Niedrig** | Gruen | Nice-to-have Controls — zusaetzliche Haertung |
### Domain
Das Praefix der Control-ID (z.B. \`AUTH-001\`, \`SEC-042\`). Kennzeichnet den thematischen Bereich.
Die haeufigsten Domains:
| Domain | Anzahl | Thema |
|--------|--------|-------|
| SEC | ~700 | Allgemeine Sicherheit, Systemhaertung |
| COMP | ~470 | Compliance, Regulierung, Nachweispflichten |
| DATA | ~400 | Datenschutz, Datenklassifizierung, DSGVO |
| AI | ~290 | KI-Regulierung (AI Act, Transparenz, Erklaerbarkeit) |
| LOG | ~230 | Logging, Monitoring, SIEM |
| AUTH | ~200 | Authentifizierung, Zugriffskontrolle |
| NET | ~150 | Netzwerksicherheit, Transport, Firewall |
| CRYP | ~90 | Kryptographie, Schluesselmanagement |
| ACC | ~25 | Zugriffskontrolle (Access Control) |
| INC | ~25 | Incident Response, Vorfallmanagement |
Zusaetzlich existieren spezialisierte Domains wie CRA, ARC (Architektur), API, PKI, SUP (Supply Chain) u.v.m.
### Status (Release State)
| Status | Bedeutung |
|--------|-----------|
| **Draft** | Entwurf — noch nicht freigegeben |
| **Approved** | Freigegeben fuer Kunden |
| **Review noetig** | Muss manuell geprueft werden |
| **Zu aehnlich** | Too-Close-Check hat Warnung ausgeloest |
| **Duplikat** | Wurde als Duplikat eines anderen Controls erkannt |
### Nachweis (Verification Method)
| Methode | Farbe | Beschreibung |
|---------|-------|-------------|
| **Code Review** | Blau | Nachweis durch Quellcode-Inspektion |
| **Dokument** | Amber | Nachweis durch Richtlinien, Prozesse, Schulungen |
| **Tool** | Teal | Nachweis durch automatisierte Scans/Monitoring |
| **Hybrid** | Lila | Kombination aus mehreren Methoden |
### Kategorie
Thematische Einordnung (17 Kategorien). Kategorien sind **thematisch**, Domains **strukturell**.
Ein AUTH-Control kann z.B. die Kategorie "Netzwerksicherheit" haben.
### Zielgruppe (Target Audience)
| Zielgruppe | Bedeutung |
|------------|-----------|
| **Unternehmen** | Fuer Endkunden/Firmen relevant |
| **Behoerden** | Spezifisch fuer oeffentliche Verwaltung |
| **Anbieter** | Fuer SaaS/Plattform-Anbieter |
| **Alle** | Allgemein anwendbar |
### Dokumentenursprung (Source)
Filtert nach der Quelldokument-Herkunft des Controls. Zeigt alle Quellen sortiert nach
Haeufigkeit. Die wichtigsten Quellen:
| Quelle | Typ |
|--------|-----|
| KI-Verordnung (EU) 2024/1689 | EU-Recht |
| Cyber Resilience Act (EU) 2024/2847 | EU-Recht |
| DSGVO (EU) 2016/679 | EU-Recht |
| NIS2-Richtlinie (EU) 2022/2555 | EU-Recht |
| NIST SP 800-53, CSF 2.0, SSDF | US-Standards |
| OWASP Top 10, ASVS, SAMM | Open Source |
| ENISA Guidelines | EU-Agentur |
| CISA Secure by Design | US-Behoerde |
| BDSG, TKG, GewO, HGB | Deutsche Gesetze |
| EDPB Leitlinien | EU Datenschutz |`,
},
{
id: 'badges',
title: 'Badges & Lizenzregeln',
content: `## Badges in der Control Library
Jedes Control zeigt mehrere farbige Badges:
### Lizenzregel-Badge (Rule 1 / 2 / 3)
Die Lizenzregel bestimmt, wie ein Control erstellt und genutzt werden darf:
| Badge | Farbe | Regel | Bedeutung |
|-------|-------|-------|-----------|
| **Free Use** | Gruen | Rule 1 | Quelle ist Public Domain oder EU-Recht — Originaltext darf gezeigt werden |
| **Zitation** | Blau | Rule 2 | Quelle ist CC-BY oder aehnlich — Zitation + Quellenangabe erforderlich |
| **Reformuliert** | Amber | Rule 3 | Quelle hat eingeschraenkte Lizenz — Control wurde eigenstaendig reformuliert, kein Originaltext |
### Processing-Path
| Pfad | Bedeutung |
|------|-----------|
| **structured** | Control wurde direkt aus strukturierten Daten (Tabellen, Listen) extrahiert |
| **llm_reform** | Control wurde mit LLM eigenstaendig formuliert (bei Rule 3 zwingend) |
### Referenzen (Open Anchors)
Zeigt die Anzahl der verlinkten Open-Source-Referenzen (OWASP, NIST, ENISA etc.).
Jedes freigegebene Control muss mindestens 1 Open Anchor haben.
### Weitere Badges
| Badge | Bedeutung |
|-------|-----------|
| Score | Risiko-Score (0-10) |
| Severity-Badge | Schweregrad (Kritisch/Hoch/Mittel/Niedrig) |
| State-Badge | Freigabestatus (Draft/Approved/etc.) |
| Kategorie-Badge | Thematische Kategorie |
| Zielgruppe-Badge | Enterprise/Behoerden/Anbieter/Alle |`,
},
{
id: 'taxonomy',
title: 'Unabhaengige Taxonomie',
content: `## Eigenes Klassifikationssystem
Die Canonical Control Library verwendet ein **eigenes Domain-Schema**, das sich bewusst von
proprietaeren Frameworks unterscheidet. Die Domains werden **automatisch** durch den
Control Generator vergeben, basierend auf dem Inhalt der Quelldokumente.
### Top-10 Domains
| Domain | Anzahl | Thema | Hauptquellen |
|--------|--------|-------|-------------|
| SEC | ~700 | Allgemeine Sicherheit | CRA, NIS2, BSI, ENISA |
| COMP | ~470 | Compliance & Regulierung | DSGVO, AI Act, Richtlinien |
| DATA | ~400 | Datenschutz & Datenklassifizierung | DSGVO, BDSG, EDPB |
| AI | ~290 | KI-Regulierung & Ethik | AI Act, HLEG, OECD |
| LOG | ~230 | Logging & Monitoring | NIST, OWASP |
| AUTH | ~200 | Authentifizierung & Session | NIST SP 800-63, OWASP |
| NET | ~150 | Netzwerksicherheit | NIST, ENISA |
| CRYP | ~90 | Kryptographie & Schluessel | NIST SP 800-57 |
| ACC | ~25 | Zugriffskontrolle | OWASP ASVS |
| INC | ~25 | Incident Response | NIS2, CRA |
### Spezialisierte Domains
Neben den Top-10 gibt es ueber 90 weitere Domains fuer spezifische Themen:
- **CRA** — Cyber Resilience Act spezifisch
- **ARC** — Sichere Architektur
- **API** — API-Security
- **PKI** — Public Key Infrastructure
- **SUP** — Supply Chain Security
- **VUL** — Vulnerability Management
- **BCP** — Business Continuity
- **PHY** — Physische Sicherheit
- u.v.m.
### ID-Format
Control-IDs folgen dem Muster \`DOMAIN-NNN\` (z.B. AUTH-001, SEC-042). Dieses Format ist
**nicht von BSI oder anderen proprietaeren Standards abgeleitet**, sondern folgt einem
allgemein ueblichen Nummerierungsschema.`,
},
{
id: 'open-sources',
title: 'Offene Referenzquellen',
content: `## Primaere offene Quellen
Alle Controls sind in mindestens einer der folgenden **frei zugaenglichen** Quellen verankert:
### OWASP (CC BY-SA 4.0 — kommerziell erlaubt)
- **ASVS** — Application Security Verification Standard v4.0.3
- **MASVS** — Mobile Application Security Verification Standard v2.1
- **Top 10** — OWASP Top 10 (2021)
- **Cheat Sheets** — OWASP Cheat Sheet Series
- **SAMM** — Software Assurance Maturity Model
### NIST (Public Domain — keine Einschraenkungen)
- **SP 800-53 Rev.5** — Security and Privacy Controls
- **SP 800-63B** — Digital Identity Guidelines (Authentication)
- **SP 800-57** — Key Management Recommendations
- **SP 800-52 Rev.2** — TLS Implementation Guidelines
- **SP 800-92** — Log Management Guide
- **SP 800-218 (SSDF)** — Secure Software Development Framework
- **SP 800-60** — Information Types to Security Categories
### ENISA (CC BY 4.0 — kommerziell erlaubt)
- Good Practices for IoT/Mobile Security
- Data Protection Engineering
- Algorithms, Key Sizes and Parameters Report
### Weitere offene Quellen
- **SLSA** (Supply-chain Levels for Software Artifacts) — Google Open Source
- **CIS Controls v8** (CC BY-NC-ND — nur fuer interne Analyse)`,
},
{
id: 'restricted-sources',
title: 'Geschuetzte Quellen — Nur interne Analyse',
content: `## Quellen mit eingeschraenkter Nutzung
Die folgenden Quellen werden **ausschliesslich intern zur Analyse** verwendet.
Kein Text, keine Struktur, keine Bezeichner aus diesen Quellen erscheinen im Produkt.
### BSI (Nutzungsbedingungen — kommerziell eingeschraenkt)
- TR-03161 Teil 1-3 (Mobile, Web, Hintergrunddienste)
- Nutzung: TDM unter UrhG §44b, Kopien werden geloescht
- Kein Shipping von Zitaten, Embeddings oder Strukturen
### ISO/IEC (Kostenpflichtig — kein Shipping)
- ISO 27001, ISO 27002
- Nutzung: Nur als Referenz fuer Mapping, kein Text im Produkt
### ETSI (Restriktiv — kein kommerzieller Gebrauch)
- Nutzung: Nur als Hintergrundwissen, kein direkter Einfluss
### Trennungsprinzip
| Ebene | Geschuetzte Quelle | Offene Quelle |
|-------|--------------------|---------------|
| Analyse | ✅ Darf gelesen werden | ✅ Darf gelesen werden |
| Inspiration | ✅ Darf Ideen liefern | ✅ Darf Ideen liefern |
| Formulierung | ❌ Keine Uebernahme | ✅ Darf zitiert werden |
| Struktur | ❌ Keine Uebernahme | ✅ Darf verwendet werden |
| Produkttext | ❌ Nicht erlaubt | ✅ Erlaubt |`,
},
{
id: 'verification-methods',
title: 'Verifikationsmethoden',
content: `## Nachweis-Klassifizierung
Jedes Control wird einer von vier Verifikationsmethoden zugeordnet. Dies bestimmt,
**wie** ein Kunde den Nachweis fuer die Einhaltung erbringen kann:
| Methode | Beschreibung | Beispiele |
|---------|-------------|-----------|
| **Code Review** | Nachweis durch Quellcode-Inspektion | Input-Validierung, Encryption-Konfiguration, Auth-Logic |
| **Dokument** | Nachweis durch Richtlinien, Prozesse, Schulungen | Notfallplaene, Schulungsnachweise, Datenschutzkonzepte |
| **Tool** | Nachweis durch automatisierte Tools/Scans | SIEM-Logs, Vulnerability-Scans, Monitoring-Dashboards |
| **Hybrid** | Kombination aus mehreren Methoden | Zugriffskontrollen (Code + Policy + Tool) |
### Bedeutung fuer Kunden
- **Code Review Controls** koennen direkt im SDK-Scan geprueft werden
- **Dokument Controls** erfordern manuelle Uploads (PDFs, Links)
- **Tool Controls** koennen per API-Integration automatisch nachgewiesen werden
- **Hybrid Controls** benoetigen mehrere Nachweisarten`,
},
{
id: 'categories',
title: 'Thematische Kategorien',
content: `## 17 Sicherheitskategorien
Controls sind in thematische Kategorien gruppiert, um Kunden eine
uebersichtliche Navigation zu ermoeglichen:
| Kategorie | Beschreibung |
|-----------|-------------|
| Verschluesselung & Kryptographie | TLS, Key Management, Algorithmen |
| Authentisierung & Zugriffskontrolle | Login, MFA, RBAC, Session-Management |
| Netzwerksicherheit | Firewall, Segmentierung, VPN, DNS |
| Datenschutz & Datensicherheit | DSGVO, Datenklassifizierung, Anonymisierung |
| Logging & Monitoring | SIEM, Audit-Logs, Alerting |
| Vorfallmanagement | Incident Response, Meldepflichten |
| Notfall & Wiederherstellung | BCM, Disaster Recovery, Backups |
| Compliance & Audit | Zertifizierungen, Audits, Berichtspflichten |
| Lieferkettenmanagement | Vendor Risk, SBOM, Third-Party |
| Physische Sicherheit | Zutritt, Gebaeudesicherheit |
| Personal & Schulung | Security Awareness, Rollenkonzepte |
| Anwendungssicherheit | SAST, DAST, Secure Coding |
| Systemhaertung & -betrieb | Patching, Konfiguration, Hardening |
| Risikomanagement | Risikoanalyse, Bewertung, Massnahmen |
| Sicherheitsorganisation | ISMS, Richtlinien, Governance |
| Hardware & Plattformsicherheit | TPM, Secure Boot, Firmware |
| Identitaetsmanagement | SSO, Federation, Directory |
### Abgrenzung zu Domains
Kategorien sind **thematisch**, Domains (AUTH, NET, etc.) sind **strukturell**.
Ein Control AUTH-005 (Domain AUTH) hat die Kategorie "authentication",
aber ein Control NET-012 (Domain NET) koennte ebenfalls die Kategorie
"authentication" haben, wenn es um Netzwerk-Authentifizierung geht.`,
},
{
id: 'master-library',
title: 'Master Library Strategie',
content: `## RAG-First Ansatz
Die Canonical Control Library folgt einer **RAG-First-Strategie**:
### Schritt 1: Rule 1+2 Controls aus RAG generieren
Prioritaet haben Controls aus Quellen mit **Originaltext-Erlaubnis**:
| Welle | Quellen | Lizenzregel | Vorteil |
|-------|---------|------------|---------|
| 1 | OWASP (ASVS, MASVS, Top10) | Rule 2 (CC-BY-SA, Zitation) | Originaltext + Zitation |
| 2 | NIST (SP 800-53, CSF, SSDF) | Rule 1 (Public Domain) | Voller Text, keine Einschraenkungen |
| 3 | EU-Verordnungen (DSGVO, AI Act, NIS2, CRA) | Rule 1 (EU Law) | Gesetzestext + Erklaerung |
| 4 | Deutsche Gesetze (BDSG, TTDSG, TKG) | Rule 1 (DE Law) | Gesetzestext + Erklaerung |
### Schritt 2: Dedup gegen BSI Rule-3 Controls
Die ~880 BSI Rule-3 Controls werden **gegen** die neuen Rule 1+2 Controls abgeglichen:
- Wenn ein BSI-Control ein Duplikat eines OWASP/NIST-Controls ist → **OWASP/NIST bevorzugt**
(weil Originaltext + Zitation erlaubt)
- BSI-Duplikate werden als \`deprecated\` markiert
- Tags und Anchors werden in den behaltenen Control zusammengefuehrt
### Schritt 3: Aktueller Stand
Aktuell: **~3.100+ Controls** (Stand Maerz 2026), davon:
- Viele mit \`source_original_text\` (Originaltext fuer Kunden sichtbar)
- Viele mit \`source_citation\` (Quellenangabe mit Lizenz)
- Klare Nachweismethode (\`verification_method\`)
- Thematische Kategorie (\`category\`)
### Verstaendliche Texte
Zusaetzlich zum Originaltext (der oft juristisch/technisch formuliert ist)
enthaelt jedes Control ein eigenstaendig formuliertes **Ziel** (objective)
und eine **Begruendung** (rationale) in verstaendlicher Sprache.`,
},
{
id: 'validation',
title: 'Automatisierte Validierung',
content: `## CI/CD-Pruefungen
Jedes Control wird bei jedem Commit automatisch geprueft:
### 1. Schema-Validierung
- Alle Pflichtfelder vorhanden
- Control-ID Format: \`^[A-Z]{2,6}-[0-9]{3}$\`
- Severity: low, medium, high, critical
- Risk Score: 0-10
### 2. No-Leak Scanner
Regex-Pruefung gegen verbotene Muster in produktfaehigen Feldern:
- \`O.[A-Za-z]+_[0-9]+\` — BSI Objective-IDs
- \`TR-03161\` — Direkte BSI-TR-Referenzen
- \`BSI-TR-\` — BSI-spezifische Locators
- \`Anforderung [A-Z].[0-9]+\` — BSI-Anforderungsformat
### 3. Open Anchor Check
Jedes freigegebene Control muss mindestens 1 Open-Source-Referenz haben.
### 4. Too-Close Detektor (5 Metriken)
| Metrik | Warn | Fail | Beschreibung |
|--------|------|------|-------------|
| Exact Phrase | ≥8 Tokens | ≥12 Tokens | Laengste identische Token-Sequenz |
| Token Overlap | ≥0.20 | ≥0.30 | Jaccard-Aehnlichkeit der Token-Mengen |
| 3-Gram Jaccard | ≥0.10 | ≥0.18 | Zeichenketten-Aehnlichkeit |
| Embedding Cosine | ≥0.86 | ≥0.92 | Semantische Aehnlichkeit (bge-m3) |
| LCS Ratio | ≥0.35 | ≥0.50 | Longest Common Subsequence |
**Entscheidungslogik:**
- **PASS** — Kein Fail + max 1 Warn
- **WARN** — Max 2 Warn, kein Fail → Human Review erforderlich
- **FAIL** — Irgendein Fail → Blockiert, Umformulierung noetig`,
},
]

View File

@@ -1,452 +1,27 @@
'use client'
import { useState, useEffect } from 'react'
import {
Shield, BookOpen, ExternalLink, CheckCircle2, AlertTriangle,
Lock, Scale, FileText, Eye, ArrowLeft,
} from 'lucide-react'
import { Shield, FileText } from 'lucide-react'
import Link from 'next/link'
// =============================================================================
// TYPES
// =============================================================================
import { PROVENANCE_SECTIONS } from './_data/provenance-sections'
import { MarkdownRenderer } from './_components/MarkdownRenderer'
import { LicenseMatrix } from './_components/LicenseMatrix'
import { SourceRegistry } from './_components/SourceRegistry'
interface LicenseInfo {
license_id: string
name: string
terms_url: string | null
commercial_use: string
ai_training_restriction: string | null
tdm_allowed_under_44b: string | null
deletion_required: boolean
notes: string | null
license_id: string; name: string; terms_url: string | null; commercial_use: string
ai_training_restriction: string | null; tdm_allowed_under_44b: string | null
deletion_required: boolean; notes: string | null
}
interface SourceInfo {
source_id: string
title: string
publisher: string
url: string | null
version_label: string | null
language: string
license_id: string
license_name: string
commercial_use: string
allowed_analysis: boolean
allowed_store_excerpt: boolean
allowed_ship_embeddings: boolean
allowed_ship_in_product: boolean
vault_retention_days: number
vault_access_tier: string
source_id: string; title: string; publisher: string; url: string | null
version_label: string | null; language: string; license_id: string; license_name: string
commercial_use: string; allowed_analysis: boolean; allowed_store_excerpt: boolean
allowed_ship_embeddings: boolean; allowed_ship_in_product: boolean
vault_retention_days: number; vault_access_tier: string
}
// =============================================================================
// STATIC PROVENANCE DOCUMENTATION
// =============================================================================
const PROVENANCE_SECTIONS = [
{
id: 'methodology',
title: 'Methodik der Control-Erstellung',
content: `## Unabhaengige Formulierung
Alle Controls in der Canonical Control Library wurden **eigenstaendig formuliert** und folgen einer
**unabhaengigen Taxonomie**. Es werden keine proprietaeren Bezeichner, Nummern oder Strukturen
aus geschuetzten Quellen uebernommen.
### Dreistufiger Prozess
1. **Offene Recherche** — Identifikation von Security-Anforderungen aus oeffentlichen, frei zugaenglichen
Frameworks (OWASP, NIST, ENISA). Jede Anforderung wird aus mindestens 2 unabhaengigen offenen Quellen belegt.
2. **Eigenstaendige Formulierung** — Jedes Control wird mit eigener Sprache, eigener Struktur und eigener
Taxonomie (z.B. AUTH-001, NET-001) verfasst. Kein Copy-Paste, keine Paraphrase geschuetzter Texte.
3. **Too-Close-Pruefung** — Automatisierte Aehnlichkeitspruefung gegen Quelltexte mit 5 Metriken
(Token Overlap, N-Gram Jaccard, Embedding Cosine, LCS Ratio, Exact-Phrase). Nur Controls mit
Status PASS oder WARN (+ Human Review) werden freigegeben.
### Rechtliche Grundlage
- **UrhG §44b** — Text & Data Mining erlaubt fuer Analyse; Kopien werden danach geloescht
- **UrhG §23** — Hinreichender Abstand zum Originalwerk durch eigene Formulierung
- **BSI Nutzungsbedingungen** — Kommerzielle Nutzung nur mit Zustimmung; wir nutzen BSI-Dokumente
ausschliesslich als Analysegrundlage, nicht im Produkt`,
},
{
id: 'filters',
title: 'Filter in der Control Library',
content: `## Dropdown-Filter
Die Control Library bietet 7 Filter-Dropdowns, um die ueber 3.000 Controls effizient zu durchsuchen:
### Schweregrad (Severity)
| Stufe | Farbe | Bedeutung |
|-------|-------|-----------|
| **Kritisch** | Rot | Sicherheitskritische Controls — Verstoesse fuehren zu schwerwiegenden Risiken |
| **Hoch** | Orange | Wichtige Controls — sollten zeitnah umgesetzt werden |
| **Mittel** | Gelb | Standardmaessige Controls — empfohlene Umsetzung |
| **Niedrig** | Gruen | Nice-to-have Controls — zusaetzliche Haertung |
### Domain
Das Praefix der Control-ID (z.B. \`AUTH-001\`, \`SEC-042\`). Kennzeichnet den thematischen Bereich.
Die haeufigsten Domains:
| Domain | Anzahl | Thema |
|--------|--------|-------|
| SEC | ~700 | Allgemeine Sicherheit, Systemhaertung |
| COMP | ~470 | Compliance, Regulierung, Nachweispflichten |
| DATA | ~400 | Datenschutz, Datenklassifizierung, DSGVO |
| AI | ~290 | KI-Regulierung (AI Act, Transparenz, Erklaerbarkeit) |
| LOG | ~230 | Logging, Monitoring, SIEM |
| AUTH | ~200 | Authentifizierung, Zugriffskontrolle |
| NET | ~150 | Netzwerksicherheit, Transport, Firewall |
| CRYP | ~90 | Kryptographie, Schluesselmanagement |
| ACC | ~25 | Zugriffskontrolle (Access Control) |
| INC | ~25 | Incident Response, Vorfallmanagement |
Zusaetzlich existieren spezialisierte Domains wie CRA, ARC (Architektur), API, PKI, SUP (Supply Chain) u.v.m.
### Status (Release State)
| Status | Bedeutung |
|--------|-----------|
| **Draft** | Entwurf — noch nicht freigegeben |
| **Approved** | Freigegeben fuer Kunden |
| **Review noetig** | Muss manuell geprueft werden |
| **Zu aehnlich** | Too-Close-Check hat Warnung ausgeloest |
| **Duplikat** | Wurde als Duplikat eines anderen Controls erkannt |
### Nachweis (Verification Method)
| Methode | Farbe | Beschreibung |
|---------|-------|-------------|
| **Code Review** | Blau | Nachweis durch Quellcode-Inspektion |
| **Dokument** | Amber | Nachweis durch Richtlinien, Prozesse, Schulungen |
| **Tool** | Teal | Nachweis durch automatisierte Scans/Monitoring |
| **Hybrid** | Lila | Kombination aus mehreren Methoden |
### Kategorie
Thematische Einordnung (17 Kategorien). Kategorien sind **thematisch**, Domains **strukturell**.
Ein AUTH-Control kann z.B. die Kategorie "Netzwerksicherheit" haben.
### Zielgruppe (Target Audience)
| Zielgruppe | Bedeutung |
|------------|-----------|
| **Unternehmen** | Fuer Endkunden/Firmen relevant |
| **Behoerden** | Spezifisch fuer oeffentliche Verwaltung |
| **Anbieter** | Fuer SaaS/Plattform-Anbieter |
| **Alle** | Allgemein anwendbar |
### Dokumentenursprung (Source)
Filtert nach der Quelldokument-Herkunft des Controls. Zeigt alle Quellen sortiert nach
Haeufigkeit. Die wichtigsten Quellen:
| Quelle | Typ |
|--------|-----|
| KI-Verordnung (EU) 2024/1689 | EU-Recht |
| Cyber Resilience Act (EU) 2024/2847 | EU-Recht |
| DSGVO (EU) 2016/679 | EU-Recht |
| NIS2-Richtlinie (EU) 2022/2555 | EU-Recht |
| NIST SP 800-53, CSF 2.0, SSDF | US-Standards |
| OWASP Top 10, ASVS, SAMM | Open Source |
| ENISA Guidelines | EU-Agentur |
| CISA Secure by Design | US-Behoerde |
| BDSG, TKG, GewO, HGB | Deutsche Gesetze |
| EDPB Leitlinien | EU Datenschutz |`,
},
{
id: 'badges',
title: 'Badges & Lizenzregeln',
content: `## Badges in der Control Library
Jedes Control zeigt mehrere farbige Badges:
### Lizenzregel-Badge (Rule 1 / 2 / 3)
Die Lizenzregel bestimmt, wie ein Control erstellt und genutzt werden darf:
| Badge | Farbe | Regel | Bedeutung |
|-------|-------|-------|-----------|
| **Free Use** | Gruen | Rule 1 | Quelle ist Public Domain oder EU-Recht — Originaltext darf gezeigt werden |
| **Zitation** | Blau | Rule 2 | Quelle ist CC-BY oder aehnlich — Zitation + Quellenangabe erforderlich |
| **Reformuliert** | Amber | Rule 3 | Quelle hat eingeschraenkte Lizenz — Control wurde eigenstaendig reformuliert, kein Originaltext |
### Processing-Path
| Pfad | Bedeutung |
|------|-----------|
| **structured** | Control wurde direkt aus strukturierten Daten (Tabellen, Listen) extrahiert |
| **llm_reform** | Control wurde mit LLM eigenstaendig formuliert (bei Rule 3 zwingend) |
### Referenzen (Open Anchors)
Zeigt die Anzahl der verlinkten Open-Source-Referenzen (OWASP, NIST, ENISA etc.).
Jedes freigegebene Control muss mindestens 1 Open Anchor haben.
### Weitere Badges
| Badge | Bedeutung |
|-------|-----------|
| Score | Risiko-Score (0-10) |
| Severity-Badge | Schweregrad (Kritisch/Hoch/Mittel/Niedrig) |
| State-Badge | Freigabestatus (Draft/Approved/etc.) |
| Kategorie-Badge | Thematische Kategorie |
| Zielgruppe-Badge | Enterprise/Behoerden/Anbieter/Alle |`,
},
{
id: 'taxonomy',
title: 'Unabhaengige Taxonomie',
content: `## Eigenes Klassifikationssystem
Die Canonical Control Library verwendet ein **eigenes Domain-Schema**, das sich bewusst von
proprietaeren Frameworks unterscheidet. Die Domains werden **automatisch** durch den
Control Generator vergeben, basierend auf dem Inhalt der Quelldokumente.
### Top-10 Domains
| Domain | Anzahl | Thema | Hauptquellen |
|--------|--------|-------|-------------|
| SEC | ~700 | Allgemeine Sicherheit | CRA, NIS2, BSI, ENISA |
| COMP | ~470 | Compliance & Regulierung | DSGVO, AI Act, Richtlinien |
| DATA | ~400 | Datenschutz & Datenklassifizierung | DSGVO, BDSG, EDPB |
| AI | ~290 | KI-Regulierung & Ethik | AI Act, HLEG, OECD |
| LOG | ~230 | Logging & Monitoring | NIST, OWASP |
| AUTH | ~200 | Authentifizierung & Session | NIST SP 800-63, OWASP |
| NET | ~150 | Netzwerksicherheit | NIST, ENISA |
| CRYP | ~90 | Kryptographie & Schluessel | NIST SP 800-57 |
| ACC | ~25 | Zugriffskontrolle | OWASP ASVS |
| INC | ~25 | Incident Response | NIS2, CRA |
### Spezialisierte Domains
Neben den Top-10 gibt es ueber 90 weitere Domains fuer spezifische Themen:
- **CRA** — Cyber Resilience Act spezifisch
- **ARC** — Sichere Architektur
- **API** — API-Security
- **PKI** — Public Key Infrastructure
- **SUP** — Supply Chain Security
- **VUL** — Vulnerability Management
- **BCP** — Business Continuity
- **PHY** — Physische Sicherheit
- u.v.m.
### ID-Format
Control-IDs folgen dem Muster \`DOMAIN-NNN\` (z.B. AUTH-001, SEC-042). Dieses Format ist
**nicht von BSI oder anderen proprietaeren Standards abgeleitet**, sondern folgt einem
allgemein ueblichen Nummerierungsschema.`,
},
{
id: 'open-sources',
title: 'Offene Referenzquellen',
content: `## Primaere offene Quellen
Alle Controls sind in mindestens einer der folgenden **frei zugaenglichen** Quellen verankert:
### OWASP (CC BY-SA 4.0 — kommerziell erlaubt)
- **ASVS** — Application Security Verification Standard v4.0.3
- **MASVS** — Mobile Application Security Verification Standard v2.1
- **Top 10** — OWASP Top 10 (2021)
- **Cheat Sheets** — OWASP Cheat Sheet Series
- **SAMM** — Software Assurance Maturity Model
### NIST (Public Domain — keine Einschraenkungen)
- **SP 800-53 Rev.5** — Security and Privacy Controls
- **SP 800-63B** — Digital Identity Guidelines (Authentication)
- **SP 800-57** — Key Management Recommendations
- **SP 800-52 Rev.2** — TLS Implementation Guidelines
- **SP 800-92** — Log Management Guide
- **SP 800-218 (SSDF)** — Secure Software Development Framework
- **SP 800-60** — Information Types to Security Categories
### ENISA (CC BY 4.0 — kommerziell erlaubt)
- Good Practices for IoT/Mobile Security
- Data Protection Engineering
- Algorithms, Key Sizes and Parameters Report
### Weitere offene Quellen
- **SLSA** (Supply-chain Levels for Software Artifacts) — Google Open Source
- **CIS Controls v8** (CC BY-NC-ND — nur fuer interne Analyse)`,
},
{
id: 'restricted-sources',
title: 'Geschuetzte Quellen — Nur interne Analyse',
content: `## Quellen mit eingeschraenkter Nutzung
Die folgenden Quellen werden **ausschliesslich intern zur Analyse** verwendet.
Kein Text, keine Struktur, keine Bezeichner aus diesen Quellen erscheinen im Produkt.
### BSI (Nutzungsbedingungen — kommerziell eingeschraenkt)
- TR-03161 Teil 1-3 (Mobile, Web, Hintergrunddienste)
- Nutzung: TDM unter UrhG §44b, Kopien werden geloescht
- Kein Shipping von Zitaten, Embeddings oder Strukturen
### ISO/IEC (Kostenpflichtig — kein Shipping)
- ISO 27001, ISO 27002
- Nutzung: Nur als Referenz fuer Mapping, kein Text im Produkt
### ETSI (Restriktiv — kein kommerzieller Gebrauch)
- Nutzung: Nur als Hintergrundwissen, kein direkter Einfluss
### Trennungsprinzip
| Ebene | Geschuetzte Quelle | Offene Quelle |
|-------|--------------------|---------------|
| Analyse | ✅ Darf gelesen werden | ✅ Darf gelesen werden |
| Inspiration | ✅ Darf Ideen liefern | ✅ Darf Ideen liefern |
| Formulierung | ❌ Keine Uebernahme | ✅ Darf zitiert werden |
| Struktur | ❌ Keine Uebernahme | ✅ Darf verwendet werden |
| Produkttext | ❌ Nicht erlaubt | ✅ Erlaubt |`,
},
{
id: 'verification-methods',
title: 'Verifikationsmethoden',
content: `## Nachweis-Klassifizierung
Jedes Control wird einer von vier Verifikationsmethoden zugeordnet. Dies bestimmt,
**wie** ein Kunde den Nachweis fuer die Einhaltung erbringen kann:
| Methode | Beschreibung | Beispiele |
|---------|-------------|-----------|
| **Code Review** | Nachweis durch Quellcode-Inspektion | Input-Validierung, Encryption-Konfiguration, Auth-Logic |
| **Dokument** | Nachweis durch Richtlinien, Prozesse, Schulungen | Notfallplaene, Schulungsnachweise, Datenschutzkonzepte |
| **Tool** | Nachweis durch automatisierte Tools/Scans | SIEM-Logs, Vulnerability-Scans, Monitoring-Dashboards |
| **Hybrid** | Kombination aus mehreren Methoden | Zugriffskontrollen (Code + Policy + Tool) |
### Bedeutung fuer Kunden
- **Code Review Controls** koennen direkt im SDK-Scan geprueft werden
- **Dokument Controls** erfordern manuelle Uploads (PDFs, Links)
- **Tool Controls** koennen per API-Integration automatisch nachgewiesen werden
- **Hybrid Controls** benoetigen mehrere Nachweisarten`,
},
{
id: 'categories',
title: 'Thematische Kategorien',
content: `## 17 Sicherheitskategorien
Controls sind in thematische Kategorien gruppiert, um Kunden eine
uebersichtliche Navigation zu ermoeglichen:
| Kategorie | Beschreibung |
|-----------|-------------|
| Verschluesselung & Kryptographie | TLS, Key Management, Algorithmen |
| Authentisierung & Zugriffskontrolle | Login, MFA, RBAC, Session-Management |
| Netzwerksicherheit | Firewall, Segmentierung, VPN, DNS |
| Datenschutz & Datensicherheit | DSGVO, Datenklassifizierung, Anonymisierung |
| Logging & Monitoring | SIEM, Audit-Logs, Alerting |
| Vorfallmanagement | Incident Response, Meldepflichten |
| Notfall & Wiederherstellung | BCM, Disaster Recovery, Backups |
| Compliance & Audit | Zertifizierungen, Audits, Berichtspflichten |
| Lieferkettenmanagement | Vendor Risk, SBOM, Third-Party |
| Physische Sicherheit | Zutritt, Gebaeudesicherheit |
| Personal & Schulung | Security Awareness, Rollenkonzepte |
| Anwendungssicherheit | SAST, DAST, Secure Coding |
| Systemhaertung & -betrieb | Patching, Konfiguration, Hardening |
| Risikomanagement | Risikoanalyse, Bewertung, Massnahmen |
| Sicherheitsorganisation | ISMS, Richtlinien, Governance |
| Hardware & Plattformsicherheit | TPM, Secure Boot, Firmware |
| Identitaetsmanagement | SSO, Federation, Directory |
### Abgrenzung zu Domains
Kategorien sind **thematisch**, Domains (AUTH, NET, etc.) sind **strukturell**.
Ein Control AUTH-005 (Domain AUTH) hat die Kategorie "authentication",
aber ein Control NET-012 (Domain NET) koennte ebenfalls die Kategorie
"authentication" haben, wenn es um Netzwerk-Authentifizierung geht.`,
},
{
id: 'master-library',
title: 'Master Library Strategie',
content: `## RAG-First Ansatz
Die Canonical Control Library folgt einer **RAG-First-Strategie**:
### Schritt 1: Rule 1+2 Controls aus RAG generieren
Prioritaet haben Controls aus Quellen mit **Originaltext-Erlaubnis**:
| Welle | Quellen | Lizenzregel | Vorteil |
|-------|---------|------------|---------|
| 1 | OWASP (ASVS, MASVS, Top10) | Rule 2 (CC-BY-SA, Zitation) | Originaltext + Zitation |
| 2 | NIST (SP 800-53, CSF, SSDF) | Rule 1 (Public Domain) | Voller Text, keine Einschraenkungen |
| 3 | EU-Verordnungen (DSGVO, AI Act, NIS2, CRA) | Rule 1 (EU Law) | Gesetzestext + Erklaerung |
| 4 | Deutsche Gesetze (BDSG, TTDSG, TKG) | Rule 1 (DE Law) | Gesetzestext + Erklaerung |
### Schritt 2: Dedup gegen BSI Rule-3 Controls
Die ~880 BSI Rule-3 Controls werden **gegen** die neuen Rule 1+2 Controls abgeglichen:
- Wenn ein BSI-Control ein Duplikat eines OWASP/NIST-Controls ist → **OWASP/NIST bevorzugt**
(weil Originaltext + Zitation erlaubt)
- BSI-Duplikate werden als \`deprecated\` markiert
- Tags und Anchors werden in den behaltenen Control zusammengefuehrt
### Schritt 3: Aktueller Stand
Aktuell: **~3.100+ Controls** (Stand Maerz 2026), davon:
- Viele mit \`source_original_text\` (Originaltext fuer Kunden sichtbar)
- Viele mit \`source_citation\` (Quellenangabe mit Lizenz)
- Klare Nachweismethode (\`verification_method\`)
- Thematische Kategorie (\`category\`)
### Verstaendliche Texte
Zusaetzlich zum Originaltext (der oft juristisch/technisch formuliert ist)
enthaelt jedes Control ein eigenstaendig formuliertes **Ziel** (objective)
und eine **Begruendung** (rationale) in verstaendlicher Sprache.`,
},
{
id: 'validation',
title: 'Automatisierte Validierung',
content: `## CI/CD-Pruefungen
Jedes Control wird bei jedem Commit automatisch geprueft:
### 1. Schema-Validierung
- Alle Pflichtfelder vorhanden
- Control-ID Format: \`^[A-Z]{2,6}-[0-9]{3}$\`
- Severity: low, medium, high, critical
- Risk Score: 0-10
### 2. No-Leak Scanner
Regex-Pruefung gegen verbotene Muster in produktfaehigen Feldern:
- \`O.[A-Za-z]+_[0-9]+\` — BSI Objective-IDs
- \`TR-03161\` — Direkte BSI-TR-Referenzen
- \`BSI-TR-\` — BSI-spezifische Locators
- \`Anforderung [A-Z].[0-9]+\` — BSI-Anforderungsformat
### 3. Open Anchor Check
Jedes freigegebene Control muss mindestens 1 Open-Source-Referenz haben.
### 4. Too-Close Detektor (5 Metriken)
| Metrik | Warn | Fail | Beschreibung |
|--------|------|------|-------------|
| Exact Phrase | ≥8 Tokens | ≥12 Tokens | Laengste identische Token-Sequenz |
| Token Overlap | ≥0.20 | ≥0.30 | Jaccard-Aehnlichkeit der Token-Mengen |
| 3-Gram Jaccard | ≥0.10 | ≥0.18 | Zeichenketten-Aehnlichkeit |
| Embedding Cosine | ≥0.86 | ≥0.92 | Semantische Aehnlichkeit (bge-m3) |
| LCS Ratio | ≥0.35 | ≥0.50 | Longest Common Subsequence |
**Entscheidungslogik:**
- **PASS** — Kein Fail + max 1 Warn
- **WARN** — Max 2 Warn, kein Fail → Human Review erforderlich
- **FAIL** — Irgendein Fail → Blockiert, Umformulierung noetig`,
},
]
// =============================================================================
// PAGE
// =============================================================================
export default function ControlProvenancePage() {
const [licenses, setLicenses] = useState<LicenseInfo[]>([])
const [sources, setSources] = useState<SourceInfo[]>([])
@@ -475,7 +50,6 @@ export default function ControlProvenancePage() {
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b border-gray-200 bg-white px-6 py-4">
<div className="flex items-center gap-3">
<FileText className="w-6 h-6 text-green-600" />
@@ -485,10 +59,7 @@ export default function ControlProvenancePage() {
Dokumentation der unabhaengigen Herkunft aller Security Controls rechtssicherer Nachweis
</p>
</div>
<Link
href="/sdk/control-library"
className="ml-auto flex items-center gap-1 text-sm text-purple-600 hover:text-purple-800"
>
<Link href="/sdk/control-library" className="ml-auto flex items-center gap-1 text-sm text-purple-600 hover:text-purple-800">
<Shield className="w-4 h-4" />
Zur Control Library
</Link>
@@ -513,29 +84,19 @@ export default function ControlProvenancePage() {
{section.title}
</button>
))}
<div className="border-t border-gray-200 mt-3 pt-3">
<p className="text-xs font-semibold text-gray-400 uppercase px-3 mb-2">Live-Daten</p>
<button
onClick={() => setActiveSection('license-matrix')}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
activeSection === 'license-matrix'
? 'bg-green-100 text-green-900 font-medium'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
Lizenz-Matrix
</button>
<button
onClick={() => setActiveSection('source-registry')}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
activeSection === 'source-registry'
? 'bg-green-100 text-green-900 font-medium'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
Quellenregister
</button>
{['license-matrix', 'source-registry'].map(id => (
<button
key={id}
onClick={() => setActiveSection(id)}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
activeSection === id ? 'bg-green-100 text-green-900 font-medium' : 'text-gray-700 hover:bg-gray-100'
}`}
>
{id === 'license-matrix' ? 'Lizenz-Matrix' : 'Quellenregister'}
</button>
))}
</div>
</div>
</div>
@@ -543,7 +104,6 @@ export default function ControlProvenancePage() {
{/* Right: Content */}
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-3xl mx-auto">
{/* Static documentation sections */}
{currentSection && (
<div>
<h2 className="text-xl font-bold text-gray-900 mb-4">{currentSection.title}</h2>
@@ -552,188 +112,11 @@ export default function ControlProvenancePage() {
</div>
</div>
)}
{/* License Matrix (live data) */}
{activeSection === 'license-matrix' && (
<div>
<h2 className="text-xl font-bold text-gray-900 mb-4">Lizenz-Matrix</h2>
<p className="text-sm text-gray-600 mb-4">
Uebersicht aller Lizenzen mit ihren erlaubten Nutzungsarten.
</p>
{loading ? (
<div className="animate-pulse h-32 bg-gray-100 rounded" />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-50">
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Lizenz</th>
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Kommerziell</th>
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">AI-Training</th>
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">TDM (§44b)</th>
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Loeschpflicht</th>
</tr>
</thead>
<tbody>
{licenses.map(lic => (
<tr key={lic.license_id} className="hover:bg-gray-50">
<td className="px-3 py-2 border-b">
<div className="font-medium text-gray-900">{lic.license_id}</div>
<div className="text-xs text-gray-500">{lic.name}</div>
</td>
<td className="px-3 py-2 border-b">
<UsageBadge value={lic.commercial_use} />
</td>
<td className="px-3 py-2 border-b">
<UsageBadge value={lic.ai_training_restriction || 'n/a'} />
</td>
<td className="px-3 py-2 border-b">
<UsageBadge value={lic.tdm_allowed_under_44b || 'unclear'} />
</td>
<td className="px-3 py-2 border-b">
{lic.deletion_required ? (
<span className="text-red-600 text-xs font-medium">Ja</span>
) : (
<span className="text-green-600 text-xs font-medium">Nein</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Source Registry (live data) */}
{activeSection === 'source-registry' && (
<div>
<h2 className="text-xl font-bold text-gray-900 mb-4">Quellenregister</h2>
<p className="text-sm text-gray-600 mb-4">
Alle registrierten Quellen mit ihren Berechtigungen.
</p>
{loading ? (
<div className="animate-pulse h-32 bg-gray-100 rounded" />
) : (
<div className="space-y-3">
{sources.map(src => (
<div key={src.source_id} className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="text-sm font-medium text-gray-900">{src.title}</h3>
<p className="text-xs text-gray-500">{src.publisher} {src.license_name}</p>
</div>
{src.url && (
<a
href={src.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800"
>
<ExternalLink className="w-3 h-3" />
Quelle
</a>
)}
</div>
<div className="flex items-center gap-3 mt-2">
<PermBadge label="Analyse" allowed={src.allowed_analysis} />
<PermBadge label="Excerpt" allowed={src.allowed_store_excerpt} />
<PermBadge label="Embeddings" allowed={src.allowed_ship_embeddings} />
<PermBadge label="Produkt" allowed={src.allowed_ship_in_product} />
</div>
</div>
))}
</div>
)}
</div>
)}
{activeSection === 'license-matrix' && <LicenseMatrix licenses={licenses} loading={loading} />}
{activeSection === 'source-registry' && <SourceRegistry sources={sources} loading={loading} />}
</div>
</div>
</div>
</div>
)
}
// =============================================================================
// HELPER COMPONENTS
// =============================================================================
function UsageBadge({ value }: { value: string }) {
const config: Record<string, { bg: string; label: string }> = {
allowed: { bg: 'bg-green-100 text-green-800', label: 'Erlaubt' },
restricted: { bg: 'bg-yellow-100 text-yellow-800', label: 'Eingeschraenkt' },
prohibited: { bg: 'bg-red-100 text-red-800', label: 'Verboten' },
unclear: { bg: 'bg-gray-100 text-gray-600', label: 'Unklar' },
yes: { bg: 'bg-green-100 text-green-800', label: 'Ja' },
no: { bg: 'bg-red-100 text-red-800', label: 'Nein' },
'n/a': { bg: 'bg-gray-100 text-gray-400', label: 'k.A.' },
}
const c = config[value] || config.unclear
return <span className={`inline-flex px-1.5 py-0.5 rounded text-xs font-medium ${c.bg}`}>{c.label}</span>
}
function PermBadge({ label, allowed }: { label: string; allowed: boolean }) {
return (
<div className="flex items-center gap-1">
{allowed ? (
<CheckCircle2 className="w-3 h-3 text-green-500" />
) : (
<Lock className="w-3 h-3 text-red-400" />
)}
<span className={`text-xs ${allowed ? 'text-green-700' : 'text-red-500'}`}>{label}</span>
</div>
)
}
function MarkdownRenderer({ content }: { content: string }) {
let html = content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Code blocks
html = html.replace(
/^```[\w]*\n([\s\S]*?)^```$/gm,
(_m, code: string) => `<pre class="bg-gray-50 border rounded p-3 my-3 text-xs font-mono overflow-x-auto whitespace-pre">${code.trimEnd()}</pre>`
)
// Tables
html = html.replace(
/^(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)*)/gm,
(_m, header: string, _sep: string, body: string) => {
const ths = header.split('|').filter((c: string) => c.trim()).map((c: string) =>
`<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase border-b">${c.trim()}</th>`
).join('')
const rows = body.trim().split('\n').map((row: string) => {
const tds = row.split('|').filter((c: string) => c.trim()).map((c: string) =>
`<td class="px-3 py-2 text-sm text-gray-700 border-b border-gray-100">${c.trim()}</td>`
).join('')
return `<tr>${tds}</tr>`
}).join('')
return `<table class="w-full border-collapse my-3 text-sm"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`
}
)
// Headers
html = html.replace(/^### (.+)$/gm, '<h4 class="text-sm font-semibold text-gray-800 mt-4 mb-2">$1</h4>')
html = html.replace(/^## (.+)$/gm, '<h3 class="text-base font-semibold text-gray-900 mt-5 mb-2">$1</h3>')
// Bold
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// Inline code
html = html.replace(/`([^`]+)`/g, '<code class="bg-gray-100 px-1 py-0.5 rounded text-xs font-mono">$1</code>')
// Lists
html = html.replace(/^- (.+)$/gm, '<li class="ml-4 text-sm text-gray-700 list-disc">$1</li>')
html = html.replace(/((?:<li[^>]*>.*<\/li>\n?)+)/g, '<ul class="my-2 space-y-1">$1</ul>')
// Numbered lists
html = html.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 text-sm text-gray-700 list-decimal">$2</li>')
// Paragraphs
html = html.replace(/^(?!<[hultdp]|$)(.+)$/gm, '<p class="text-sm text-gray-700 my-2">$1</p>')
return <div dangerouslySetInnerHTML={{ __html: html }} />
}

View File

@@ -0,0 +1,104 @@
'use client'
import { useState } from 'react'
import type { ControlType } from '@/lib/sdk'
interface FormData {
name: string
description: string
type: ControlType
category: string
owner: string
}
export function AddControlForm({
onSubmit,
onCancel,
}: {
onSubmit: (data: FormData) => void
onCancel: () => void
}) {
const [formData, setFormData] = useState<FormData>({
name: '',
description: '',
type: 'TECHNICAL',
category: '',
owner: '',
})
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Neue Kontrolle</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
<input
type="text"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. Zugriffskontrolle"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
placeholder="Beschreiben Sie die Kontrolle..."
rows={2}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Typ</label>
<select
value={formData.type}
onChange={e => setFormData({ ...formData, type: e.target.value as ControlType })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="TECHNICAL">Technisch</option>
<option value="ORGANIZATIONAL">Organisatorisch</option>
<option value="PHYSICAL">Physisch</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
<input
type="text"
value={formData.category}
onChange={e => setFormData({ ...formData, category: e.target.value })}
placeholder="z.B. Zutrittskontrolle"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verantwortlich</label>
<input
type="text"
value={formData.owner}
onChange={e => setFormData({ ...formData, owner: e.target.value })}
placeholder="z.B. IT Security"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
</div>
</div>
<div className="mt-6 flex items-center justify-end gap-3">
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
<button
onClick={() => onSubmit(formData)}
disabled={!formData.name}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
formData.name ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Hinzufuegen
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,163 @@
'use client'
import { useState } from 'react'
import type { DisplayControl, DisplayControlType, DisplayCategory, DisplayStatus } from '../_types'
import type { ImplementationStatus } from '@/lib/sdk'
const TYPE_COLORS: Record<DisplayControlType, string> = {
preventive: 'bg-blue-100 text-blue-700',
detective: 'bg-purple-100 text-purple-700',
corrective: 'bg-orange-100 text-orange-700',
}
const CATEGORY_COLORS: Record<DisplayCategory, string> = {
technical: 'bg-green-100 text-green-700',
organizational: 'bg-yellow-100 text-yellow-700',
physical: 'bg-gray-100 text-gray-700',
}
const STATUS_COLORS: Record<DisplayStatus, string> = {
implemented: 'border-green-200 bg-green-50',
partial: 'border-yellow-200 bg-yellow-50',
planned: 'border-blue-200 bg-blue-50',
'not-implemented': 'border-red-200 bg-red-50',
}
const STATUS_LABELS: Record<DisplayStatus, string> = {
implemented: 'Implementiert',
partial: 'Teilweise',
planned: 'Geplant',
'not-implemented': 'Nicht implementiert',
}
export function ControlCard({
control,
onStatusChange,
onEffectivenessChange,
onLinkEvidence,
}: {
control: DisplayControl
onStatusChange: (status: ImplementationStatus) => void
onEffectivenessChange: (effectivenessPercent: number) => void
onLinkEvidence: () => void
}) {
const [showEffectivenessSlider, setShowEffectivenessSlider] = useState(false)
return (
<div className={`bg-white rounded-xl border-2 p-6 ${STATUS_COLORS[control.displayStatus]}`}>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded font-mono">{control.code}</span>
<span className={`px-2 py-1 text-xs rounded-full ${TYPE_COLORS[control.displayType]}`}>
{control.displayType === 'preventive' ? 'Praeventiv' :
control.displayType === 'detective' ? 'Detektiv' : 'Korrektiv'}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${CATEGORY_COLORS[control.displayCategory]}`}>
{control.displayCategory === 'technical' ? 'Technisch' :
control.displayCategory === 'organizational' ? 'Organisatorisch' : 'Physisch'}
</span>
</div>
<h3 className="text-lg font-semibold text-gray-900">{control.name}</h3>
<p className="text-sm text-gray-500 mt-1">{control.description}</p>
</div>
<select
value={control.implementationStatus}
onChange={(e) => onStatusChange(e.target.value as ImplementationStatus)}
className={`px-3 py-1 text-sm rounded-full border ${STATUS_COLORS[control.displayStatus]}`}
>
<option value="NOT_IMPLEMENTED">Nicht implementiert</option>
<option value="PARTIAL">Teilweise</option>
<option value="IMPLEMENTED">Implementiert</option>
</select>
</div>
<div className="mt-4">
<div
className="flex items-center justify-between text-sm mb-1 cursor-pointer"
onClick={() => setShowEffectivenessSlider(!showEffectivenessSlider)}
>
<span className="text-gray-500">Wirksamkeit</span>
<span className="font-medium">{control.effectivenessPercent}%</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
control.effectivenessPercent >= 80 ? 'bg-green-500' :
control.effectivenessPercent >= 50 ? 'bg-yellow-500' : 'bg-red-500'
}`}
style={{ width: `${control.effectivenessPercent}%` }}
/>
</div>
{showEffectivenessSlider && (
<div className="mt-2">
<input
type="range" min={0} max={100} value={control.effectivenessPercent}
onChange={(e) => onEffectivenessChange(Number(e.target.value))}
className="w-full"
/>
</div>
)}
</div>
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
<div className="text-gray-500">
<span>Verantwortlich: </span>
<span className="font-medium text-gray-700">{control.owner || 'Nicht zugewiesen'}</span>
</div>
<div className="text-gray-500">Letzte Pruefung: {control.lastReview.toLocaleDateString('de-DE')}</div>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-1 flex-wrap">
{control.linkedRequirements.slice(0, 3).map(req => (
<span key={req} className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">{req}</span>
))}
{control.linkedRequirements.length > 3 && (
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">+{control.linkedRequirements.length - 3}</span>
)}
</div>
<span className={`px-3 py-1 text-xs rounded-full ${
control.displayStatus === 'implemented' ? 'bg-green-100 text-green-700' :
control.displayStatus === 'partial' ? 'bg-yellow-100 text-yellow-700' :
control.displayStatus === 'planned' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'
}`}>
{STATUS_LABELS[control.displayStatus]}
</span>
</div>
{control.linkedEvidence.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-100">
<span className="text-xs text-gray-500 mb-1 block">
Nachweise: {control.linkedEvidence.length}
{(() => {
const e2plus = control.linkedEvidence.filter((ev: { confidenceLevel?: string }) =>
ev.confidenceLevel && ['E2', 'E3', 'E4'].includes(ev.confidenceLevel)
).length
return e2plus > 0 ? ` (${e2plus} E2+)` : ''
})()}
</span>
<div className="flex items-center gap-1 flex-wrap">
{control.linkedEvidence.map(ev => (
<span key={ev.id} className={`px-2 py-0.5 text-xs rounded ${
ev.status === 'valid' ? 'bg-green-50 text-green-700' :
ev.status === 'expired' ? 'bg-red-50 text-red-700' : 'bg-yellow-50 text-yellow-700'
}`}>
{ev.title}
{(ev as { confidenceLevel?: string }).confidenceLevel && (
<span className="ml-1 opacity-70">({(ev as { confidenceLevel?: string }).confidenceLevel})</span>
)}
</span>
))}
</div>
</div>
)}
<div className="mt-3 pt-3 border-t border-gray-100">
<button onClick={onLinkEvidence} className="text-sm text-purple-600 hover:text-purple-700 font-medium">
Evidence verknuepfen
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
const FILTERS = ['all', 'implemented', 'partial', 'not-implemented', 'technical', 'organizational', 'preventive', 'detective']
const FILTER_LABELS: Record<string, string> = {
all: 'Alle',
implemented: 'Implementiert',
partial: 'Teilweise',
'not-implemented': 'Offen',
technical: 'Technisch',
organizational: 'Organisatorisch',
preventive: 'Praeventiv',
detective: 'Detektiv',
}
export function FilterBar({
filter,
onFilterChange,
}: {
filter: string
onFilterChange: (f: string) => void
}) {
return (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-gray-500">Filter:</span>
{FILTERS.map(f => (
<button
key={f}
onClick={() => onFilterChange(f)}
className={`px-3 py-1 text-sm rounded-full transition-colors ${
filter === f ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{FILTER_LABELS[f]}
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,18 @@
export function LoadingSkeleton() {
return (
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
<div className="flex items-center gap-2 mb-3">
<div className="h-5 w-20 bg-gray-200 rounded" />
<div className="h-5 w-16 bg-gray-200 rounded-full" />
<div className="h-5 w-16 bg-gray-200 rounded-full" />
</div>
<div className="h-6 w-3/4 bg-gray-200 rounded mb-2" />
<div className="h-4 w-full bg-gray-100 rounded" />
<div className="mt-4 h-2 bg-gray-200 rounded-full" />
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,135 @@
'use client'
import type { RAGControlSuggestion } from '../_types'
export function RAGPanel({
selectedRequirementId,
onSelectedRequirementIdChange,
requirements,
onSuggestControls,
ragLoading,
ragSuggestions,
onAddSuggestion,
onClose,
}: {
selectedRequirementId: string
onSelectedRequirementIdChange: (id: string) => void
requirements: { id: string; title?: string }[]
onSuggestControls: () => void
ragLoading: boolean
ragSuggestions: RAGControlSuggestion[]
onAddSuggestion: (s: RAGControlSuggestion) => void
onClose: () => void
}) {
return (
<div className="bg-purple-50 border border-purple-200 rounded-xl p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-purple-900">KI-Controls aus RAG vorschlagen</h3>
<p className="text-sm text-purple-700 mt-1">
Geben Sie eine Anforderungs-ID ein. Das KI-System analysiert die Anforderung mit Hilfe des RAG-Corpus
und schlaegt passende Controls vor.
</p>
</div>
<button onClick={onClose} className="text-purple-400 hover:text-purple-600 ml-4">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex items-center gap-3 mb-4">
<input
type="text"
value={selectedRequirementId}
onChange={e => onSelectedRequirementIdChange(e.target.value)}
placeholder="Anforderungs-UUID eingeben..."
className="flex-1 px-4 py-2 border border-purple-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent bg-white"
/>
{requirements.length > 0 && (
<select
value={selectedRequirementId}
onChange={e => onSelectedRequirementIdChange(e.target.value)}
className="px-3 py-2 border border-purple-300 rounded-lg bg-white text-sm focus:ring-2 focus:ring-purple-500"
>
<option value="">Aus Liste waehlen...</option>
{requirements.slice(0, 20).map(r => (
<option key={r.id} value={r.id}>{r.id.substring(0, 8)}... {r.title?.substring(0, 40)}</option>
))}
</select>
)}
<button
onClick={onSuggestControls}
disabled={ragLoading || !selectedRequirementId}
className={`flex items-center gap-2 px-5 py-2 rounded-lg font-medium transition-colors ${
ragLoading || !selectedRequirementId
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-purple-600 text-white hover:bg-purple-700'
}`}
>
{ragLoading ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Analysiere...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Vorschlaege generieren
</>
)}
</button>
</div>
{ragSuggestions.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-semibold text-purple-800">{ragSuggestions.length} Vorschlaege gefunden:</h4>
{ragSuggestions.map((suggestion) => (
<div key={suggestion.control_id} className="bg-white border border-purple-200 rounded-lg p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded font-mono">
{suggestion.control_id}
</span>
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">{suggestion.domain}</span>
<span className="text-xs text-gray-500">Konfidenz: {Math.round(suggestion.confidence_score * 100)}%</span>
</div>
<h5 className="font-semibold text-gray-900">{suggestion.title}</h5>
<p className="text-sm text-gray-600 mt-1">{suggestion.description}</p>
{suggestion.pass_criteria && (
<p className="text-xs text-gray-500 mt-1">
<span className="font-medium">Erfolgskriterium:</span> {suggestion.pass_criteria}
</p>
)}
{suggestion.is_automated && (
<span className="mt-1 inline-block px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded">
Automatisierbar {suggestion.automation_tool ? `(${suggestion.automation_tool})` : ''}
</span>
)}
</div>
<button
onClick={() => onAddSuggestion(suggestion)}
className="flex-shrink-0 flex items-center gap-1 px-3 py-1.5 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Hinzufuegen
</button>
</div>
</div>
))}
</div>
)}
{!ragLoading && ragSuggestions.length === 0 && selectedRequirementId && (
<p className="text-sm text-purple-600 italic">
Klicken Sie auf &quot;Vorschlaege generieren&quot;, um KI-Controls abzurufen.
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,32 @@
export function StatsCards({
total,
implementedCount,
avgEffectiveness,
partialCount,
}: {
total: number
implementedCount: number
avgEffectiveness: number
partialCount: number
}) {
return (
<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">Gesamt</div>
<div className="text-3xl font-bold text-gray-900">{total}</div>
</div>
<div className="bg-white rounded-xl border border-green-200 p-6">
<div className="text-sm text-green-600">Implementiert</div>
<div className="text-3xl font-bold text-green-600">{implementedCount}</div>
</div>
<div className="bg-white rounded-xl border border-purple-200 p-6">
<div className="text-sm text-purple-600">Durchschn. Wirksamkeit</div>
<div className="text-3xl font-bold text-purple-600">{avgEffectiveness}%</div>
</div>
<div className="bg-white rounded-xl border border-yellow-200 p-6">
<div className="text-sm text-yellow-600">Teilweise</div>
<div className="text-3xl font-bold text-yellow-600">{partialCount}</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,40 @@
export function TransitionErrorBanner({
controlId,
violations,
onDismiss,
}: {
controlId: string
violations: string[]
onDismiss: () => void
}) {
return (
<div className="p-4 bg-orange-50 border border-orange-200 rounded-lg">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-orange-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<h4 className="font-medium text-orange-800">Status-Transition blockiert ({controlId})</h4>
<ul className="mt-2 space-y-1">
{violations.map((v, i) => (
<li key={i} className="text-sm text-orange-700 flex items-start gap-2">
<span className="text-orange-400 mt-0.5"></span>
<span>{v}</span>
</li>
))}
</ul>
<a href="/sdk/evidence" className="mt-2 inline-block text-sm text-purple-600 hover:text-purple-700 font-medium">
Evidence hinzufuegen
</a>
</div>
</div>
<button onClick={onDismiss} className="text-orange-400 hover:text-orange-600 ml-4">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,197 @@
'use client'
import { useState, useEffect } from 'react'
import { useSDK, Control as SDKControl, ControlType, ImplementationStatus } from '@/lib/sdk'
import { mapControlTypeToDisplay, mapStatusToDisplay } from '../_types'
import type { DisplayControl, RAGControlSuggestion } from '../_types'
export function useControlsData() {
const { state, dispatch } = useSDK()
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [effectivenessMap, setEffectivenessMap] = useState<Record<string, number>>({})
const [evidenceMap, setEvidenceMap] = useState<Record<string, { id: string; title: string; status: string }[]>>({})
const [transitionError, setTransitionError] = useState<{ controlId: string; violations: string[] } | null>(null)
const fetchEvidenceForControls = async (_controlIds: string[]) => {
try {
const res = await fetch('/api/sdk/v1/compliance/evidence')
if (res.ok) {
const data = await res.json()
const allEvidence = data.evidence || data
if (Array.isArray(allEvidence)) {
const map: Record<string, { id: string; title: string; status: string; confidenceLevel?: string }[]> = {}
for (const ev of allEvidence) {
const ctrlId = ev.control_id || ''
if (!map[ctrlId]) map[ctrlId] = []
map[ctrlId].push({
id: ev.id,
title: ev.title || ev.name || 'Nachweis',
status: ev.status || 'pending',
confidenceLevel: ev.confidence_level || undefined,
})
}
setEvidenceMap(map)
}
}
} catch {
// Silently fail
}
}
useEffect(() => {
const fetchControls = async () => {
try {
setLoading(true)
const res = await fetch('/api/sdk/v1/compliance/controls')
if (res.ok) {
const data = await res.json()
const backendControls = data.controls || data
if (Array.isArray(backendControls) && backendControls.length > 0) {
const mapped: SDKControl[] = backendControls.map((c: Record<string, unknown>) => ({
id: (c.control_id || c.id) as string,
name: (c.name || c.title || '') as string,
description: (c.description || '') as string,
type: ((c.type || c.control_type || 'TECHNICAL') as string).toUpperCase() as ControlType,
category: (c.category || '') as string,
implementationStatus: ((c.implementation_status || c.status || 'NOT_IMPLEMENTED') as string).toUpperCase() as ImplementationStatus,
effectiveness: (c.effectiveness || 'LOW') as 'LOW' | 'MEDIUM' | 'HIGH',
evidence: (c.evidence || []) as string[],
owner: (c.owner || null) as string | null,
dueDate: c.due_date ? new Date(c.due_date as string) : null,
}))
dispatch({ type: 'SET_STATE', payload: { controls: mapped } })
setError(null)
fetchEvidenceForControls(mapped.map(c => c.id))
return
}
}
} catch {
// API not available — show empty state
} finally {
setLoading(false)
}
}
fetchControls()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const displayControls: DisplayControl[] = state.controls.map(ctrl => {
const effectivenessPercent = effectivenessMap[ctrl.id] ??
(ctrl.implementationStatus === 'IMPLEMENTED' ? 85 :
ctrl.implementationStatus === 'PARTIAL' ? 50 : 0)
return {
id: ctrl.id,
name: ctrl.name,
description: ctrl.description,
type: ctrl.type,
category: ctrl.category,
implementationStatus: ctrl.implementationStatus,
evidence: ctrl.evidence,
owner: ctrl.owner,
dueDate: ctrl.dueDate,
code: ctrl.id,
displayType: 'preventive' as const,
displayCategory: mapControlTypeToDisplay(ctrl.type),
displayStatus: mapStatusToDisplay(ctrl.implementationStatus),
effectivenessPercent,
linkedRequirements: [],
linkedEvidence: evidenceMap[ctrl.id] || [],
lastReview: new Date(),
}
})
const handleStatusChange = async (controlId: string, newStatus: ImplementationStatus) => {
const oldControl = state.controls.find(c => c.id === controlId)
const oldStatus = oldControl?.implementationStatus
dispatch({ type: 'UPDATE_CONTROL', payload: { id: controlId, data: { implementationStatus: newStatus } } })
try {
const res = await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ implementation_status: newStatus }),
})
if (!res.ok) {
if (oldStatus) {
dispatch({ type: 'UPDATE_CONTROL', payload: { id: controlId, data: { implementationStatus: oldStatus } } })
}
const err = await res.json().catch(() => ({ detail: 'Status-Aenderung fehlgeschlagen' }))
if (res.status === 409 && err.detail?.violations) {
setTransitionError({ controlId, violations: err.detail.violations })
} else {
const msg = typeof err.detail === 'string' ? err.detail : err.detail?.error || 'Status-Aenderung fehlgeschlagen'
setError(msg)
}
} else {
setTransitionError(prev => prev?.controlId === controlId ? null : prev)
}
} catch {
if (oldStatus) {
dispatch({ type: 'UPDATE_CONTROL', payload: { id: controlId, data: { implementationStatus: oldStatus } } })
}
setError('Netzwerkfehler bei Status-Aenderung')
}
}
const handleEffectivenessChange = async (controlId: string, effectiveness: number) => {
setEffectivenessMap(prev => ({ ...prev, [controlId]: effectiveness }))
try {
await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ effectiveness_score: effectiveness }),
})
} catch {
// Silently fail
}
}
const handleAddControl = (data: { name: string; description: string; type: ControlType; category: string; owner: string }) => {
const newControl: SDKControl = {
id: `ctrl-${Date.now()}`,
name: data.name,
description: data.description,
type: data.type,
category: data.category,
implementationStatus: 'NOT_IMPLEMENTED',
effectiveness: 'LOW',
evidence: [],
owner: data.owner || null,
dueDate: null,
}
dispatch({ type: 'ADD_CONTROL', payload: newControl })
}
const addSuggestedControl = (suggestion: RAGControlSuggestion) => {
const newControl: SDKControl = {
id: `rag-${suggestion.control_id}-${Date.now()}`,
name: suggestion.title,
description: suggestion.description,
type: 'TECHNICAL',
category: suggestion.domain,
implementationStatus: 'NOT_IMPLEMENTED',
effectiveness: 'LOW',
evidence: [],
owner: null,
dueDate: null,
}
dispatch({ type: 'ADD_CONTROL', payload: newControl })
}
return {
state,
loading,
error,
setError,
effectivenessMap,
evidenceMap,
displayControls,
transitionError,
setTransitionError,
handleStatusChange,
handleEffectivenessChange,
handleAddControl,
addSuggestedControl,
}
}

View File

@@ -0,0 +1,53 @@
'use client'
import { useState } from 'react'
import type { RAGControlSuggestion } from '../_types'
export function useRAGSuggestions(setError: (msg: string | null) => void) {
const [ragLoading, setRagLoading] = useState(false)
const [ragSuggestions, setRagSuggestions] = useState<RAGControlSuggestion[]>([])
const [showRagPanel, setShowRagPanel] = useState(false)
const [selectedRequirementId, setSelectedRequirementId] = useState<string>('')
const suggestControlsFromRAG = async () => {
if (!selectedRequirementId) {
setError('Bitte eine Anforderungs-ID eingeben.')
return
}
setRagLoading(true)
setRagSuggestions([])
try {
const res = await fetch('/api/sdk/v1/compliance/ai/suggest-controls', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requirement_id: selectedRequirementId }),
})
if (!res.ok) {
const msg = await res.text()
throw new Error(msg || `HTTP ${res.status}`)
}
const data = await res.json()
setRagSuggestions(data.suggestions || [])
setShowRagPanel(true)
} catch (e) {
setError(`KI-Vorschlaege fehlgeschlagen: ${e instanceof Error ? e.message : 'Unbekannter Fehler'}`)
} finally {
setRagLoading(false)
}
}
const removeSuggestion = (controlId: string) => {
setRagSuggestions(prev => prev.filter(s => s.control_id !== controlId))
}
return {
ragLoading,
ragSuggestions,
showRagPanel,
setShowRagPanel,
selectedRequirementId,
setSelectedRequirementId,
suggestControlsFromRAG,
removeSuggestion,
}
}

View File

@@ -0,0 +1,56 @@
import type { ControlType, ImplementationStatus } from '@/lib/sdk'
export type DisplayControlType = 'preventive' | 'detective' | 'corrective'
export type DisplayCategory = 'technical' | 'organizational' | 'physical'
export type DisplayStatus = 'implemented' | 'partial' | 'planned' | 'not-implemented'
export interface DisplayControl {
id: string
name: string
description: string
type: ControlType
category: string
implementationStatus: ImplementationStatus
evidence: string[]
owner: string | null
dueDate: Date | null
code: string
displayType: DisplayControlType
displayCategory: DisplayCategory
displayStatus: DisplayStatus
effectivenessPercent: number
linkedRequirements: string[]
linkedEvidence: { id: string; title: string; status: string }[]
lastReview: Date
}
export interface RAGControlSuggestion {
control_id: string
domain: string
title: string
description: string
pass_criteria: string
implementation_guidance?: string
is_automated: boolean
automation_tool?: string
priority: number
confidence_score: number
}
export function mapControlTypeToDisplay(type: ControlType): DisplayCategory {
switch (type) {
case 'TECHNICAL': return 'technical'
case 'ORGANIZATIONAL': return 'organizational'
case 'PHYSICAL': return 'physical'
default: return 'technical'
}
}
export function mapStatusToDisplay(status: ImplementationStatus): DisplayStatus {
switch (status) {
case 'IMPLEMENTED': return 'implemented'
case 'PARTIAL': return 'partial'
case 'NOT_IMPLEMENTED': return 'not-implemented'
default: return 'not-implemented'
}
}

View File

@@ -1,538 +1,52 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useSDK, Control as SDKControl, ControlType, ImplementationStatus } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
// =============================================================================
// TYPES
// =============================================================================
type DisplayControlType = 'preventive' | 'detective' | 'corrective'
type DisplayCategory = 'technical' | 'organizational' | 'physical'
type DisplayStatus = 'implemented' | 'partial' | 'planned' | 'not-implemented'
interface DisplayControl {
id: string
name: string
description: string
type: ControlType
category: string
implementationStatus: ImplementationStatus
evidence: string[]
owner: string | null
dueDate: Date | null
code: string
displayType: DisplayControlType
displayCategory: DisplayCategory
displayStatus: DisplayStatus
effectivenessPercent: number
linkedRequirements: string[]
linkedEvidence: { id: string; title: string; status: string }[]
lastReview: Date
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
function mapControlTypeToDisplay(type: ControlType): DisplayCategory {
switch (type) {
case 'TECHNICAL': return 'technical'
case 'ORGANIZATIONAL': return 'organizational'
case 'PHYSICAL': return 'physical'
default: return 'technical'
}
}
function mapStatusToDisplay(status: ImplementationStatus): DisplayStatus {
switch (status) {
case 'IMPLEMENTED': return 'implemented'
case 'PARTIAL': return 'partial'
case 'NOT_IMPLEMENTED': return 'not-implemented'
default: return 'not-implemented'
}
}
// =============================================================================
// COMPONENTS
// =============================================================================
function ControlCard({
control,
onStatusChange,
onEffectivenessChange,
onLinkEvidence,
}: {
control: DisplayControl
onStatusChange: (status: ImplementationStatus) => void
onEffectivenessChange: (effectivenessPercent: number) => void
onLinkEvidence: () => void
}) {
const [showEffectivenessSlider, setShowEffectivenessSlider] = useState(false)
const typeColors = {
preventive: 'bg-blue-100 text-blue-700',
detective: 'bg-purple-100 text-purple-700',
corrective: 'bg-orange-100 text-orange-700',
}
const categoryColors = {
technical: 'bg-green-100 text-green-700',
organizational: 'bg-yellow-100 text-yellow-700',
physical: 'bg-gray-100 text-gray-700',
}
const statusColors = {
implemented: 'border-green-200 bg-green-50',
partial: 'border-yellow-200 bg-yellow-50',
planned: 'border-blue-200 bg-blue-50',
'not-implemented': 'border-red-200 bg-red-50',
}
const statusLabels = {
implemented: 'Implementiert',
partial: 'Teilweise',
planned: 'Geplant',
'not-implemented': 'Nicht implementiert',
}
return (
<div className={`bg-white rounded-xl border-2 p-6 ${statusColors[control.displayStatus]}`}>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded font-mono">
{control.code}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${typeColors[control.displayType]}`}>
{control.displayType === 'preventive' ? 'Praeventiv' :
control.displayType === 'detective' ? 'Detektiv' : 'Korrektiv'}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${categoryColors[control.displayCategory]}`}>
{control.displayCategory === 'technical' ? 'Technisch' :
control.displayCategory === 'organizational' ? 'Organisatorisch' : 'Physisch'}
</span>
</div>
<h3 className="text-lg font-semibold text-gray-900">{control.name}</h3>
<p className="text-sm text-gray-500 mt-1">{control.description}</p>
</div>
<select
value={control.implementationStatus}
onChange={(e) => onStatusChange(e.target.value as ImplementationStatus)}
className={`px-3 py-1 text-sm rounded-full border ${statusColors[control.displayStatus]}`}
>
<option value="NOT_IMPLEMENTED">Nicht implementiert</option>
<option value="PARTIAL">Teilweise</option>
<option value="IMPLEMENTED">Implementiert</option>
</select>
</div>
<div className="mt-4">
<div
className="flex items-center justify-between text-sm mb-1 cursor-pointer"
onClick={() => setShowEffectivenessSlider(!showEffectivenessSlider)}
>
<span className="text-gray-500">Wirksamkeit</span>
<span className="font-medium">{control.effectivenessPercent}%</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
control.effectivenessPercent >= 80 ? 'bg-green-500' :
control.effectivenessPercent >= 50 ? 'bg-yellow-500' : 'bg-red-500'
}`}
style={{ width: `${control.effectivenessPercent}%` }}
/>
</div>
{showEffectivenessSlider && (
<div className="mt-2">
<input
type="range"
min={0}
max={100}
value={control.effectivenessPercent}
onChange={(e) => onEffectivenessChange(Number(e.target.value))}
className="w-full"
/>
</div>
)}
</div>
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
<div className="text-gray-500">
<span>Verantwortlich: </span>
<span className="font-medium text-gray-700">{control.owner || 'Nicht zugewiesen'}</span>
</div>
<div className="text-gray-500">
Letzte Pruefung: {control.lastReview.toLocaleDateString('de-DE')}
</div>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-1 flex-wrap">
{control.linkedRequirements.slice(0, 3).map(req => (
<span key={req} className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
{req}
</span>
))}
{control.linkedRequirements.length > 3 && (
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
+{control.linkedRequirements.length - 3}
</span>
)}
</div>
<span className={`px-3 py-1 text-xs rounded-full ${
control.displayStatus === 'implemented' ? 'bg-green-100 text-green-700' :
control.displayStatus === 'partial' ? 'bg-yellow-100 text-yellow-700' :
control.displayStatus === 'planned' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'
}`}>
{statusLabels[control.displayStatus]}
</span>
</div>
{/* Linked Evidence */}
{control.linkedEvidence.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-100">
<span className="text-xs text-gray-500 mb-1 block">
Nachweise: {control.linkedEvidence.length}
{(() => {
const e2plus = control.linkedEvidence.filter((ev: { confidenceLevel?: string }) =>
ev.confidenceLevel && ['E2', 'E3', 'E4'].includes(ev.confidenceLevel)
).length
return e2plus > 0 ? ` (${e2plus} E2+)` : ''
})()}
</span>
<div className="flex items-center gap-1 flex-wrap">
{control.linkedEvidence.map(ev => (
<span key={ev.id} className={`px-2 py-0.5 text-xs rounded ${
ev.status === 'valid' ? 'bg-green-50 text-green-700' :
ev.status === 'expired' ? 'bg-red-50 text-red-700' :
'bg-yellow-50 text-yellow-700'
}`}>
{ev.title}
{(ev as { confidenceLevel?: string }).confidenceLevel && (
<span className="ml-1 opacity-70">({(ev as { confidenceLevel?: string }).confidenceLevel})</span>
)}
</span>
))}
</div>
</div>
)}
<div className="mt-3 pt-3 border-t border-gray-100">
<button
onClick={onLinkEvidence}
className="text-sm text-purple-600 hover:text-purple-700 font-medium"
>
Evidence verknuepfen
</button>
</div>
</div>
)
}
function AddControlForm({
onSubmit,
onCancel,
}: {
onSubmit: (data: { name: string; description: string; type: ControlType; category: string; owner: string }) => void
onCancel: () => void
}) {
const [formData, setFormData] = useState({
name: '',
description: '',
type: 'TECHNICAL' as ControlType,
category: '',
owner: '',
})
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Neue Kontrolle</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
<input
type="text"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. Zugriffskontrolle"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
placeholder="Beschreiben Sie die Kontrolle..."
rows={2}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Typ</label>
<select
value={formData.type}
onChange={e => setFormData({ ...formData, type: e.target.value as ControlType })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="TECHNICAL">Technisch</option>
<option value="ORGANIZATIONAL">Organisatorisch</option>
<option value="PHYSICAL">Physisch</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
<input
type="text"
value={formData.category}
onChange={e => setFormData({ ...formData, category: e.target.value })}
placeholder="z.B. Zutrittskontrolle"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verantwortlich</label>
<input
type="text"
value={formData.owner}
onChange={e => setFormData({ ...formData, owner: e.target.value })}
placeholder="z.B. IT Security"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
</div>
</div>
</div>
<div className="mt-6 flex items-center justify-end gap-3">
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
<button
onClick={() => onSubmit(formData)}
disabled={!formData.name}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
formData.name ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Hinzufuegen
</button>
</div>
</div>
)
}
function LoadingSkeleton() {
return (
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
<div className="flex items-center gap-2 mb-3">
<div className="h-5 w-20 bg-gray-200 rounded" />
<div className="h-5 w-16 bg-gray-200 rounded-full" />
<div className="h-5 w-16 bg-gray-200 rounded-full" />
</div>
<div className="h-6 w-3/4 bg-gray-200 rounded mb-2" />
<div className="h-4 w-full bg-gray-100 rounded" />
<div className="mt-4 h-2 bg-gray-200 rounded-full" />
</div>
))}
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
// =============================================================================
// RAG SUGGESTION TYPES
// =============================================================================
interface RAGControlSuggestion {
control_id: string
domain: string
title: string
description: string
pass_criteria: string
implementation_guidance?: string
is_automated: boolean
automation_tool?: string
priority: number
confidence_score: number
}
// =============================================================================
// MAIN PAGE
// =============================================================================
function TransitionErrorBanner({
controlId,
violations,
onDismiss,
}: {
controlId: string
violations: string[]
onDismiss: () => void
}) {
return (
<div className="p-4 bg-orange-50 border border-orange-200 rounded-lg">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-orange-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<h4 className="font-medium text-orange-800">
Status-Transition blockiert ({controlId})
</h4>
<ul className="mt-2 space-y-1">
{violations.map((v, i) => (
<li key={i} className="text-sm text-orange-700 flex items-start gap-2">
<span className="text-orange-400 mt-0.5"></span>
<span>{v}</span>
</li>
))}
</ul>
<a href="/sdk/evidence" className="mt-2 inline-block text-sm text-purple-600 hover:text-purple-700 font-medium">
Evidence hinzufuegen
</a>
</div>
</div>
<button onClick={onDismiss} className="text-orange-400 hover:text-orange-600 ml-4">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
)
}
import { useControlsData } from './_hooks/useControlsData'
import { useRAGSuggestions } from './_hooks/useRAGSuggestions'
import { ControlCard } from './_components/ControlCard'
import { AddControlForm } from './_components/AddControlForm'
import { LoadingSkeleton } from './_components/LoadingSkeleton'
import { TransitionErrorBanner } from './_components/TransitionErrorBanner'
import { StatsCards } from './_components/StatsCards'
import { FilterBar } from './_components/FilterBar'
import { RAGPanel } from './_components/RAGPanel'
export default function ControlsPage() {
const { state, dispatch } = useSDK()
const router = useRouter()
const [filter, setFilter] = useState<string>('all')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showAddForm, setShowAddForm] = useState(false)
// RAG suggestion state
const [ragLoading, setRagLoading] = useState(false)
const [ragSuggestions, setRagSuggestions] = useState<RAGControlSuggestion[]>([])
const [showRagPanel, setShowRagPanel] = useState(false)
const [selectedRequirementId, setSelectedRequirementId] = useState<string>('')
const {
state,
loading,
error,
setError,
displayControls,
transitionError,
setTransitionError,
handleStatusChange,
handleEffectivenessChange,
handleAddControl,
addSuggestedControl,
} = useControlsData()
// Transition error from Anti-Fake-Evidence state machine (409 Conflict)
const [transitionError, setTransitionError] = useState<{ controlId: string; violations: string[] } | null>(null)
// Track effectiveness locally as it's not in the SDK state type
const [effectivenessMap, setEffectivenessMap] = useState<Record<string, number>>({})
// Track linked evidence per control
const [evidenceMap, setEvidenceMap] = useState<Record<string, { id: string; title: string; status: string }[]>>({})
const fetchEvidenceForControls = async (controlIds: string[]) => {
try {
const res = await fetch('/api/sdk/v1/compliance/evidence')
if (res.ok) {
const data = await res.json()
const allEvidence = data.evidence || data
if (Array.isArray(allEvidence)) {
const map: Record<string, { id: string; title: string; status: string; confidenceLevel?: string }[]> = {}
for (const ev of allEvidence) {
const ctrlId = ev.control_id || ''
if (!map[ctrlId]) map[ctrlId] = []
map[ctrlId].push({
id: ev.id,
title: ev.title || ev.name || 'Nachweis',
status: ev.status || 'pending',
confidenceLevel: ev.confidence_level || undefined,
})
}
setEvidenceMap(map)
}
}
} catch {
// Silently fail
}
}
// Fetch controls from backend on mount
useEffect(() => {
const fetchControls = async () => {
try {
setLoading(true)
const res = await fetch('/api/sdk/v1/compliance/controls')
if (res.ok) {
const data = await res.json()
const backendControls = data.controls || data
if (Array.isArray(backendControls) && backendControls.length > 0) {
const mapped: SDKControl[] = backendControls.map((c: Record<string, unknown>) => ({
id: (c.control_id || c.id) as string,
name: (c.name || c.title || '') as string,
description: (c.description || '') as string,
type: ((c.type || c.control_type || 'TECHNICAL') as string).toUpperCase() as ControlType,
category: (c.category || '') as string,
implementationStatus: ((c.implementation_status || c.status || 'NOT_IMPLEMENTED') as string).toUpperCase() as ImplementationStatus,
effectiveness: (c.effectiveness || 'LOW') as 'LOW' | 'MEDIUM' | 'HIGH',
evidence: (c.evidence || []) as string[],
owner: (c.owner || null) as string | null,
dueDate: c.due_date ? new Date(c.due_date as string) : null,
}))
dispatch({ type: 'SET_STATE', payload: { controls: mapped } })
setError(null)
// Fetch evidence for all controls
fetchEvidenceForControls(mapped.map(c => c.id))
return
}
}
} catch {
// API not available — show empty state
} finally {
setLoading(false)
}
}
fetchControls()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
// Convert SDK controls to display controls
const displayControls: DisplayControl[] = state.controls.map(ctrl => {
const effectivenessPercent = effectivenessMap[ctrl.id] ??
(ctrl.implementationStatus === 'IMPLEMENTED' ? 85 :
ctrl.implementationStatus === 'PARTIAL' ? 50 : 0)
return {
id: ctrl.id,
name: ctrl.name,
description: ctrl.description,
type: ctrl.type,
category: ctrl.category,
implementationStatus: ctrl.implementationStatus,
evidence: ctrl.evidence,
owner: ctrl.owner,
dueDate: ctrl.dueDate,
code: ctrl.id,
displayType: 'preventive' as DisplayControlType,
displayCategory: mapControlTypeToDisplay(ctrl.type),
displayStatus: mapStatusToDisplay(ctrl.implementationStatus),
effectivenessPercent,
linkedRequirements: [],
linkedEvidence: evidenceMap[ctrl.id] || [],
lastReview: new Date(),
}
})
const {
ragLoading,
ragSuggestions,
showRagPanel,
setShowRagPanel,
selectedRequirementId,
setSelectedRequirementId,
suggestControlsFromRAG,
removeSuggestion,
} = useRAGSuggestions(setError)
const filteredControls = filter === 'all'
? displayControls
: displayControls.filter(c =>
c.displayStatus === filter ||
c.displayType === filter ||
c.displayCategory === filter
c.displayStatus === filter || c.displayType === filter || c.displayCategory === filter
)
const implementedCount = displayControls.filter(c => c.displayStatus === 'implemented').length
@@ -541,141 +55,10 @@ export default function ControlsPage() {
: 0
const partialCount = displayControls.filter(c => c.displayStatus === 'partial').length
const handleStatusChange = async (controlId: string, newStatus: ImplementationStatus) => {
// Remember old status for rollback
const oldControl = state.controls.find(c => c.id === controlId)
const oldStatus = oldControl?.implementationStatus
// Optimistic update
dispatch({
type: 'UPDATE_CONTROL',
payload: { id: controlId, data: { implementationStatus: newStatus } },
})
try {
const res = await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ implementation_status: newStatus }),
})
if (!res.ok) {
// Rollback optimistic update
if (oldStatus) {
dispatch({
type: 'UPDATE_CONTROL',
payload: { id: controlId, data: { implementationStatus: oldStatus } },
})
}
const err = await res.json().catch(() => ({ detail: 'Status-Aenderung fehlgeschlagen' }))
if (res.status === 409 && err.detail?.violations) {
setTransitionError({ controlId, violations: err.detail.violations })
} else {
const msg = typeof err.detail === 'string' ? err.detail : err.detail?.error || 'Status-Aenderung fehlgeschlagen'
setError(msg)
}
} else {
// Clear any previous transition error for this control
if (transitionError?.controlId === controlId) {
setTransitionError(null)
}
}
} catch {
// Network error — rollback
if (oldStatus) {
dispatch({
type: 'UPDATE_CONTROL',
payload: { id: controlId, data: { implementationStatus: oldStatus } },
})
}
setError('Netzwerkfehler bei Status-Aenderung')
}
}
const handleEffectivenessChange = async (controlId: string, effectiveness: number) => {
setEffectivenessMap(prev => ({ ...prev, [controlId]: effectiveness }))
// Persist to backend
try {
await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ effectiveness_score: effectiveness }),
})
} catch {
// Silently fail — local state is already updated
}
}
const handleAddControl = (data: { name: string; description: string; type: ControlType; category: string; owner: string }) => {
const newControl: SDKControl = {
id: `ctrl-${Date.now()}`,
name: data.name,
description: data.description,
type: data.type,
category: data.category,
implementationStatus: 'NOT_IMPLEMENTED',
effectiveness: 'LOW',
evidence: [],
owner: data.owner || null,
dueDate: null,
}
dispatch({ type: 'ADD_CONTROL', payload: newControl })
setShowAddForm(false)
}
const suggestControlsFromRAG = async () => {
if (!selectedRequirementId) {
setError('Bitte eine Anforderungs-ID eingeben.')
return
}
setRagLoading(true)
setRagSuggestions([])
try {
const res = await fetch('/api/sdk/v1/compliance/ai/suggest-controls', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requirement_id: selectedRequirementId }),
})
if (!res.ok) {
const msg = await res.text()
throw new Error(msg || `HTTP ${res.status}`)
}
const data = await res.json()
setRagSuggestions(data.suggestions || [])
setShowRagPanel(true)
} catch (e) {
setError(`KI-Vorschläge fehlgeschlagen: ${e instanceof Error ? e.message : 'Unbekannter Fehler'}`)
} finally {
setRagLoading(false)
}
}
const addSuggestedControl = (suggestion: RAGControlSuggestion) => {
const newControl: import('@/lib/sdk').Control = {
id: `rag-${suggestion.control_id}-${Date.now()}`,
name: suggestion.title,
description: suggestion.description,
type: 'TECHNICAL',
category: suggestion.domain,
implementationStatus: 'NOT_IMPLEMENTED',
effectiveness: 'LOW',
evidence: [],
owner: null,
dueDate: null,
}
dispatch({ type: 'ADD_CONTROL', payload: newControl })
// Remove from suggestions after adding
setRagSuggestions(prev => prev.filter(s => s.control_id !== suggestion.control_id))
}
const stepInfo = STEP_EXPLANATIONS['controls']
return (
<div className="space-y-6">
{/* Step Header */}
<StepHeader
stepId="controls"
title={stepInfo.title}
@@ -705,133 +88,26 @@ export default function ControlsPage() {
</div>
</StepHeader>
{/* Add Form */}
{showAddForm && (
<AddControlForm
onSubmit={handleAddControl}
onSubmit={(data) => { handleAddControl(data); setShowAddForm(false) }}
onCancel={() => setShowAddForm(false)}
/>
)}
{/* RAG Controls Panel */}
{showRagPanel && (
<div className="bg-purple-50 border border-purple-200 rounded-xl p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-purple-900">KI-Controls aus RAG vorschlagen</h3>
<p className="text-sm text-purple-700 mt-1">
Geben Sie eine Anforderungs-ID ein. Das KI-System analysiert die Anforderung mit Hilfe des RAG-Corpus
und schlägt passende Controls vor.
</p>
</div>
<button onClick={() => setShowRagPanel(false)} className="text-purple-400 hover:text-purple-600 ml-4">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex items-center gap-3 mb-4">
<input
type="text"
value={selectedRequirementId}
onChange={e => setSelectedRequirementId(e.target.value)}
placeholder="Anforderungs-UUID eingeben..."
className="flex-1 px-4 py-2 border border-purple-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent bg-white"
/>
{state.requirements.length > 0 && (
<select
value={selectedRequirementId}
onChange={e => setSelectedRequirementId(e.target.value)}
className="px-3 py-2 border border-purple-300 rounded-lg bg-white text-sm focus:ring-2 focus:ring-purple-500"
>
<option value="">Aus Liste wählen...</option>
{state.requirements.slice(0, 20).map(r => (
<option key={r.id} value={r.id}>{r.id.substring(0, 8)}... {r.title?.substring(0, 40)}</option>
))}
</select>
)}
<button
onClick={suggestControlsFromRAG}
disabled={ragLoading || !selectedRequirementId}
className={`flex items-center gap-2 px-5 py-2 rounded-lg font-medium transition-colors ${
ragLoading || !selectedRequirementId
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-purple-600 text-white hover:bg-purple-700'
}`}
>
{ragLoading ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Analysiere...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Vorschläge generieren
</>
)}
</button>
</div>
{/* Suggestions */}
{ragSuggestions.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-semibold text-purple-800">{ragSuggestions.length} Vorschläge gefunden:</h4>
{ragSuggestions.map((suggestion) => (
<div key={suggestion.control_id} className="bg-white border border-purple-200 rounded-lg p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded font-mono">
{suggestion.control_id}
</span>
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
{suggestion.domain}
</span>
<span className="text-xs text-gray-500">
Konfidenz: {Math.round(suggestion.confidence_score * 100)}%
</span>
</div>
<h5 className="font-semibold text-gray-900">{suggestion.title}</h5>
<p className="text-sm text-gray-600 mt-1">{suggestion.description}</p>
{suggestion.pass_criteria && (
<p className="text-xs text-gray-500 mt-1">
<span className="font-medium">Erfolgskriterium:</span> {suggestion.pass_criteria}
</p>
)}
{suggestion.is_automated && (
<span className="mt-1 inline-block px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded">
Automatisierbar {suggestion.automation_tool ? `(${suggestion.automation_tool})` : ''}
</span>
)}
</div>
<button
onClick={() => addSuggestedControl(suggestion)}
className="flex-shrink-0 flex items-center gap-1 px-3 py-1.5 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Hinzufügen
</button>
</div>
</div>
))}
</div>
)}
{!ragLoading && ragSuggestions.length === 0 && selectedRequirementId && (
<p className="text-sm text-purple-600 italic">
Klicken Sie auf &quot;Vorschläge generieren&quot;, um KI-Controls abzurufen.
</p>
)}
</div>
<RAGPanel
selectedRequirementId={selectedRequirementId}
onSelectedRequirementIdChange={setSelectedRequirementId}
requirements={state.requirements}
onSuggestControls={suggestControlsFromRAG}
ragLoading={ragLoading}
ragSuggestions={ragSuggestions}
onAddSuggestion={(s) => { addSuggestedControl(s); removeSuggestion(s.control_id) }}
onClose={() => setShowRagPanel(false)}
/>
)}
{/* Error Banner */}
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
<span>{error}</span>
@@ -839,7 +115,6 @@ export default function ControlsPage() {
</div>
)}
{/* Transition Error Banner (Anti-Fake-Evidence 409 violations) */}
{transitionError && (
<TransitionErrorBanner
controlId={transitionError.controlId}
@@ -848,7 +123,6 @@ export default function ControlsPage() {
/>
)}
{/* Requirements Alert */}
{state.requirements.length === 0 && !loading && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<div className="flex items-start gap-3">
@@ -865,54 +139,17 @@ export default function ControlsPage() {
</div>
)}
{/* Stats */}
<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">Gesamt</div>
<div className="text-3xl font-bold text-gray-900">{displayControls.length}</div>
</div>
<div className="bg-white rounded-xl border border-green-200 p-6">
<div className="text-sm text-green-600">Implementiert</div>
<div className="text-3xl font-bold text-green-600">{implementedCount}</div>
</div>
<div className="bg-white rounded-xl border border-purple-200 p-6">
<div className="text-sm text-purple-600">Durchschn. Wirksamkeit</div>
<div className="text-3xl font-bold text-purple-600">{avgEffectiveness}%</div>
</div>
<div className="bg-white rounded-xl border border-yellow-200 p-6">
<div className="text-sm text-yellow-600">Teilweise</div>
<div className="text-3xl font-bold text-yellow-600">{partialCount}</div>
</div>
</div>
<StatsCards
total={displayControls.length}
implementedCount={implementedCount}
avgEffectiveness={avgEffectiveness}
partialCount={partialCount}
/>
{/* Filter */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-gray-500">Filter:</span>
{['all', 'implemented', 'partial', 'not-implemented', 'technical', 'organizational', 'preventive', 'detective'].map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1 text-sm rounded-full transition-colors ${
filter === f
? 'bg-purple-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{f === 'all' ? 'Alle' :
f === 'implemented' ? 'Implementiert' :
f === 'partial' ? 'Teilweise' :
f === 'not-implemented' ? 'Offen' :
f === 'technical' ? 'Technisch' :
f === 'organizational' ? 'Organisatorisch' :
f === 'preventive' ? 'Praeventiv' : 'Detektiv'}
</button>
))}
</div>
<FilterBar filter={filter} onFilterChange={setFilter} />
{/* Loading State */}
{loading && <LoadingSkeleton />}
{/* Controls List */}
{!loading && (
<div className="space-y-4">
{filteredControls.map(control => (

View File

@@ -0,0 +1,80 @@
'use client'
import { useState } from 'react'
import { Component, ComponentFormData, COMPONENT_TYPES } from './types'
export function ComponentForm({
onSubmit, onCancel, initialData, parentId,
}: {
onSubmit: (data: ComponentFormData) => void
onCancel: () => void
initialData?: Component | null
parentId?: string | null
}) {
const [formData, setFormData] = useState<ComponentFormData>({
name: initialData?.name || '',
type: initialData?.type || 'SW',
version: initialData?.version || '',
description: initialData?.description || '',
safety_relevant: initialData?.safety_relevant || false,
parent_id: parentId || initialData?.parent_id || null,
})
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{initialData ? 'Komponente bearbeiten' : parentId ? 'Unterkomponente hinzufuegen' : 'Neue Komponente'}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name *</label>
<input type="text" value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. Bildverarbeitungsmodul"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Typ</label>
<select value={formData.type} onChange={(e) => setFormData({ ...formData, type: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white">
{COMPONENT_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Version</label>
<input type="text" value={formData.version}
onChange={(e) => setFormData({ ...formData, version: e.target.value })}
placeholder="z.B. 1.2.0"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white" />
</div>
<div className="flex items-center gap-3 pt-6">
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" checked={formData.safety_relevant}
onChange={(e) => setFormData({ ...formData, safety_relevant: e.target.checked })}
className="sr-only peer" />
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-red-500" />
</label>
<span className="text-sm text-gray-700 dark:text-gray-300">Sicherheitsrelevant</span>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
<textarea value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Kurze Beschreibung der Komponente..." rows={2}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white" />
</div>
</div>
<div className="mt-4 flex items-center gap-3">
<button onClick={() => onSubmit(formData)} disabled={!formData.name}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
formData.name ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}>
{initialData ? 'Aktualisieren' : 'Hinzufuegen'}
</button>
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,188 @@
'use client'
import { useState, useEffect } from 'react'
import { LibraryComponent, EnergySource, LIBRARY_CATEGORIES } from './types'
import { ComponentTypeIcon } from './ComponentTypeIcon'
export function ComponentLibraryModal({
onAdd, onClose,
}: {
onAdd: (components: LibraryComponent[], energySources: EnergySource[]) => void
onClose: () => void
}) {
const [libraryComponents, setLibraryComponents] = useState<LibraryComponent[]>([])
const [energySources, setEnergySources] = useState<EnergySource[]>([])
const [selectedComponents, setSelectedComponents] = useState<Set<string>>(new Set())
const [selectedEnergySources, setSelectedEnergySources] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [filterCategory, setFilterCategory] = useState('')
const [activeTab, setActiveTab] = useState<'components' | 'energy'>('components')
const [loading, setLoading] = useState(true)
useEffect(() => {
async function fetchData() {
try {
const [compRes, enRes] = await Promise.all([
fetch('/api/sdk/v1/iace/component-library'),
fetch('/api/sdk/v1/iace/energy-sources'),
])
if (compRes.ok) { const json = await compRes.json(); setLibraryComponents(json.components || []) }
if (enRes.ok) { const json = await enRes.json(); setEnergySources(json.energy_sources || []) }
} catch (err) {
console.error('Failed to fetch library:', err)
} finally {
setLoading(false)
}
}
fetchData()
}, [])
function toggleComponent(id: string) {
setSelectedComponents(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next })
}
function toggleEnergySource(id: string) {
setSelectedEnergySources(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next })
}
function toggleAllInCategory(category: string) {
const items = libraryComponents.filter(c => c.category === category)
const allIds = items.map(i => i.id)
const allSelected = allIds.every(id => selectedComponents.has(id))
setSelectedComponents(prev => { const next = new Set(prev); allIds.forEach(id => allSelected ? next.delete(id) : next.add(id)); return next })
}
function handleAdd() {
const selComps = libraryComponents.filter(c => selectedComponents.has(c.id))
const selEnergy = energySources.filter(e => selectedEnergySources.has(e.id))
onAdd(selComps, selEnergy)
}
const filtered = libraryComponents.filter(c => {
if (filterCategory && c.category !== filterCategory) return false
if (search) {
const q = search.toLowerCase()
return c.name_de.toLowerCase().includes(q) || c.name_en.toLowerCase().includes(q) || c.description_de.toLowerCase().includes(q)
}
return true
})
const grouped = filtered.reduce<Record<string, LibraryComponent[]>>((acc, c) => {
if (!acc[c.category]) acc[c.category] = []
acc[c.category].push(c)
return acc
}, {})
const categories = Object.keys(LIBRARY_CATEGORIES)
const totalSelected = selectedComponents.size + selectedEnergySources.size
if (loading) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600 mx-auto" />
<p className="mt-3 text-sm text-gray-500">Bibliothek wird geladen...</p>
</div>
</div>
)
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-4xl max-h-[85vh] flex flex-col">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Komponentenbibliothek</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex gap-2 mb-4">
<button onClick={() => setActiveTab('components')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${activeTab === 'components' ? 'bg-purple-100 text-purple-700' : 'text-gray-500 hover:bg-gray-100'}`}>
Komponenten ({libraryComponents.length})
</button>
<button onClick={() => setActiveTab('energy')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${activeTab === 'energy' ? 'bg-purple-100 text-purple-700' : 'text-gray-500 hover:bg-gray-100'}`}>
Energiequellen ({energySources.length})
</button>
</div>
{activeTab === 'components' && (
<div className="flex gap-3">
<input type="text" value={search} onChange={e => setSearch(e.target.value)} placeholder="Suchen..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white" />
<select value={filterCategory} onChange={e => setFilterCategory(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
<option value="">Alle Kategorien</option>
{categories.map(cat => <option key={cat} value={cat}>{LIBRARY_CATEGORIES[cat]}</option>)}
</select>
</div>
)}
</div>
<div className="flex-1 overflow-auto p-4">
{activeTab === 'components' ? (
<div className="space-y-4">
{Object.entries(grouped).sort(([a], [b]) => categories.indexOf(a) - categories.indexOf(b)).map(([category, items]) => (
<div key={category}>
<div className="flex items-center gap-2 mb-2 sticky top-0 bg-white dark:bg-gray-800 py-1 z-10">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">{LIBRARY_CATEGORIES[category] || category}</h4>
<span className="text-xs text-gray-400">({items.length})</span>
<button onClick={() => toggleAllInCategory(category)} className="text-xs text-purple-600 hover:text-purple-700 ml-auto">
{items.every(i => selectedComponents.has(i.id)) ? 'Alle abwaehlen' : 'Alle waehlen'}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{items.map(comp => (
<label key={comp.id}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedComponents.has(comp.id) ? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-750'
}`}>
<input type="checkbox" checked={selectedComponents.has(comp.id)} onChange={() => toggleComponent(comp.id)} className="mt-0.5 accent-purple-600" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-gray-400">{comp.id}</span>
<ComponentTypeIcon type={comp.maps_to_component_type} />
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{comp.name_de}</div>
{comp.description_de && <div className="text-xs text-gray-500 mt-0.5 line-clamp-2">{comp.description_de}</div>}
</div>
</label>
))}
</div>
</div>
))}
{filtered.length === 0 && <div className="text-center py-8 text-gray-500">Keine Komponenten gefunden</div>}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{energySources.map(es => (
<label key={es.id}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedEnergySources.has(es.id) ? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-750'
}`}>
<input type="checkbox" checked={selectedEnergySources.has(es.id)} onChange={() => toggleEnergySource(es.id)} className="mt-0.5 accent-purple-600" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2"><span className="text-xs font-mono text-gray-400">{es.id}</span></div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{es.name_de}</div>
{es.description_de && <div className="text-xs text-gray-500 mt-0.5 line-clamp-2">{es.description_de}</div>}
</div>
</label>
))}
</div>
)}
</div>
<div className="p-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<span className="text-sm text-gray-500">{selectedComponents.size} Komponenten, {selectedEnergySources.size} Energiequellen ausgewaehlt</span>
<div className="flex gap-3">
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">Abbrechen</button>
<button onClick={handleAdd} disabled={totalSelected === 0}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${totalSelected > 0 ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'}`}>
{totalSelected > 0 ? `${totalSelected} hinzufuegen` : 'Auswaehlen'}
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,85 @@
'use client'
import { useState } from 'react'
import { Component } from './types'
import { ComponentTypeIcon } from './ComponentTypeIcon'
export function ComponentTreeNode({
component, depth, onEdit, onDelete, onAddChild,
}: {
component: Component
depth: number
onEdit: (c: Component) => void
onDelete: (id: string) => void
onAddChild: (parentId: string) => void
}) {
const [expanded, setExpanded] = useState(true)
const hasChildren = component.children && component.children.length > 0
return (
<div>
<div
className="flex items-center gap-2 py-2 px-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 group transition-colors"
style={{ paddingLeft: `${depth * 24 + 12}px` }}
>
<button onClick={() => setExpanded(!expanded)}
className={`w-5 h-5 flex items-center justify-center text-gray-400 ${hasChildren ? 'visible' : 'invisible'}`}>
<svg className={`w-4 h-4 transition-transform ${expanded ? '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>
</button>
<ComponentTypeIcon type={component.type} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-gray-900 dark:text-white">{component.name}</span>
{component.version && <span className="ml-2 text-xs text-gray-400">v{component.version}</span>}
{component.safety_relevant && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700">
Sicherheitsrelevant
</span>
)}
{component.library_component_id && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">
Bibliothek
</span>
)}
</div>
{component.description && (
<span className="text-xs text-gray-400 truncate max-w-[200px] hidden lg:block">{component.description}</span>
)}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => onAddChild(component.id)} title="Unterkomponente hinzufuegen"
className="p-1 text-gray-400 hover:text-purple-600 hover:bg-purple-50 rounded transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</button>
<button onClick={() => onEdit(component)} title="Bearbeiten"
className="p-1 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button onClick={() => onDelete(component.id)} title="Loeschen"
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors">
<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-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
{expanded && hasChildren && (
<div>
{component.children.map((child) => (
<ComponentTreeNode key={child.id} component={child} depth={depth + 1}
onEdit={onEdit} onDelete={onDelete} onAddChild={onAddChild} />
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,20 @@
export function ComponentTypeIcon({ type }: { type: string }) {
const colors: Record<string, string> = {
SW: 'bg-blue-100 text-blue-700',
FW: 'bg-indigo-100 text-indigo-700',
AI: 'bg-purple-100 text-purple-700',
HMI: 'bg-pink-100 text-pink-700',
SENSOR: 'bg-cyan-100 text-cyan-700',
ACTUATOR: 'bg-orange-100 text-orange-700',
CONTROLLER: 'bg-green-100 text-green-700',
NETWORK: 'bg-yellow-100 text-yellow-700',
MECHANICAL: 'bg-gray-100 text-gray-700',
ELECTRICAL: 'bg-red-100 text-red-700',
OTHER: 'bg-gray-100 text-gray-500',
}
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${colors[type] || colors.OTHER}`}>
{type}
</span>
)
}

View File

@@ -0,0 +1,88 @@
export interface Component {
id: string
name: string
type: string
version: string
description: string
safety_relevant: boolean
parent_id: string | null
children: Component[]
library_component_id?: string
energy_source_ids?: string[]
}
export interface LibraryComponent {
id: string
name_de: string
name_en: string
category: string
description_de: string
typical_hazard_categories: string[]
typical_energy_sources: string[]
maps_to_component_type: string
tags: string[]
sort_order: number
}
export interface EnergySource {
id: string
name_de: string
name_en: string
description_de: string
typical_components: string[]
typical_hazard_categories: string[]
tags: string[]
sort_order: number
}
export interface ComponentFormData {
name: string
type: string
version: string
description: string
safety_relevant: boolean
parent_id: string | null
}
export const LIBRARY_CATEGORIES: Record<string, string> = {
mechanical: 'Mechanik',
structural: 'Struktur',
drive: 'Antrieb',
hydraulic: 'Hydraulik',
pneumatic: 'Pneumatik',
electrical: 'Elektrik',
control: 'Steuerung',
sensor: 'Sensorik',
actuator: 'Aktorik',
safety: 'Sicherheit',
it_network: 'IT/Netzwerk',
}
export const COMPONENT_TYPES = [
{ value: 'SW', label: 'Software (SW)' },
{ value: 'FW', label: 'Firmware (FW)' },
{ value: 'AI', label: 'KI-Modul (AI)' },
{ value: 'HMI', label: 'Mensch-Maschine-Schnittstelle (HMI)' },
{ value: 'SENSOR', label: 'Sensor' },
{ value: 'ACTUATOR', label: 'Aktor' },
{ value: 'CONTROLLER', label: 'Steuerung' },
{ value: 'NETWORK', label: 'Netzwerk' },
{ value: 'MECHANICAL', label: 'Mechanik' },
{ value: 'ELECTRICAL', label: 'Elektrik' },
{ value: 'OTHER', label: 'Sonstiges' },
]
export function buildTree(components: Component[]): Component[] {
const map = new Map<string, Component>()
const roots: Component[] = []
components.forEach((c) => { map.set(c.id, { ...c, children: [] }) })
components.forEach((c) => {
const node = map.get(c.id)!
if (c.parent_id && map.has(c.parent_id)) {
map.get(c.parent_id)!.children.push(node)
} else {
roots.push(node)
}
})
return roots
}

View File

@@ -0,0 +1,80 @@
'use client'
import { useState, useEffect } from 'react'
import { Component, LibraryComponent, EnergySource, ComponentFormData, buildTree } from '../_components/types'
export function useComponents(projectId: string) {
const [components, setComponents] = useState<Component[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editingComponent, setEditingComponent] = useState<Component | null>(null)
const [addingParentId, setAddingParentId] = useState<string | null>(null)
const [showLibrary, setShowLibrary] = useState(false)
useEffect(() => { fetchComponents() }, [projectId])
async function fetchComponents() {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`)
if (res.ok) { const json = await res.json(); setComponents(json.components || json || []) }
} catch (err) {
console.error('Failed to fetch components:', err)
} finally {
setLoading(false)
}
}
async function handleSubmit(data: ComponentFormData) {
try {
const url = editingComponent
? `/api/sdk/v1/iace/projects/${projectId}/components/${editingComponent.id}`
: `/api/sdk/v1/iace/projects/${projectId}/components`
const method = editingComponent ? 'PUT' : 'POST'
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
if (res.ok) { setShowForm(false); setEditingComponent(null); setAddingParentId(null); await fetchComponents() }
} catch (err) { console.error('Failed to save component:', err) }
}
async function handleDelete(id: string) {
if (!confirm('Komponente wirklich loeschen? Unterkomponenten werden ebenfalls entfernt.')) return
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components/${id}`, { method: 'DELETE' })
if (res.ok) await fetchComponents()
} catch (err) { console.error('Failed to delete component:', err) }
}
function handleEdit(component: Component) {
setEditingComponent(component); setAddingParentId(null); setShowForm(true)
}
function handleAddChild(parentId: string) {
setAddingParentId(parentId); setEditingComponent(null); setShowForm(true)
}
async function handleAddFromLibrary(libraryComps: LibraryComponent[], energySrcs: EnergySource[]) {
setShowLibrary(false)
const energySourceIds = energySrcs.map(e => e.id)
for (const comp of libraryComps) {
try {
await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: comp.name_de, type: comp.maps_to_component_type,
description: comp.description_de, safety_relevant: false,
library_component_id: comp.id, energy_source_ids: energySourceIds, tags: comp.tags,
}),
})
} catch (err) { console.error(`Failed to add component ${comp.id}:`, err) }
}
await fetchComponents()
}
const tree = buildTree(components)
return {
components, loading, tree,
showForm, setShowForm, editingComponent, setEditingComponent,
addingParentId, setAddingParentId, showLibrary, setShowLibrary,
handleSubmit, handleDelete, handleEdit, handleAddChild, handleAddFromLibrary,
}
}

View File

@@ -1,728 +1,17 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
interface Component {
id: string
name: string
type: string
version: string
description: string
safety_relevant: boolean
parent_id: string | null
children: Component[]
library_component_id?: string
energy_source_ids?: string[]
}
interface LibraryComponent {
id: string
name_de: string
name_en: string
category: string
description_de: string
typical_hazard_categories: string[]
typical_energy_sources: string[]
maps_to_component_type: string
tags: string[]
sort_order: number
}
interface EnergySource {
id: string
name_de: string
name_en: string
description_de: string
typical_components: string[]
typical_hazard_categories: string[]
tags: string[]
sort_order: number
}
const LIBRARY_CATEGORIES: Record<string, string> = {
mechanical: 'Mechanik',
structural: 'Struktur',
drive: 'Antrieb',
hydraulic: 'Hydraulik',
pneumatic: 'Pneumatik',
electrical: 'Elektrik',
control: 'Steuerung',
sensor: 'Sensorik',
actuator: 'Aktorik',
safety: 'Sicherheit',
it_network: 'IT/Netzwerk',
}
const COMPONENT_TYPES = [
{ value: 'SW', label: 'Software (SW)' },
{ value: 'FW', label: 'Firmware (FW)' },
{ value: 'AI', label: 'KI-Modul (AI)' },
{ value: 'HMI', label: 'Mensch-Maschine-Schnittstelle (HMI)' },
{ value: 'SENSOR', label: 'Sensor' },
{ value: 'ACTUATOR', label: 'Aktor' },
{ value: 'CONTROLLER', label: 'Steuerung' },
{ value: 'NETWORK', label: 'Netzwerk' },
{ value: 'MECHANICAL', label: 'Mechanik' },
{ value: 'ELECTRICAL', label: 'Elektrik' },
{ value: 'OTHER', label: 'Sonstiges' },
]
function ComponentTypeIcon({ type }: { type: string }) {
const colors: Record<string, string> = {
SW: 'bg-blue-100 text-blue-700',
FW: 'bg-indigo-100 text-indigo-700',
AI: 'bg-purple-100 text-purple-700',
HMI: 'bg-pink-100 text-pink-700',
SENSOR: 'bg-cyan-100 text-cyan-700',
ACTUATOR: 'bg-orange-100 text-orange-700',
CONTROLLER: 'bg-green-100 text-green-700',
NETWORK: 'bg-yellow-100 text-yellow-700',
MECHANICAL: 'bg-gray-100 text-gray-700',
ELECTRICAL: 'bg-red-100 text-red-700',
OTHER: 'bg-gray-100 text-gray-500',
}
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${colors[type] || colors.OTHER}`}>
{type}
</span>
)
}
function ComponentTreeNode({
component,
depth,
onEdit,
onDelete,
onAddChild,
}: {
component: Component
depth: number
onEdit: (c: Component) => void
onDelete: (id: string) => void
onAddChild: (parentId: string) => void
}) {
const [expanded, setExpanded] = useState(true)
const hasChildren = component.children && component.children.length > 0
return (
<div>
<div
className="flex items-center gap-2 py-2 px-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 group transition-colors"
style={{ paddingLeft: `${depth * 24 + 12}px` }}
>
{/* Expand/collapse */}
<button
onClick={() => setExpanded(!expanded)}
className={`w-5 h-5 flex items-center justify-center text-gray-400 ${hasChildren ? 'visible' : 'invisible'}`}
>
<svg
className={`w-4 h-4 transition-transform ${expanded ? '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>
</button>
<ComponentTypeIcon type={component.type} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-gray-900 dark:text-white">{component.name}</span>
{component.version && (
<span className="ml-2 text-xs text-gray-400">v{component.version}</span>
)}
{component.safety_relevant && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700">
Sicherheitsrelevant
</span>
)}
{component.library_component_id && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">
Bibliothek
</span>
)}
</div>
{component.description && (
<span className="text-xs text-gray-400 truncate max-w-[200px] hidden lg:block">
{component.description}
</span>
)}
{/* Actions */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => onAddChild(component.id)}
title="Unterkomponente hinzufuegen"
className="p-1 text-gray-400 hover:text-purple-600 hover:bg-purple-50 rounded transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</button>
<button
onClick={() => onEdit(component)}
title="Bearbeiten"
className="p-1 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => onDelete(component.id)}
title="Loeschen"
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
>
<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-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
{expanded && hasChildren && (
<div>
{component.children.map((child) => (
<ComponentTreeNode
key={child.id}
component={child}
depth={depth + 1}
onEdit={onEdit}
onDelete={onDelete}
onAddChild={onAddChild}
/>
))}
</div>
)}
</div>
)
}
interface ComponentFormData {
name: string
type: string
version: string
description: string
safety_relevant: boolean
parent_id: string | null
}
function ComponentForm({
onSubmit,
onCancel,
initialData,
parentId,
}: {
onSubmit: (data: ComponentFormData) => void
onCancel: () => void
initialData?: Component | null
parentId?: string | null
}) {
const [formData, setFormData] = useState<ComponentFormData>({
name: initialData?.name || '',
type: initialData?.type || 'SW',
version: initialData?.version || '',
description: initialData?.description || '',
safety_relevant: initialData?.safety_relevant || false,
parent_id: parentId || initialData?.parent_id || null,
})
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{initialData ? 'Komponente bearbeiten' : parentId ? 'Unterkomponente hinzufuegen' : 'Neue Komponente'}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name *</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. Bildverarbeitungsmodul"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Typ</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
{COMPONENT_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Version</label>
<input
type="text"
value={formData.version}
onChange={(e) => setFormData({ ...formData, version: e.target.value })}
placeholder="z.B. 1.2.0"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div className="flex items-center gap-3 pt-6">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={formData.safety_relevant}
onChange={(e) => setFormData({ ...formData, safety_relevant: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-red-500" />
</label>
<span className="text-sm text-gray-700 dark:text-gray-300">Sicherheitsrelevant</span>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Kurze Beschreibung der Komponente..."
rows={2}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
</div>
<div className="mt-4 flex items-center gap-3">
<button
onClick={() => onSubmit(formData)}
disabled={!formData.name}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
formData.name
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
{initialData ? 'Aktualisieren' : 'Hinzufuegen'}
</button>
<button
onClick={onCancel}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
Abbrechen
</button>
</div>
</div>
)
}
function buildTree(components: Component[]): Component[] {
const map = new Map<string, Component>()
const roots: Component[] = []
components.forEach((c) => {
map.set(c.id, { ...c, children: [] })
})
components.forEach((c) => {
const node = map.get(c.id)!
if (c.parent_id && map.has(c.parent_id)) {
map.get(c.parent_id)!.children.push(node)
} else {
roots.push(node)
}
})
return roots
}
// ============================================================================
// Component Library Modal (Phase 5)
// ============================================================================
function ComponentLibraryModal({
onAdd,
onClose,
}: {
onAdd: (components: LibraryComponent[], energySources: EnergySource[]) => void
onClose: () => void
}) {
const [libraryComponents, setLibraryComponents] = useState<LibraryComponent[]>([])
const [energySources, setEnergySources] = useState<EnergySource[]>([])
const [selectedComponents, setSelectedComponents] = useState<Set<string>>(new Set())
const [selectedEnergySources, setSelectedEnergySources] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
const [filterCategory, setFilterCategory] = useState('')
const [activeTab, setActiveTab] = useState<'components' | 'energy'>('components')
const [loading, setLoading] = useState(true)
useEffect(() => {
async function fetchData() {
try {
const [compRes, enRes] = await Promise.all([
fetch('/api/sdk/v1/iace/component-library'),
fetch('/api/sdk/v1/iace/energy-sources'),
])
if (compRes.ok) {
const json = await compRes.json()
setLibraryComponents(json.components || [])
}
if (enRes.ok) {
const json = await enRes.json()
setEnergySources(json.energy_sources || [])
}
} catch (err) {
console.error('Failed to fetch library:', err)
} finally {
setLoading(false)
}
}
fetchData()
}, [])
function toggleComponent(id: string) {
setSelectedComponents(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function toggleEnergySource(id: string) {
setSelectedEnergySources(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function toggleAllInCategory(category: string) {
const items = libraryComponents.filter(c => c.category === category)
const allIds = items.map(i => i.id)
const allSelected = allIds.every(id => selectedComponents.has(id))
setSelectedComponents(prev => {
const next = new Set(prev)
allIds.forEach(id => allSelected ? next.delete(id) : next.add(id))
return next
})
}
function handleAdd() {
const selComps = libraryComponents.filter(c => selectedComponents.has(c.id))
const selEnergy = energySources.filter(e => selectedEnergySources.has(e.id))
onAdd(selComps, selEnergy)
}
const filtered = libraryComponents.filter(c => {
if (filterCategory && c.category !== filterCategory) return false
if (search) {
const q = search.toLowerCase()
return c.name_de.toLowerCase().includes(q) || c.name_en.toLowerCase().includes(q) || c.description_de.toLowerCase().includes(q)
}
return true
})
const grouped = filtered.reduce<Record<string, LibraryComponent[]>>((acc, c) => {
if (!acc[c.category]) acc[c.category] = []
acc[c.category].push(c)
return acc
}, {})
const categories = Object.keys(LIBRARY_CATEGORIES)
const totalSelected = selectedComponents.size + selectedEnergySources.size
if (loading) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600 mx-auto" />
<p className="mt-3 text-sm text-gray-500">Bibliothek wird geladen...</p>
</div>
</div>
)
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-4xl max-h-[85vh] flex flex-col">
{/* Header */}
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Komponentenbibliothek</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Tabs */}
<div className="flex gap-2 mb-4">
<button
onClick={() => setActiveTab('components')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
activeTab === 'components' ? 'bg-purple-100 text-purple-700' : 'text-gray-500 hover:bg-gray-100'
}`}
>
Komponenten ({libraryComponents.length})
</button>
<button
onClick={() => setActiveTab('energy')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
activeTab === 'energy' ? 'bg-purple-100 text-purple-700' : 'text-gray-500 hover:bg-gray-100'
}`}
>
Energiequellen ({energySources.length})
</button>
</div>
{activeTab === 'components' && (
<div className="flex gap-3">
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Suchen..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
<select
value={filterCategory}
onChange={e => setFilterCategory(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
<option value="">Alle Kategorien</option>
{categories.map(cat => (
<option key={cat} value={cat}>{LIBRARY_CATEGORIES[cat]}</option>
))}
</select>
</div>
)}
</div>
{/* Body */}
<div className="flex-1 overflow-auto p-4">
{activeTab === 'components' ? (
<div className="space-y-4">
{Object.entries(grouped)
.sort(([a], [b]) => categories.indexOf(a) - categories.indexOf(b))
.map(([category, items]) => (
<div key={category}>
<div className="flex items-center gap-2 mb-2 sticky top-0 bg-white dark:bg-gray-800 py-1 z-10">
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
{LIBRARY_CATEGORIES[category] || category}
</h4>
<span className="text-xs text-gray-400">({items.length})</span>
<button
onClick={() => toggleAllInCategory(category)}
className="text-xs text-purple-600 hover:text-purple-700 ml-auto"
>
{items.every(i => selectedComponents.has(i.id)) ? 'Alle abwaehlen' : 'Alle waehlen'}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{items.map(comp => (
<label
key={comp.id}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedComponents.has(comp.id)
? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-750'
}`}
>
<input
type="checkbox"
checked={selectedComponents.has(comp.id)}
onChange={() => toggleComponent(comp.id)}
className="mt-0.5 accent-purple-600"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-gray-400">{comp.id}</span>
<ComponentTypeIcon type={comp.maps_to_component_type} />
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{comp.name_de}</div>
{comp.description_de && (
<div className="text-xs text-gray-500 mt-0.5 line-clamp-2">{comp.description_de}</div>
)}
</div>
</label>
))}
</div>
</div>
))}
{filtered.length === 0 && (
<div className="text-center py-8 text-gray-500">Keine Komponenten gefunden</div>
)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{energySources.map(es => (
<label
key={es.id}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
selectedEnergySources.has(es.id)
? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-750'
}`}
>
<input
type="checkbox"
checked={selectedEnergySources.has(es.id)}
onChange={() => toggleEnergySource(es.id)}
className="mt-0.5 accent-purple-600"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-gray-400">{es.id}</span>
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{es.name_de}</div>
{es.description_de && (
<div className="text-xs text-gray-500 mt-0.5 line-clamp-2">{es.description_de}</div>
)}
</div>
</label>
))}
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<span className="text-sm text-gray-500">
{selectedComponents.size} Komponenten, {selectedEnergySources.size} Energiequellen ausgewaehlt
</span>
<div className="flex gap-3">
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
<button
onClick={handleAdd}
disabled={totalSelected === 0}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
totalSelected > 0
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
{totalSelected > 0 ? `${totalSelected} hinzufuegen` : 'Auswaehlen'}
</button>
</div>
</div>
</div>
</div>
)
}
// ============================================================================
// Main Page
// ============================================================================
import { ComponentForm } from './_components/ComponentForm'
import { ComponentTreeNode } from './_components/ComponentTreeNode'
import { ComponentLibraryModal } from './_components/ComponentLibraryModal'
import { useComponents } from './_hooks/useComponents'
export default function ComponentsPage() {
const params = useParams()
const projectId = params.projectId as string
const [components, setComponents] = useState<Component[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editingComponent, setEditingComponent] = useState<Component | null>(null)
const [addingParentId, setAddingParentId] = useState<string | null>(null)
const [showLibrary, setShowLibrary] = useState(false)
const c = useComponents(projectId)
useEffect(() => {
fetchComponents()
}, [projectId])
async function fetchComponents() {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`)
if (res.ok) {
const json = await res.json()
setComponents(json.components || json || [])
}
} catch (err) {
console.error('Failed to fetch components:', err)
} finally {
setLoading(false)
}
}
async function handleSubmit(data: ComponentFormData) {
try {
const url = editingComponent
? `/api/sdk/v1/iace/projects/${projectId}/components/${editingComponent.id}`
: `/api/sdk/v1/iace/projects/${projectId}/components`
const method = editingComponent ? 'PUT' : 'POST'
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (res.ok) {
setShowForm(false)
setEditingComponent(null)
setAddingParentId(null)
await fetchComponents()
}
} catch (err) {
console.error('Failed to save component:', err)
}
}
async function handleDelete(id: string) {
if (!confirm('Komponente wirklich loeschen? Unterkomponenten werden ebenfalls entfernt.')) return
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components/${id}`, {
method: 'DELETE',
})
if (res.ok) {
await fetchComponents()
}
} catch (err) {
console.error('Failed to delete component:', err)
}
}
function handleEdit(component: Component) {
setEditingComponent(component)
setAddingParentId(null)
setShowForm(true)
}
function handleAddChild(parentId: string) {
setAddingParentId(parentId)
setEditingComponent(null)
setShowForm(true)
}
async function handleAddFromLibrary(libraryComps: LibraryComponent[], energySrcs: EnergySource[]) {
setShowLibrary(false)
const energySourceIds = energySrcs.map(e => e.id)
for (const comp of libraryComps) {
try {
await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: comp.name_de,
type: comp.maps_to_component_type,
description: comp.description_de,
safety_relevant: false,
library_component_id: comp.id,
energy_source_ids: energySourceIds,
tags: comp.tags,
}),
})
} catch (err) {
console.error(`Failed to add component ${comp.id}:`, err)
}
}
await fetchComponents()
}
const tree = buildTree(components)
if (loading) {
if (c.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" />
@@ -740,25 +29,18 @@ export default function ComponentsPage() {
Erfassen Sie alle Software-, Firmware-, KI- und Hardware-Komponenten der Maschine.
</p>
</div>
{!showForm && (
{!c.showForm && (
<div className="flex items-center gap-2">
<button
onClick={() => setShowLibrary(true)}
className="flex items-center gap-2 px-3 py-2 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors text-sm"
>
<button onClick={() => c.setShowLibrary(true)}
className="flex items-center gap-2 px-3 py-2 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-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="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
Aus Bibliothek waehlen
</button>
<button
onClick={() => {
setShowForm(true)
setEditingComponent(null)
setAddingParentId(null)
}}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
onClick={() => { c.setShowForm(true); c.setEditingComponent(null); c.setAddingParentId(null) }}
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>
@@ -768,30 +50,19 @@ export default function ComponentsPage() {
)}
</div>
{/* Library Modal */}
{showLibrary && (
<ComponentLibraryModal
onAdd={handleAddFromLibrary}
onClose={() => setShowLibrary(false)}
/>
{c.showLibrary && (
<ComponentLibraryModal onAdd={c.handleAddFromLibrary} onClose={() => c.setShowLibrary(false)} />
)}
{/* Form */}
{showForm && (
{c.showForm && (
<ComponentForm
onSubmit={handleSubmit}
onCancel={() => {
setShowForm(false)
setEditingComponent(null)
setAddingParentId(null)
}}
initialData={editingComponent}
parentId={addingParentId}
onSubmit={c.handleSubmit}
onCancel={() => { c.setShowForm(false); c.setEditingComponent(null); c.setAddingParentId(null) }}
initialData={c.editingComponent} parentId={c.addingParentId}
/>
)}
{/* Component Tree */}
{tree.length > 0 ? (
{c.tree.length > 0 ? (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700">
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-750 rounded-t-xl">
<div className="flex items-center gap-2 text-xs font-medium text-gray-500 uppercase tracking-wider">
@@ -803,20 +74,14 @@ export default function ComponentsPage() {
</div>
</div>
<div className="py-1">
{tree.map((component) => (
<ComponentTreeNode
key={component.id}
component={component}
depth={0}
onEdit={handleEdit}
onDelete={handleDelete}
onAddChild={handleAddChild}
/>
{c.tree.map((component) => (
<ComponentTreeNode key={component.id} component={component} depth={0}
onEdit={c.handleEdit} onDelete={c.handleDelete} onAddChild={c.handleAddChild} />
))}
</div>
</div>
) : (
!showForm && (
!c.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">
@@ -829,16 +94,12 @@ export default function ComponentsPage() {
Erstellen Sie eine hierarchische Struktur aus Software, Firmware, KI-Modulen und Hardware.
</p>
<div className="mt-6 flex items-center justify-center gap-3">
<button
onClick={() => setShowLibrary(true)}
className="px-6 py-3 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors"
>
<button onClick={() => c.setShowLibrary(true)}
className="px-6 py-3 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors">
Aus Bibliothek waehlen
</button>
<button
onClick={() => setShowForm(true)}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<button onClick={() => c.setShowForm(true)}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
Manuell hinzufuegen
</button>
</div>

View File

@@ -0,0 +1,22 @@
export function HierarchyWarning({ onDismiss }: { onDismiss: () => void }) {
return (
<div className="bg-amber-50 border border-amber-300 rounded-xl p-4 flex items-start gap-3">
<svg className="w-6 h-6 text-amber-600 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div className="flex-1">
<h4 className="text-sm font-semibold text-amber-800">Hierarchie-Warnung: Massnahmen vom Typ &quot;Information&quot;</h4>
<p className="text-sm text-amber-700 mt-1">
Hinweismassnahmen (Stufe 3) duerfen <strong>nicht als Primaermassnahme</strong> akzeptiert werden, wenn konstruktive
(Stufe 1) oder technische (Stufe 2) Massnahmen moeglich und zumutbar sind. Pruefen Sie, ob hoeherwertige
Massnahmen ergaenzt werden koennen.
</p>
</div>
<button onClick={onDismiss} className="text-amber-400 hover:text-amber-600 transition-colors">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)
}

View File

@@ -0,0 +1,89 @@
'use client'
import { useState } from 'react'
import { ProtectiveMeasure } from './types'
export function MeasuresLibraryModal({
measures, onSelect, onClose, filterType,
}: {
measures: ProtectiveMeasure[]
onSelect: (measure: ProtectiveMeasure) => void
onClose: () => void
filterType?: string
}) {
const [search, setSearch] = useState('')
const [selectedSubType, setSelectedSubType] = useState('')
const filtered = measures.filter((m) => {
if (filterType && m.reduction_type !== filterType) return false
if (selectedSubType && m.sub_type !== selectedSubType) return false
if (search) {
const q = search.toLowerCase()
return m.name.toLowerCase().includes(q) || m.description.toLowerCase().includes(q)
}
return true
})
const subTypes = [...new Set(measures.filter((m) => !filterType || m.reduction_type === filterType).map((m) => m.sub_type))].filter(Boolean)
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[80vh] flex flex-col">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Massnahmen-Bibliothek</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded transition-colors">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex gap-3">
<input type="text" value={search} onChange={(e) => setSearch(e.target.value)}
placeholder="Massnahme suchen..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white" />
{subTypes.length > 1 && (
<select value={selectedSubType} onChange={(e) => setSelectedSubType(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm">
<option value="">Alle Sub-Typen</option>
{subTypes.map((st) => <option key={st} value={st}>{st}</option>)}
</select>
)}
</div>
<div className="mt-2 text-xs text-gray-500">{filtered.length} Massnahmen</div>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-3">
{filtered.map((m) => (
<div key={m.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-purple-300 hover:bg-purple-50/30 transition-colors cursor-pointer"
onClick={() => onSelect(m)}>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-gray-400">{m.id}</span>
{m.sub_type && <span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{m.sub_type}</span>}
</div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{m.name}</h4>
<p className="text-xs text-gray-500 mt-1">{m.description}</p>
{m.examples && m.examples.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{m.examples.map((ex, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-600">{ex}</span>
))}
</div>
)}
</div>
<button className="ml-3 px-3 py-1.5 text-xs bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors flex-shrink-0">
Uebernehmen
</button>
</div>
</div>
))}
{filtered.length === 0 && (
<div className="text-center py-8 text-gray-500">Keine Massnahmen gefunden</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,50 @@
import { Mitigation } from './types'
import { StatusBadge } from './StatusBadge'
export function MitigationCard({
mitigation, onVerify, onDelete,
}: {
mitigation: Mitigation
onVerify: (id: string) => void
onDelete: (id: string) => void
}) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title}</h4>
{mitigation.title.startsWith('Auto:') && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">Auto</span>
)}
</div>
<StatusBadge status={mitigation.status} />
</div>
{mitigation.description && (
<p className="text-xs text-gray-500 mb-3">{mitigation.description}</p>
)}
{mitigation.linked_hazard_names.length > 0 && (
<div className="mb-3">
<div className="flex flex-wrap gap-1">
{mitigation.linked_hazard_names.map((name, i) => (
<span key={i} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
{name}
</span>
))}
</div>
</div>
)}
<div className="flex items-center gap-2">
{mitigation.status !== 'verified' && (
<button onClick={() => onVerify(mitigation.id)}
className="text-xs px-2.5 py-1 bg-green-50 text-green-700 border border-green-200 rounded-lg hover:bg-green-100 transition-colors">
Verifizieren
</button>
)}
<button onClick={() => onDelete(mitigation.id)}
className="text-xs px-2.5 py-1 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
Loeschen
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,98 @@
'use client'
import { useState } from 'react'
import { Hazard, MitigationFormData } from './types'
export function MitigationForm({
onSubmit, onCancel, hazards, preselectedType, onOpenLibrary,
}: {
onSubmit: (data: MitigationFormData) => void
onCancel: () => void
hazards: Hazard[]
preselectedType?: 'design' | 'protection' | 'information'
onOpenLibrary: (type?: string) => void
}) {
const [formData, setFormData] = useState<MitigationFormData>({
title: '',
description: '',
reduction_type: preselectedType || 'design',
linked_hazard_ids: [],
})
function toggleHazard(id: string) {
setFormData((prev) => ({
...prev,
linked_hazard_ids: prev.linked_hazard_ids.includes(id)
? prev.linked_hazard_ids.filter((h) => h !== id)
: [...prev.linked_hazard_ids, id],
}))
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Neue Massnahme</h3>
<button onClick={() => onOpenLibrary(formData.reduction_type)}
className="text-sm px-3 py-1.5 bg-purple-50 text-purple-700 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors">
Aus Bibliothek waehlen
</button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titel *</label>
<input type="text" value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="z.B. Lichtvorhang an Gefahrenstelle"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reduktionstyp</label>
<select value={formData.reduction_type}
onChange={(e) => setFormData({ ...formData, reduction_type: e.target.value as MitigationFormData['reduction_type'] })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white">
<option value="design">Stufe 1: Design - Inhaerent sichere Konstruktion</option>
<option value="protection">Stufe 2: Schutz - Technische Schutzmassnahmen</option>
<option value="information">Stufe 3: Information - Hinweise und Schulungen</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
<textarea value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2} placeholder="Detaillierte Beschreibung der Massnahme..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white" />
</div>
{hazards.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Verknuepfte Gefaehrdungen</label>
<div className="flex flex-wrap gap-2">
{hazards.map((h) => (
<button key={h.id} onClick={() => toggleHazard(h.id)}
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
formData.linked_hazard_ids.includes(h.id)
? 'border-purple-400 bg-purple-50 text-purple-700'
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'
}`}>
{h.name}
</button>
))}
</div>
</div>
)}
</div>
<div className="mt-4 flex items-center gap-3">
<button onClick={() => onSubmit(formData)} disabled={!formData.title}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
formData.title ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}>
Hinzufuegen
</button>
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,17 @@
export function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
planned: 'bg-gray-100 text-gray-700',
implemented: 'bg-blue-100 text-blue-700',
verified: 'bg-green-100 text-green-700',
}
const labels: Record<string, string> = {
planned: 'Geplant',
implemented: 'Umgesetzt',
verified: 'Verifiziert',
}
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${colors[status] || colors.planned}`}>
{labels[status] || status}
</span>
)
}

View File

@@ -0,0 +1,128 @@
'use client'
import { useState } from 'react'
import { Hazard, SuggestedMeasure, REDUCTION_TYPES } from './types'
export function SuggestMeasuresModal({
hazards, projectId, onAddMeasure, onClose,
}: {
hazards: Hazard[]
projectId: string
onAddMeasure: (title: string, description: string, reductionType: string, hazardId: string) => void
onClose: () => void
}) {
const [selectedHazard, setSelectedHazard] = useState<string>('')
const [suggested, setSuggested] = useState<SuggestedMeasure[]>([])
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
const riskColors: Record<string, string> = {
not_acceptable: 'border-red-400 bg-red-50',
very_high: 'border-red-300 bg-red-50',
critical: 'border-red-300 bg-red-50',
high: 'border-orange-300 bg-orange-50',
medium: 'border-yellow-300 bg-yellow-50',
low: 'border-green-300 bg-green-50',
}
async function handleSelectHazard(hazardId: string) {
setSelectedHazard(hazardId)
setSuggested([])
if (!hazardId) return
setLoadingSuggestions(true)
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/${hazardId}/suggest-measures`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
})
if (res.ok) { const json = await res.json(); setSuggested(json.suggested_measures || []) }
} catch (err) {
console.error('Failed to suggest measures:', err)
} finally {
setLoadingSuggestions(false)
}
}
const groupedByType = {
design: suggested.filter(m => m.reduction_type === 'design'),
protection: suggested.filter(m => m.reduction_type === 'protection'),
information: suggested.filter(m => m.reduction_type === 'information'),
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[85vh] flex flex-col">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Massnahmen-Vorschlaege</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-sm text-gray-500 mb-3">Waehlen Sie eine Gefaehrdung, um passende Massnahmen vorgeschlagen zu bekommen.</p>
<div className="flex flex-wrap gap-2">
{hazards.map(h => (
<button key={h.id} onClick={() => handleSelectHazard(h.id)}
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
selectedHazard === h.id
? 'border-purple-400 bg-purple-50 text-purple-700 font-medium'
: `${riskColors[h.risk_level] || 'border-gray-200 bg-white'} text-gray-700 hover:border-purple-300`
}`}>
{h.name}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-auto p-6">
{loadingSuggestions ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : suggested.length > 0 ? (
<div className="space-y-6">
{(['design', 'protection', 'information'] as const).map(type => {
const items = groupedByType[type]
if (items.length === 0) return null
const config = REDUCTION_TYPES[type]
return (
<div key={type}>
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-3`}>
{config.icon}
<span className="text-sm font-semibold">{config.label}</span>
<span className="ml-auto text-sm font-bold">{items.length}</span>
</div>
<div className="space-y-2">
{items.map(m => (
<div key={m.id} className="border border-gray-200 rounded-lg p-3 hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-gray-400">{m.id}</span>
{m.sub_type && <span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{m.sub_type}</span>}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{m.name}</div>
<div className="text-xs text-gray-500 mt-0.5">{m.description}</div>
</div>
<button onClick={() => onAddMeasure(m.name, m.description, m.reduction_type, selectedHazard)}
className="ml-3 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex-shrink-0">
Uebernehmen
</button>
</div>
</div>
))}
</div>
</div>
)
})}
</div>
) : selectedHazard ? (
<div className="text-center py-12 text-gray-500">Keine Vorschlaege fuer diese Gefaehrdung gefunden.</div>
) : (
<div className="text-center py-12 text-gray-500">Waehlen Sie eine Gefaehrdung aus, um Vorschlaege zu erhalten.</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,111 @@
export interface Mitigation {
id: string
title: string
description: string
reduction_type: 'design' | 'protection' | 'information'
status: 'planned' | 'implemented' | 'verified'
linked_hazard_ids: string[]
linked_hazard_names: string[]
created_at: string
verified_at: string | null
verified_by: string | null
source?: string
}
export interface Hazard {
id: string
name: string
risk_level: string
category?: string
}
export interface ProtectiveMeasure {
id: string
reduction_type: string
sub_type: string
name: string
description: string
hazard_category: string
examples: string[]
}
export interface SuggestedMeasure {
id: string
reduction_type: string
sub_type: string
name: string
description: string
hazard_category: string
examples: string[]
tags?: string[]
}
export interface MitigationFormData {
title: string
description: string
reduction_type: 'design' | 'protection' | 'information'
linked_hazard_ids: string[]
}
export const REDUCTION_TYPES = {
design: {
label: 'Stufe 1: Design',
description: 'Inhaerent sichere Konstruktion',
color: 'border-blue-200 bg-blue-50',
headerColor: 'bg-blue-100 text-blue-800',
subTypes: [
{ value: 'geometry', label: 'Geometrie & Anordnung' },
{ value: 'force_energy', label: 'Kraft & Energie' },
{ value: 'material', label: 'Material & Stabilitaet' },
{ value: 'ergonomics', label: 'Ergonomie' },
{ value: 'control_design', label: 'Steuerungstechnik' },
{ value: 'fluid_design', label: 'Pneumatik / Hydraulik' },
],
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
</svg>
),
},
protection: {
label: 'Stufe 2: Schutz',
description: 'Technische Schutzmassnahmen',
color: 'border-green-200 bg-green-50',
headerColor: 'bg-green-100 text-green-800',
subTypes: [
{ value: 'fixed_guard', label: 'Feststehende Schutzeinrichtung' },
{ value: 'movable_guard', label: 'Bewegliche Schutzeinrichtung' },
{ value: 'electro_sensitive', label: 'Optoelektronisch' },
{ value: 'pressure_sensitive', label: 'Druckempfindlich' },
{ value: 'emergency_stop', label: 'Not-Halt' },
{ value: 'electrical_protection', label: 'Elektrischer Schutz' },
{ value: 'thermal_protection', label: 'Thermischer Schutz' },
{ value: 'fluid_protection', label: 'Hydraulik/Pneumatik-Schutz' },
{ value: 'extraction', label: 'Absaugung / Kapselung' },
],
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
),
},
information: {
label: 'Stufe 3: Information',
description: 'Hinweise und Schulungen',
color: 'border-yellow-200 bg-yellow-50',
headerColor: 'bg-yellow-100 text-yellow-800',
subTypes: [
{ value: 'signage', label: 'Beschilderung & Kennzeichnung' },
{ value: 'manual', label: 'Betriebsanleitung' },
{ value: 'training', label: 'Schulung & Unterweisung' },
{ value: 'ppe', label: 'PSA (Schutzausruestung)' },
{ value: 'organizational', label: 'Organisatorisch' },
{ value: 'marking', label: 'Markierung & Codierung' },
],
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
}

View File

@@ -0,0 +1,134 @@
'use client'
import { useState, useEffect } from 'react'
import { Mitigation, Hazard, ProtectiveMeasure, MitigationFormData } from '../_components/types'
export function useMitigations(projectId: string) {
const [mitigations, setMitigations] = useState<Mitigation[]>([])
const [hazards, setHazards] = useState<Hazard[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [preselectedType, setPreselectedType] = useState<'design' | 'protection' | 'information' | undefined>()
const [hierarchyWarning, setHierarchyWarning] = useState<boolean>(false)
const [showLibrary, setShowLibrary] = useState(false)
const [libraryFilter, setLibraryFilter] = useState<string | undefined>()
const [measures, setMeasures] = useState<ProtectiveMeasure[]>([])
const [showSuggest, setShowSuggest] = useState(false)
useEffect(() => { fetchData() }, [projectId])
async function fetchData() {
try {
const [mitRes, hazRes] = await Promise.all([
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
])
if (mitRes.ok) {
const json = await mitRes.json()
const mits = json.mitigations || json || []
setMitigations(mits)
validateHierarchy(mits)
}
if (hazRes.ok) {
const json = await hazRes.json()
setHazards((json.hazards || json || []).map((h: Hazard) => ({ id: h.id, name: h.name, risk_level: h.risk_level, category: h.category })))
}
} catch (err) {
console.error('Failed to fetch data:', err)
} finally {
setLoading(false)
}
}
async function validateHierarchy(mits: Mitigation[]) {
if (mits.length === 0) return
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/validate-mitigation-hierarchy`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mitigations: mits.map((m) => ({ reduction_type: m.reduction_type, linked_hazard_ids: m.linked_hazard_ids })) }),
})
if (res.ok) { const json = await res.json(); setHierarchyWarning(json.has_warning === true) }
} catch { /* Non-critical, ignore */ }
}
async function fetchMeasuresLibrary(type?: string) {
try {
const url = type
? `/api/sdk/v1/iace/protective-measures-library?reduction_type=${type}`
: '/api/sdk/v1/iace/protective-measures-library'
const res = await fetch(url)
if (res.ok) { const json = await res.json(); setMeasures(json.protective_measures || []) }
} catch (err) {
console.error('Failed to fetch measures library:', err)
}
}
function handleOpenLibrary(type?: string) {
setLibraryFilter(type)
fetchMeasuresLibrary(type)
setShowLibrary(true)
}
function handleSelectMeasure(measure: ProtectiveMeasure) {
setShowLibrary(false)
setShowForm(true)
setPreselectedType(measure.reduction_type as 'design' | 'protection' | 'information')
}
async function handleSubmit(data: MitigationFormData) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
})
if (res.ok) { setShowForm(false); setPreselectedType(undefined); await fetchData() }
} catch (err) { console.error('Failed to add mitigation:', err) }
}
async function handleAddSuggestedMeasure(title: string, description: string, reductionType: string, hazardId: string) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description, reduction_type: reductionType, linked_hazard_ids: [hazardId] }),
})
if (res.ok) await fetchData()
} catch (err) { console.error('Failed to add suggested measure:', err) }
}
async function handleVerify(id: string) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}/verify`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
})
if (res.ok) await fetchData()
} catch (err) { console.error('Failed to verify mitigation:', err) }
}
async function handleDelete(id: string) {
if (!confirm('Massnahme wirklich loeschen?')) return
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}`, { method: 'DELETE' })
if (res.ok) await fetchData()
} catch (err) { console.error('Failed to delete mitigation:', err) }
}
function handleAddForType(type: 'design' | 'protection' | 'information') {
setPreselectedType(type)
setShowForm(true)
}
const byType = {
design: mitigations.filter((m) => m.reduction_type === 'design'),
protection: mitigations.filter((m) => m.reduction_type === 'protection'),
information: mitigations.filter((m) => m.reduction_type === 'information'),
}
return {
mitigations, hazards, loading, byType,
showForm, setShowForm, preselectedType, setPreselectedType,
hierarchyWarning, setHierarchyWarning,
showLibrary, setShowLibrary, libraryFilter, measures,
showSuggest, setShowSuggest,
handleOpenLibrary, handleSelectMeasure, handleSubmit,
handleAddSuggestedMeasure, handleVerify, handleDelete, handleAddForType,
}
}

View File

@@ -1,752 +1,20 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
interface Mitigation {
id: string
title: string
description: string
reduction_type: 'design' | 'protection' | 'information'
status: 'planned' | 'implemented' | 'verified'
linked_hazard_ids: string[]
linked_hazard_names: string[]
created_at: string
verified_at: string | null
verified_by: string | null
source?: string
}
interface Hazard {
id: string
name: string
risk_level: string
category?: string
}
interface ProtectiveMeasure {
id: string
reduction_type: string
sub_type: string
name: string
description: string
hazard_category: string
examples: string[]
}
interface SuggestedMeasure {
id: string
reduction_type: string
sub_type: string
name: string
description: string
hazard_category: string
examples: string[]
tags?: string[]
}
const REDUCTION_TYPES = {
design: {
label: 'Stufe 1: Design',
description: 'Inhaerent sichere Konstruktion',
color: 'border-blue-200 bg-blue-50',
headerColor: 'bg-blue-100 text-blue-800',
subTypes: [
{ value: 'geometry', label: 'Geometrie & Anordnung' },
{ value: 'force_energy', label: 'Kraft & Energie' },
{ value: 'material', label: 'Material & Stabilitaet' },
{ value: 'ergonomics', label: 'Ergonomie' },
{ value: 'control_design', label: 'Steuerungstechnik' },
{ value: 'fluid_design', label: 'Pneumatik / Hydraulik' },
],
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
</svg>
),
},
protection: {
label: 'Stufe 2: Schutz',
description: 'Technische Schutzmassnahmen',
color: 'border-green-200 bg-green-50',
headerColor: 'bg-green-100 text-green-800',
subTypes: [
{ value: 'fixed_guard', label: 'Feststehende Schutzeinrichtung' },
{ value: 'movable_guard', label: 'Bewegliche Schutzeinrichtung' },
{ value: 'electro_sensitive', label: 'Optoelektronisch' },
{ value: 'pressure_sensitive', label: 'Druckempfindlich' },
{ value: 'emergency_stop', label: 'Not-Halt' },
{ value: 'electrical_protection', label: 'Elektrischer Schutz' },
{ value: 'thermal_protection', label: 'Thermischer Schutz' },
{ value: 'fluid_protection', label: 'Hydraulik/Pneumatik-Schutz' },
{ value: 'extraction', label: 'Absaugung / Kapselung' },
],
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
),
},
information: {
label: 'Stufe 3: Information',
description: 'Hinweise und Schulungen',
color: 'border-yellow-200 bg-yellow-50',
headerColor: 'bg-yellow-100 text-yellow-800',
subTypes: [
{ value: 'signage', label: 'Beschilderung & Kennzeichnung' },
{ value: 'manual', label: 'Betriebsanleitung' },
{ value: 'training', label: 'Schulung & Unterweisung' },
{ value: 'ppe', label: 'PSA (Schutzausruestung)' },
{ value: 'organizational', label: 'Organisatorisch' },
{ value: 'marking', label: 'Markierung & Codierung' },
],
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
}
function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
planned: 'bg-gray-100 text-gray-700',
implemented: 'bg-blue-100 text-blue-700',
verified: 'bg-green-100 text-green-700',
}
const labels: Record<string, string> = {
planned: 'Geplant',
implemented: 'Umgesetzt',
verified: 'Verifiziert',
}
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${colors[status] || colors.planned}`}>
{labels[status] || status}
</span>
)
}
function HierarchyWarning({ onDismiss }: { onDismiss: () => void }) {
return (
<div className="bg-amber-50 border border-amber-300 rounded-xl p-4 flex items-start gap-3">
<svg className="w-6 h-6 text-amber-600 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div className="flex-1">
<h4 className="text-sm font-semibold text-amber-800">Hierarchie-Warnung: Massnahmen vom Typ &quot;Information&quot;</h4>
<p className="text-sm text-amber-700 mt-1">
Hinweismassnahmen (Stufe 3) duerfen <strong>nicht als Primaermassnahme</strong> akzeptiert werden, wenn konstruktive
(Stufe 1) oder technische (Stufe 2) Massnahmen moeglich und zumutbar sind. Pruefen Sie, ob hoeherwertige
Massnahmen ergaenzt werden koennen.
</p>
</div>
<button onClick={onDismiss} className="text-amber-400 hover:text-amber-600 transition-colors">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)
}
function MeasuresLibraryModal({
measures,
onSelect,
onClose,
filterType,
}: {
measures: ProtectiveMeasure[]
onSelect: (measure: ProtectiveMeasure) => void
onClose: () => void
filterType?: string
}) {
const [search, setSearch] = useState('')
const [selectedSubType, setSelectedSubType] = useState('')
const filtered = measures.filter((m) => {
if (filterType && m.reduction_type !== filterType) return false
if (selectedSubType && m.sub_type !== selectedSubType) return false
if (search) {
const q = search.toLowerCase()
return m.name.toLowerCase().includes(q) || m.description.toLowerCase().includes(q)
}
return true
})
const subTypes = [...new Set(measures.filter((m) => !filterType || m.reduction_type === filterType).map((m) => m.sub_type))].filter(Boolean)
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[80vh] flex flex-col">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Massnahmen-Bibliothek</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded transition-colors">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex gap-3">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Massnahme suchen..."
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
{subTypes.length > 1 && (
<select
value={selectedSubType}
onChange={(e) => setSelectedSubType(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm"
>
<option value="">Alle Sub-Typen</option>
{subTypes.map((st) => (
<option key={st} value={st}>{st}</option>
))}
</select>
)}
</div>
<div className="mt-2 text-xs text-gray-500">{filtered.length} Massnahmen</div>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-3">
{filtered.map((m) => (
<div
key={m.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-purple-300 hover:bg-purple-50/30 transition-colors cursor-pointer"
onClick={() => onSelect(m)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-gray-400">{m.id}</span>
{m.sub_type && (
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{m.sub_type}</span>
)}
</div>
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{m.name}</h4>
<p className="text-xs text-gray-500 mt-1">{m.description}</p>
{m.examples && m.examples.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{m.examples.map((ex, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-600">
{ex}
</span>
))}
</div>
)}
</div>
<button className="ml-3 px-3 py-1.5 text-xs bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors flex-shrink-0">
Uebernehmen
</button>
</div>
</div>
))}
{filtered.length === 0 && (
<div className="text-center py-8 text-gray-500">Keine Massnahmen gefunden</div>
)}
</div>
</div>
</div>
)
}
// ============================================================================
// Suggest Measures Modal (Phase 5)
// ============================================================================
function SuggestMeasuresModal({
hazards,
projectId,
onAddMeasure,
onClose,
}: {
hazards: Hazard[]
projectId: string
onAddMeasure: (title: string, description: string, reductionType: string, hazardId: string) => void
onClose: () => void
}) {
const [selectedHazard, setSelectedHazard] = useState<string>('')
const [suggested, setSuggested] = useState<SuggestedMeasure[]>([])
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
const riskColors: Record<string, string> = {
not_acceptable: 'border-red-400 bg-red-50',
very_high: 'border-red-300 bg-red-50',
critical: 'border-red-300 bg-red-50',
high: 'border-orange-300 bg-orange-50',
medium: 'border-yellow-300 bg-yellow-50',
low: 'border-green-300 bg-green-50',
}
async function handleSelectHazard(hazardId: string) {
setSelectedHazard(hazardId)
setSuggested([])
if (!hazardId) return
setLoadingSuggestions(true)
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/${hazardId}/suggest-measures`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (res.ok) {
const json = await res.json()
setSuggested(json.suggested_measures || [])
}
} catch (err) {
console.error('Failed to suggest measures:', err)
} finally {
setLoadingSuggestions(false)
}
}
const groupedByType = {
design: suggested.filter(m => m.reduction_type === 'design'),
protection: suggested.filter(m => m.reduction_type === 'protection'),
information: suggested.filter(m => m.reduction_type === 'information'),
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[85vh] flex flex-col">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Massnahmen-Vorschlaege</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-sm text-gray-500 mb-3">
Waehlen Sie eine Gefaehrdung, um passende Massnahmen vorgeschlagen zu bekommen.
</p>
<div className="flex flex-wrap gap-2">
{hazards.map(h => (
<button
key={h.id}
onClick={() => handleSelectHazard(h.id)}
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
selectedHazard === h.id
? 'border-purple-400 bg-purple-50 text-purple-700 font-medium'
: `${riskColors[h.risk_level] || 'border-gray-200 bg-white'} text-gray-700 hover:border-purple-300`
}`}
>
{h.name}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-auto p-6">
{loadingSuggestions ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : suggested.length > 0 ? (
<div className="space-y-6">
{(['design', 'protection', 'information'] as const).map(type => {
const items = groupedByType[type]
if (items.length === 0) return null
const config = REDUCTION_TYPES[type]
return (
<div key={type}>
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-3`}>
{config.icon}
<span className="text-sm font-semibold">{config.label}</span>
<span className="ml-auto text-sm font-bold">{items.length}</span>
</div>
<div className="space-y-2">
{items.map(m => (
<div key={m.id} className="border border-gray-200 rounded-lg p-3 hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-gray-400">{m.id}</span>
{m.sub_type && (
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{m.sub_type}</span>
)}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{m.name}</div>
<div className="text-xs text-gray-500 mt-0.5">{m.description}</div>
</div>
<button
onClick={() => onAddMeasure(m.name, m.description, m.reduction_type, selectedHazard)}
className="ml-3 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex-shrink-0"
>
Uebernehmen
</button>
</div>
</div>
))}
</div>
</div>
)
})}
</div>
) : selectedHazard ? (
<div className="text-center py-12 text-gray-500">
Keine Vorschlaege fuer diese Gefaehrdung gefunden.
</div>
) : (
<div className="text-center py-12 text-gray-500">
Waehlen Sie eine Gefaehrdung aus, um Vorschlaege zu erhalten.
</div>
)}
</div>
</div>
</div>
)
}
interface MitigationFormData {
title: string
description: string
reduction_type: 'design' | 'protection' | 'information'
linked_hazard_ids: string[]
}
function MitigationForm({
onSubmit,
onCancel,
hazards,
preselectedType,
onOpenLibrary,
}: {
onSubmit: (data: MitigationFormData) => void
onCancel: () => void
hazards: Hazard[]
preselectedType?: 'design' | 'protection' | 'information'
onOpenLibrary: (type?: string) => void
}) {
const [formData, setFormData] = useState<MitigationFormData>({
title: '',
description: '',
reduction_type: preselectedType || 'design',
linked_hazard_ids: [],
})
function toggleHazard(id: string) {
setFormData((prev) => ({
...prev,
linked_hazard_ids: prev.linked_hazard_ids.includes(id)
? prev.linked_hazard_ids.filter((h) => h !== id)
: [...prev.linked_hazard_ids, id],
}))
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Neue Massnahme</h3>
<button
onClick={() => onOpenLibrary(formData.reduction_type)}
className="text-sm px-3 py-1.5 bg-purple-50 text-purple-700 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors"
>
Aus Bibliothek waehlen
</button>
</div>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titel *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="z.B. Lichtvorhang an Gefahrenstelle"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reduktionstyp</label>
<select
value={formData.reduction_type}
onChange={(e) => setFormData({ ...formData, reduction_type: e.target.value as MitigationFormData['reduction_type'] })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
<option value="design">Stufe 1: Design - Inhaerent sichere Konstruktion</option>
<option value="protection">Stufe 2: Schutz - Technische Schutzmassnahmen</option>
<option value="information">Stufe 3: Information - Hinweise und Schulungen</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
placeholder="Detaillierte Beschreibung der Massnahme..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
{hazards.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Verknuepfte Gefaehrdungen</label>
<div className="flex flex-wrap gap-2">
{hazards.map((h) => (
<button
key={h.id}
onClick={() => toggleHazard(h.id)}
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
formData.linked_hazard_ids.includes(h.id)
? 'border-purple-400 bg-purple-50 text-purple-700'
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'
}`}
>
{h.name}
</button>
))}
</div>
</div>
)}
</div>
<div className="mt-4 flex items-center gap-3">
<button
onClick={() => onSubmit(formData)}
disabled={!formData.title}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
formData.title
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Hinzufuegen
</button>
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
</div>
</div>
)
}
function MitigationCard({
mitigation,
onVerify,
onDelete,
}: {
mitigation: Mitigation
onVerify: (id: string) => void
onDelete: (id: string) => void
}) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title}</h4>
{mitigation.title.startsWith('Auto:') && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">
Auto
</span>
)}
</div>
<StatusBadge status={mitigation.status} />
</div>
{mitigation.description && (
<p className="text-xs text-gray-500 mb-3">{mitigation.description}</p>
)}
{mitigation.linked_hazard_names.length > 0 && (
<div className="mb-3">
<div className="flex flex-wrap gap-1">
{mitigation.linked_hazard_names.map((name, i) => (
<span key={i} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
{name}
</span>
))}
</div>
</div>
)}
<div className="flex items-center gap-2">
{mitigation.status !== 'verified' && (
<button
onClick={() => onVerify(mitigation.id)}
className="text-xs px-2.5 py-1 bg-green-50 text-green-700 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
>
Verifizieren
</button>
)}
<button
onClick={() => onDelete(mitigation.id)}
className="text-xs px-2.5 py-1 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
Loeschen
</button>
</div>
</div>
)
}
import { REDUCTION_TYPES } from './_components/types'
import { HierarchyWarning } from './_components/HierarchyWarning'
import { MitigationForm } from './_components/MitigationForm'
import { MitigationCard } from './_components/MitigationCard'
import { MeasuresLibraryModal } from './_components/MeasuresLibraryModal'
import { SuggestMeasuresModal } from './_components/SuggestMeasuresModal'
import { useMitigations } from './_hooks/useMitigations'
export default function MitigationsPage() {
const params = useParams()
const projectId = params.projectId as string
const [mitigations, setMitigations] = useState<Mitigation[]>([])
const [hazards, setHazards] = useState<Hazard[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [preselectedType, setPreselectedType] = useState<'design' | 'protection' | 'information' | undefined>()
const [hierarchyWarning, setHierarchyWarning] = useState<boolean>(false)
const [showLibrary, setShowLibrary] = useState(false)
const [libraryFilter, setLibraryFilter] = useState<string | undefined>()
const [measures, setMeasures] = useState<ProtectiveMeasure[]>([])
// Phase 5: Suggest measures
const [showSuggest, setShowSuggest] = useState(false)
const m = useMitigations(projectId)
useEffect(() => {
fetchData()
}, [projectId])
async function fetchData() {
try {
const [mitRes, hazRes] = await Promise.all([
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
])
if (mitRes.ok) {
const json = await mitRes.json()
const mits = json.mitigations || json || []
setMitigations(mits)
// Check hierarchy: if information-only measures exist without design/protection
validateHierarchy(mits)
}
if (hazRes.ok) {
const json = await hazRes.json()
setHazards((json.hazards || json || []).map((h: Hazard) => ({ id: h.id, name: h.name, risk_level: h.risk_level, category: h.category })))
}
} catch (err) {
console.error('Failed to fetch data:', err)
} finally {
setLoading(false)
}
}
async function validateHierarchy(mits: Mitigation[]) {
if (mits.length === 0) return
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/validate-mitigation-hierarchy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mitigations: mits.map((m) => ({
reduction_type: m.reduction_type,
linked_hazard_ids: m.linked_hazard_ids,
})),
}),
})
if (res.ok) {
const json = await res.json()
setHierarchyWarning(json.has_warning === true)
}
} catch {
// Non-critical, ignore
}
}
async function fetchMeasuresLibrary(type?: string) {
try {
const url = type
? `/api/sdk/v1/iace/protective-measures-library?reduction_type=${type}`
: '/api/sdk/v1/iace/protective-measures-library'
const res = await fetch(url)
if (res.ok) {
const json = await res.json()
setMeasures(json.protective_measures || [])
}
} catch (err) {
console.error('Failed to fetch measures library:', err)
}
}
function handleOpenLibrary(type?: string) {
setLibraryFilter(type)
fetchMeasuresLibrary(type)
setShowLibrary(true)
}
function handleSelectMeasure(measure: ProtectiveMeasure) {
setShowLibrary(false)
setShowForm(true)
setPreselectedType(measure.reduction_type as 'design' | 'protection' | 'information')
}
async function handleSubmit(data: MitigationFormData) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (res.ok) {
setShowForm(false)
setPreselectedType(undefined)
await fetchData()
}
} catch (err) {
console.error('Failed to add mitigation:', err)
}
}
async function handleAddSuggestedMeasure(title: string, description: string, reductionType: string, hazardId: string) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description,
reduction_type: reductionType,
linked_hazard_ids: [hazardId],
}),
})
if (res.ok) {
await fetchData()
}
} catch (err) {
console.error('Failed to add suggested measure:', err)
}
}
async function handleVerify(id: string) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (res.ok) {
await fetchData()
}
} catch (err) {
console.error('Failed to verify mitigation:', err)
}
}
async function handleDelete(id: string) {
if (!confirm('Massnahme wirklich loeschen?')) return
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}`, { method: 'DELETE' })
if (res.ok) {
await fetchData()
}
} catch (err) {
console.error('Failed to delete mitigation:', err)
}
}
function handleAddForType(type: 'design' | 'protection' | 'information') {
setPreselectedType(type)
setShowForm(true)
}
const byType = {
design: mitigations.filter((m) => m.reduction_type === 'design'),
protection: mitigations.filter((m) => m.reduction_type === 'protection'),
information: mitigations.filter((m) => m.reduction_type === 'information'),
}
if (loading) {
if (m.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" />
@@ -765,33 +33,24 @@ export default function MitigationsPage() {
</p>
</div>
<div className="flex items-center gap-3">
{hazards.length > 0 && (
<button
onClick={() => 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"
>
{m.hazards.length > 0 && (
<button onClick={() => m.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>
Vorschlaege
</button>
)}
<button
onClick={() => handleOpenLibrary()}
className="flex items-center gap-2 px-4 py-2 bg-white border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors"
>
<button onClick={() => m.handleOpenLibrary()}
className="flex items-center gap-2 px-4 py-2 bg-white border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
Bibliothek
</button>
<button
onClick={() => {
setPreselectedType(undefined)
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"
>
<button onClick={() => { m.setPreselectedType(undefined); m.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>
@@ -800,42 +59,29 @@ export default function MitigationsPage() {
</div>
</div>
{/* Hierarchy Warning */}
{hierarchyWarning && (
<HierarchyWarning onDismiss={() => setHierarchyWarning(false)} />
)}
{m.hierarchyWarning && <HierarchyWarning onDismiss={() => m.setHierarchyWarning(false)} />}
{/* Form */}
{showForm && (
{m.showForm && (
<MitigationForm
onSubmit={handleSubmit}
onCancel={() => {
setShowForm(false)
setPreselectedType(undefined)
}}
hazards={hazards}
preselectedType={preselectedType}
onOpenLibrary={handleOpenLibrary}
onSubmit={m.handleSubmit}
onCancel={() => { m.setShowForm(false); m.setPreselectedType(undefined) }}
hazards={m.hazards} preselectedType={m.preselectedType}
onOpenLibrary={m.handleOpenLibrary}
/>
)}
{/* Measures Library Modal */}
{showLibrary && (
{m.showLibrary && (
<MeasuresLibraryModal
measures={measures}
onSelect={handleSelectMeasure}
onClose={() => setShowLibrary(false)}
filterType={libraryFilter}
measures={m.measures} onSelect={m.handleSelectMeasure}
onClose={() => m.setShowLibrary(false)} filterType={m.libraryFilter}
/>
)}
{/* Suggest Measures Modal (Phase 5) */}
{showSuggest && (
{m.showSuggest && (
<SuggestMeasuresModal
hazards={hazards}
projectId={projectId}
onAddMeasure={handleAddSuggestedMeasure}
onClose={() => setShowSuggest(false)}
hazards={m.hazards} projectId={projectId}
onAddMeasure={m.handleAddSuggestedMeasure}
onClose={() => m.setShowSuggest(false)}
/>
)}
@@ -843,7 +89,7 @@ export default function MitigationsPage() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{(['design', 'protection', 'information'] as const).map((type) => {
const config = REDUCTION_TYPES[type]
const items = byType[type]
const items = m.byType[type]
return (
<div key={type} className={`rounded-xl border ${config.color} p-4`}>
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-3`}>
@@ -854,8 +100,6 @@ export default function MitigationsPage() {
</div>
<span className="ml-auto text-sm font-bold">{items.length}</span>
</div>
{/* Sub-types overview */}
<div className="mb-3 flex flex-wrap gap-1">
{config.subTypes.map((st) => (
<span key={st.value} className="text-xs px-1.5 py-0.5 rounded bg-white/60 text-gray-500 border border-gray-200/50">
@@ -863,30 +107,19 @@ export default function MitigationsPage() {
</span>
))}
</div>
<div className="space-y-3">
{items.map((m) => (
<MitigationCard
key={m.id}
mitigation={m}
onVerify={handleVerify}
onDelete={handleDelete}
/>
{items.map((item) => (
<MitigationCard key={item.id} mitigation={item} onVerify={m.handleVerify} onDelete={m.handleDelete} />
))}
</div>
<div className="mt-3 flex gap-2">
<button
onClick={() => handleAddForType(type)}
className="flex-1 py-2 text-sm text-gray-500 hover:text-purple-600 hover:bg-white rounded-lg border border-dashed border-gray-300 hover:border-purple-300 transition-colors"
>
<button onClick={() => m.handleAddForType(type)}
className="flex-1 py-2 text-sm text-gray-500 hover:text-purple-600 hover:bg-white rounded-lg border border-dashed border-gray-300 hover:border-purple-300 transition-colors">
+ Hinzufuegen
</button>
<button
onClick={() => handleOpenLibrary(type)}
<button onClick={() => m.handleOpenLibrary(type)}
className="py-2 px-3 text-sm text-gray-400 hover:text-purple-600 hover:bg-white rounded-lg border border-dashed border-gray-300 hover:border-purple-300 transition-colors"
title="Aus Bibliothek waehlen"
>
title="Aus Bibliothek waehlen">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>

View File

@@ -0,0 +1,74 @@
'use client'
import { useState } from 'react'
interface VerificationItem {
id: string
title: string
[key: string]: unknown
}
export function CompleteModal({
item,
onSubmit,
onClose,
}: {
item: VerificationItem
onSubmit: (id: string, result: string, passed: boolean) => void
onClose: () => void
}) {
const [result, setResult] = useState('')
const [passed, setPassed] = useState(true)
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Verifikation abschliessen: {item.title}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ergebnis</label>
<textarea
value={result} onChange={(e) => setResult(e.target.value)}
rows={3} placeholder="Beschreiben Sie das Ergebnis der Verifikation..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Bewertung</label>
<div className="flex gap-3">
<button
onClick={() => setPassed(true)}
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
passed ? 'border-green-400 bg-green-50 text-green-700' : 'border-gray-200 text-gray-500 hover:bg-gray-50'
}`}
>
Bestanden
</button>
<button
onClick={() => setPassed(false)}
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
!passed ? 'border-red-400 bg-red-50 text-red-700' : 'border-gray-200 text-gray-500 hover:bg-gray-50'
}`}
>
Nicht bestanden
</button>
</div>
</div>
</div>
<div className="mt-6 flex items-center gap-3">
<button
onClick={() => onSubmit(item.id, result, passed)} disabled={!result}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
result ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Abschliessen
</button>
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">Abbrechen</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,15 @@
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
pending: { label: 'Ausstehend', color: 'bg-gray-100 text-gray-700' },
in_progress: { label: 'In Bearbeitung', color: 'bg-blue-100 text-blue-700' },
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700' },
failed: { label: 'Fehlgeschlagen', color: 'bg-red-100 text-red-700' },
}
export function StatusBadge({ status }: { status: string }) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.pending
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
{config.label}
</span>
)
}

View File

@@ -0,0 +1,126 @@
'use client'
import { useState } from 'react'
interface SuggestedEvidence {
id: string
name: string
description: string
method: string
tags?: string[]
}
const VERIFICATION_METHOD_LABELS: Record<string, string> = {
design_review: 'Design-Review',
calculation: 'Berechnung',
test_report: 'Pruefbericht',
validation: 'Validierung',
electrical_test: 'Elektrische Pruefung',
software_test: 'Software-Test',
penetration_test: 'Penetrationstest',
acceptance_protocol: 'Abnahmeprotokoll',
user_test: 'Anwendertest',
documentation_release: 'Dokumentenfreigabe',
}
export function SuggestEvidenceModal({
mitigations,
projectId,
onAddEvidence,
onClose,
}: {
mitigations: { id: string; title: string }[]
projectId: string
onAddEvidence: (title: string, description: string, method: string, mitigationId: string) => void
onClose: () => void
}) {
const [selectedMitigation, setSelectedMitigation] = useState<string>('')
const [suggested, setSuggested] = useState<SuggestedEvidence[]>([])
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
async function handleSelectMitigation(mitigationId: string) {
setSelectedMitigation(mitigationId)
setSuggested([])
if (!mitigationId) return
setLoadingSuggestions(true)
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${mitigationId}/suggest-evidence`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
})
if (res.ok) { const json = await res.json(); setSuggested(json.suggested_evidence || []) }
} catch (err) { console.error('Failed to suggest evidence:', err) }
finally { setLoadingSuggestions(false) }
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[85vh] flex flex-col">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Nachweise vorschlagen</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-sm text-gray-500 mb-3">
Waehlen Sie eine Massnahme, um passende Nachweismethoden vorgeschlagen zu bekommen.
</p>
<div className="flex flex-wrap gap-2">
{mitigations.map(m => (
<button key={m.id} onClick={() => handleSelectMitigation(m.id)}
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
selectedMitigation === m.id
? 'border-purple-400 bg-purple-50 text-purple-700 font-medium'
: 'border-gray-200 bg-white text-gray-700 hover:border-purple-300'
}`}
>
{m.title}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-auto p-6">
{loadingSuggestions ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : suggested.length > 0 ? (
<div className="space-y-3">
{suggested.map(ev => (
<div key={ev.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-gray-400">{ev.id}</span>
{ev.method && (
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-600">
{VERIFICATION_METHOD_LABELS[ev.method] || ev.method}
</span>
)}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{ev.name}</div>
<div className="text-xs text-gray-500 mt-0.5">{ev.description}</div>
</div>
<button
onClick={() => onAddEvidence(ev.name, ev.description, ev.method || 'test_report', selectedMitigation)}
className="ml-3 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex-shrink-0"
>
Uebernehmen
</button>
</div>
</div>
))}
</div>
) : selectedMitigation ? (
<div className="text-center py-12 text-gray-500">Keine Vorschlaege fuer diese Massnahme gefunden.</div>
) : (
<div className="text-center py-12 text-gray-500">Waehlen Sie eine Massnahme aus, um Nachweise vorgeschlagen zu bekommen.</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,115 @@
'use client'
import { useState } from 'react'
export interface VerificationFormData {
title: string
description: string
method: string
linked_hazard_id: string
linked_mitigation_id: string
}
const VERIFICATION_METHODS = [
{ value: 'design_review', label: 'Design-Review' },
{ value: 'calculation', label: 'Berechnung' },
{ value: 'test_report', label: 'Pruefbericht' },
{ value: 'validation', label: 'Validierung' },
{ value: 'electrical_test', label: 'Elektrische Pruefung' },
{ value: 'software_test', label: 'Software-Test' },
{ value: 'penetration_test', label: 'Penetrationstest' },
{ value: 'acceptance_protocol', label: 'Abnahmeprotokoll' },
{ value: 'user_test', label: 'Anwendertest' },
{ value: 'documentation_release', label: 'Dokumentenfreigabe' },
]
export function VerificationForm({
onSubmit,
onCancel,
hazards,
mitigations,
}: {
onSubmit: (data: VerificationFormData) => void
onCancel: () => void
hazards: { id: string; name: string }[]
mitigations: { id: string; title: string }[]
}) {
const [formData, setFormData] = useState<VerificationFormData>({
title: '', description: '', method: 'test', linked_hazard_id: '', linked_mitigation_id: '',
})
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Neues Verifikationselement</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titel *</label>
<input
type="text" value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="z.B. Funktionstest Lichtvorhang"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Methode</label>
<select
value={formData.method}
onChange={(e) => setFormData({ ...formData, method: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
{VERIFICATION_METHODS.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2} placeholder="Beschreiben Sie den Verifikationsschritt..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Verknuepfte Gefaehrdung</label>
<select
value={formData.linked_hazard_id}
onChange={(e) => setFormData({ ...formData, linked_hazard_id: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
<option value="">-- Keine --</option>
{hazards.map((h) => <option key={h.id} value={h.id}>{h.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Verknuepfte Massnahme</label>
<select
value={formData.linked_mitigation_id}
onChange={(e) => setFormData({ ...formData, linked_mitigation_id: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
<option value="">-- Keine --</option>
{mitigations.map((m) => <option key={m.id} value={m.id}>{m.title}</option>)}
</select>
</div>
</div>
</div>
<div className="mt-4 flex items-center gap-3">
<button
onClick={() => onSubmit(formData)} disabled={!formData.title}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
formData.title ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Hinzufuegen
</button>
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">Abbrechen</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,93 @@
import { StatusBadge } from './StatusBadge'
interface VerificationItem {
id: string
title: string
description: string
method: string
status: 'pending' | 'in_progress' | 'completed' | 'failed'
result: string | null
linked_hazard_name: string | null
linked_mitigation_name: string | null
completed_at: string | null
completed_by: string | null
created_at: string
}
const VERIFICATION_METHOD_LABELS: Record<string, string> = {
design_review: 'Design-Review', calculation: 'Berechnung', test_report: 'Pruefbericht',
validation: 'Validierung', electrical_test: 'Elektrische Pruefung', software_test: 'Software-Test',
penetration_test: 'Penetrationstest', acceptance_protocol: 'Abnahmeprotokoll',
user_test: 'Anwendertest', documentation_release: 'Dokumentenfreigabe',
}
export function VerificationTable({
items,
onComplete,
onDelete,
}: {
items: VerificationItem[]
onComplete: (item: VerificationItem) => void
onDelete: (id: string) => void
}) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gray-50 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Titel</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Methode</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Gefaehrdung</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Massnahme</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ergebnis</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{items.map((item) => (
<tr key={item.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
<td className="px-4 py-3">
<div className="text-sm font-medium text-gray-900 dark:text-white">{item.title}</div>
{item.description && (
<div className="text-xs text-gray-500 truncate max-w-[200px]">{item.description}</div>
)}
</td>
<td className="px-4 py-3">
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
{VERIFICATION_METHOD_LABELS[item.method] || item.method}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-600">{item.linked_hazard_name || '--'}</td>
<td className="px-4 py-3 text-sm text-gray-600">{item.linked_mitigation_name || '--'}</td>
<td className="px-4 py-3"><StatusBadge status={item.status} /></td>
<td className="px-4 py-3 text-sm text-gray-600 max-w-[150px] truncate">{item.result || '--'}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
{item.status !== 'completed' && item.status !== 'failed' && (
<button
onClick={() => onComplete(item)}
className="text-xs px-2.5 py-1 bg-green-50 text-green-700 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
>
Abschliessen
</button>
)}
<button
onClick={() => onDelete(item.id)}
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
>
<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-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -2,6 +2,11 @@
import React, { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import { VerificationForm } from './_components/VerificationForm'
import { CompleteModal } from './_components/CompleteModal'
import { SuggestEvidenceModal } from './_components/SuggestEvidenceModal'
import { VerificationTable } from './_components/VerificationTable'
import type { VerificationFormData } from './_components/VerificationForm'
interface VerificationItem {
id: string
@@ -19,360 +24,6 @@ interface VerificationItem {
created_at: string
}
interface SuggestedEvidence {
id: string
name: string
description: string
method: string
tags?: string[]
}
const VERIFICATION_METHODS = [
{ value: 'design_review', label: 'Design-Review', description: 'Systematische Pruefung der Konstruktionsunterlagen' },
{ value: 'calculation', label: 'Berechnung', description: 'Rechnerischer Nachweis (FEM, Festigkeit, Thermik)' },
{ value: 'test_report', label: 'Pruefbericht', description: 'Dokumentierter Test mit Messprotokoll' },
{ value: 'validation', label: 'Validierung', description: 'Nachweis der Eignung unter realen Betriebsbedingungen' },
{ value: 'electrical_test', label: 'Elektrische Pruefung', description: 'Isolationsmessung, Schutzleiter, Spannungsfestigkeit' },
{ value: 'software_test', label: 'Software-Test', description: 'Unit-, Integrations- oder Systemtest der Steuerungssoftware' },
{ value: 'penetration_test', label: 'Penetrationstest', description: 'Security-Test der Netzwerk- und Steuerungskomponenten' },
{ value: 'acceptance_protocol', label: 'Abnahmeprotokoll', description: 'Formelle Abnahme mit Checkliste und Unterschrift' },
{ value: 'user_test', label: 'Anwendertest', description: 'Pruefung durch Bediener unter realen Einsatzbedingungen' },
{ value: 'documentation_release', label: 'Dokumentenfreigabe', description: 'Formelle Freigabe der technischen Dokumentation' },
]
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
pending: { label: 'Ausstehend', color: 'bg-gray-100 text-gray-700' },
in_progress: { label: 'In Bearbeitung', color: 'bg-blue-100 text-blue-700' },
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700' },
failed: { label: 'Fehlgeschlagen', color: 'bg-red-100 text-red-700' },
}
function StatusBadge({ status }: { status: string }) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.pending
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
{config.label}
</span>
)
}
interface VerificationFormData {
title: string
description: string
method: string
linked_hazard_id: string
linked_mitigation_id: string
}
function VerificationForm({
onSubmit,
onCancel,
hazards,
mitigations,
}: {
onSubmit: (data: VerificationFormData) => void
onCancel: () => void
hazards: { id: string; name: string }[]
mitigations: { id: string; title: string }[]
}) {
const [formData, setFormData] = useState<VerificationFormData>({
title: '',
description: '',
method: 'test',
linked_hazard_id: '',
linked_mitigation_id: '',
})
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Neues Verifikationselement</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titel *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="z.B. Funktionstest Lichtvorhang"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Methode</label>
<select
value={formData.method}
onChange={(e) => setFormData({ ...formData, method: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
{VERIFICATION_METHODS.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
placeholder="Beschreiben Sie den Verifikationsschritt..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Verknuepfte Gefaehrdung</label>
<select
value={formData.linked_hazard_id}
onChange={(e) => setFormData({ ...formData, linked_hazard_id: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
<option value="">-- Keine --</option>
{hazards.map((h) => (
<option key={h.id} value={h.id}>{h.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Verknuepfte Massnahme</label>
<select
value={formData.linked_mitigation_id}
onChange={(e) => setFormData({ ...formData, linked_mitigation_id: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
<option value="">-- Keine --</option>
{mitigations.map((m) => (
<option key={m.id} value={m.id}>{m.title}</option>
))}
</select>
</div>
</div>
</div>
<div className="mt-4 flex items-center gap-3">
<button
onClick={() => onSubmit(formData)}
disabled={!formData.title}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
formData.title
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Hinzufuegen
</button>
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
</div>
</div>
)
}
function CompleteModal({
item,
onSubmit,
onClose,
}: {
item: VerificationItem
onSubmit: (id: string, result: string, passed: boolean) => void
onClose: () => void
}) {
const [result, setResult] = useState('')
const [passed, setPassed] = useState(true)
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Verifikation abschliessen: {item.title}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ergebnis</label>
<textarea
value={result}
onChange={(e) => setResult(e.target.value)}
rows={3}
placeholder="Beschreiben Sie das Ergebnis der Verifikation..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Bewertung</label>
<div className="flex gap-3">
<button
onClick={() => setPassed(true)}
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
passed
? 'border-green-400 bg-green-50 text-green-700'
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
}`}
>
Bestanden
</button>
<button
onClick={() => setPassed(false)}
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
!passed
? 'border-red-400 bg-red-50 text-red-700'
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
}`}
>
Nicht bestanden
</button>
</div>
</div>
</div>
<div className="mt-6 flex items-center gap-3">
<button
onClick={() => onSubmit(item.id, result, passed)}
disabled={!result}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
result
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Abschliessen
</button>
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
</div>
</div>
</div>
)
}
// ============================================================================
// Suggest Evidence Modal (Phase 5)
// ============================================================================
function SuggestEvidenceModal({
mitigations,
projectId,
onAddEvidence,
onClose,
}: {
mitigations: { id: string; title: string }[]
projectId: string
onAddEvidence: (title: string, description: string, method: string, mitigationId: string) => void
onClose: () => void
}) {
const [selectedMitigation, setSelectedMitigation] = useState<string>('')
const [suggested, setSuggested] = useState<SuggestedEvidence[]>([])
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
async function handleSelectMitigation(mitigationId: string) {
setSelectedMitigation(mitigationId)
setSuggested([])
if (!mitigationId) return
setLoadingSuggestions(true)
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${mitigationId}/suggest-evidence`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (res.ok) {
const json = await res.json()
setSuggested(json.suggested_evidence || [])
}
} catch (err) {
console.error('Failed to suggest evidence:', err)
} finally {
setLoadingSuggestions(false)
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[85vh] flex flex-col">
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Nachweise vorschlagen</h3>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-sm text-gray-500 mb-3">
Waehlen Sie eine Massnahme, um passende Nachweismethoden vorgeschlagen zu bekommen.
</p>
<div className="flex flex-wrap gap-2">
{mitigations.map(m => (
<button
key={m.id}
onClick={() => handleSelectMitigation(m.id)}
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
selectedMitigation === m.id
? 'border-purple-400 bg-purple-50 text-purple-700 font-medium'
: 'border-gray-200 bg-white text-gray-700 hover:border-purple-300'
}`}
>
{m.title}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-auto p-6">
{loadingSuggestions ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : suggested.length > 0 ? (
<div className="space-y-3">
{suggested.map(ev => (
<div key={ev.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-gray-400">{ev.id}</span>
{ev.method && (
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-600">
{VERIFICATION_METHODS.find(m => m.value === ev.method)?.label || ev.method}
</span>
)}
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">{ev.name}</div>
<div className="text-xs text-gray-500 mt-0.5">{ev.description}</div>
</div>
<button
onClick={() => onAddEvidence(ev.name, ev.description, ev.method || 'test_report', selectedMitigation)}
className="ml-3 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex-shrink-0"
>
Uebernehmen
</button>
</div>
</div>
))}
</div>
) : selectedMitigation ? (
<div className="text-center py-12 text-gray-500">
Keine Vorschlaege fuer diese Massnahme gefunden.
</div>
) : (
<div className="text-center py-12 text-gray-500">
Waehlen Sie eine Massnahme aus, um Nachweise vorgeschlagen zu bekommen.
</div>
)}
</div>
</div>
</div>
)
}
// ============================================================================
// Main Page
// ============================================================================
export default function VerificationPage() {
const params = useParams()
const projectId = params.projectId as string
@@ -382,12 +33,9 @@ export default function VerificationPage() {
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [completingItem, setCompletingItem] = useState<VerificationItem | null>(null)
// Phase 5: Suggest evidence
const [showSuggest, setShowSuggest] = useState(false)
useEffect(() => {
fetchData()
}, [projectId])
useEffect(() => { fetchData() }, [projectId])
async function fetchData() {
try {
@@ -396,87 +44,47 @@ export default function VerificationPage() {
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
])
if (verRes.ok) {
const json = await verRes.json()
setItems(json.verifications || json || [])
}
if (hazRes.ok) {
const json = await hazRes.json()
setHazards((json.hazards || json || []).map((h: { id: string; name: string }) => ({ id: h.id, name: h.name })))
}
if (mitRes.ok) {
const json = await mitRes.json()
setMitigations((json.mitigations || json || []).map((m: { id: string; title: string }) => ({ id: m.id, title: m.title })))
}
} catch (err) {
console.error('Failed to fetch data:', err)
} finally {
setLoading(false)
}
if (verRes.ok) { const json = await verRes.json(); setItems(json.verifications || json || []) }
if (hazRes.ok) { const json = await hazRes.json(); setHazards((json.hazards || json || []).map((h: { id: string; name: string }) => ({ id: h.id, name: h.name }))) }
if (mitRes.ok) { const json = await mitRes.json(); setMitigations((json.mitigations || json || []).map((m: { id: string; title: string }) => ({ id: m.id, title: m.title }))) }
} catch (err) { console.error('Failed to fetch data:', err) }
finally { setLoading(false) }
}
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),
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)
}
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,
}),
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)
}
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 }),
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)
}
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)
}
if (res.ok) await fetchData()
} catch (err) { console.error('Failed to delete verification:', err) }
}
const completed = items.filter((i) => i.status === 'completed').length
@@ -493,7 +101,6 @@ export default function VerificationPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Verifikationsplan</h1>
@@ -503,8 +110,7 @@ export default function VerificationPage() {
</div>
<div className="flex items-center gap-2">
{mitigations.length > 0 && (
<button
onClick={() => setShowSuggest(true)}
<button onClick={() => 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">
@@ -513,8 +119,7 @@ export default function VerificationPage() {
Nachweise vorschlagen
</button>
)}
<button
onClick={() => setShowForm(true)}
<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">
@@ -525,7 +130,6 @@ export default function VerificationPage() {
</div>
</div>
{/* Stats */}
{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">
@@ -547,95 +151,20 @@ export default function VerificationPage() {
</div>
)}
{/* Form */}
{showForm && (
<VerificationForm
onSubmit={handleSubmit}
onCancel={() => setShowForm(false)}
hazards={hazards}
mitigations={mitigations}
/>
<VerificationForm onSubmit={handleSubmit} onCancel={() => setShowForm(false)} hazards={hazards} mitigations={mitigations} />
)}
{/* Complete Modal */}
{completingItem && (
<CompleteModal
item={completingItem}
onSubmit={handleComplete}
onClose={() => setCompletingItem(null)}
/>
<CompleteModal item={completingItem} onSubmit={handleComplete} onClose={() => setCompletingItem(null)} />
)}
{/* Suggest Evidence Modal (Phase 5) */}
{showSuggest && (
<SuggestEvidenceModal
mitigations={mitigations}
projectId={projectId}
onAddEvidence={handleAddSuggestedEvidence}
onClose={() => setShowSuggest(false)}
/>
<SuggestEvidenceModal mitigations={mitigations} projectId={projectId} onAddEvidence={handleAddSuggestedEvidence} onClose={() => setShowSuggest(false)} />
)}
{/* Table */}
{items.length > 0 ? (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gray-50 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Titel</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Methode</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Gefaehrdung</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Massnahme</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ergebnis</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{items.map((item) => (
<tr key={item.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
<td className="px-4 py-3">
<div className="text-sm font-medium text-gray-900 dark:text-white">{item.title}</div>
{item.description && (
<div className="text-xs text-gray-500 truncate max-w-[200px]">{item.description}</div>
)}
</td>
<td className="px-4 py-3">
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
{VERIFICATION_METHODS.find((m) => m.value === item.method)?.label || item.method}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-600">{item.linked_hazard_name || '--'}</td>
<td className="px-4 py-3 text-sm text-gray-600">{item.linked_mitigation_name || '--'}</td>
<td className="px-4 py-3"><StatusBadge status={item.status} /></td>
<td className="px-4 py-3 text-sm text-gray-600 max-w-[150px] truncate">{item.result || '--'}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
{item.status !== 'completed' && item.status !== 'failed' && (
<button
onClick={() => setCompletingItem(item)}
className="text-xs px-2.5 py-1 bg-green-50 text-green-700 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
>
Abschliessen
</button>
)}
<button
onClick={() => handleDelete(item.id)}
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
>
<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-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<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">
@@ -651,17 +180,11 @@ export default function VerificationPage() {
</p>
<div className="mt-6 flex items-center justify-center gap-3">
{mitigations.length > 0 && (
<button
onClick={() => setShowSuggest(true)}
className="px-6 py-3 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors"
>
<button onClick={() => setShowSuggest(true)} className="px-6 py-3 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors">
Nachweise vorschlagen
</button>
)}
<button
onClick={() => setShowForm(true)}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<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>

View File

@@ -0,0 +1,125 @@
'use client'
import { useState } from 'react'
import { updateAssignment, completeAssignment } from '@/lib/sdk/training/api'
import type { TrainingAssignment } from '@/lib/sdk/training/types'
import { STATUS_LABELS, STATUS_COLORS } from '@/lib/sdk/training/types'
export default function AssignmentDetailDrawer({
assignment,
onClose,
onSaved,
}: {
assignment: TrainingAssignment
onClose: () => void
onSaved: () => void
}) {
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const colors = STATUS_COLORS[assignment.status]
async function handleComplete() {
if (!window.confirm('Zuweisung als abgeschlossen markieren?')) return
setSaving(true)
try {
await completeAssignment(assignment.id)
onSaved()
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler')
} finally {
setSaving(false)
}
}
async function handleExtend(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setSaving(true)
setError(null)
const fd = new FormData(e.currentTarget)
try {
await updateAssignment(assignment.id, { deadline: fd.get('deadline') as string })
onSaved()
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Aktualisieren')
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 z-50 flex justify-end">
<div className="absolute inset-0 bg-black/30" onClick={onClose} />
<div className="relative bg-white w-full max-w-md shadow-xl flex flex-col overflow-y-auto">
<div className="flex items-center justify-between px-6 py-4 border-b">
<h3 className="text-base font-semibold">Zuweisung</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">×</button>
</div>
<div className="px-6 py-4 space-y-4 flex-1">
{error && (
<div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded p-3">{error}</div>
)}
<div className="space-y-2">
<Row label="Nutzer" value={`${assignment.user_name} (${assignment.user_email})`} />
<Row label="Modul" value={`${assignment.module_code ?? ''} ${assignment.module_title ?? assignment.module_id.slice(0, 8)}`} />
<Row label="Status">
<span className={`text-xs px-2 py-0.5 rounded-full ${colors.bg} ${colors.text}`}>
{STATUS_LABELS[assignment.status]}
</span>
</Row>
<Row label="Fortschritt" value={`${assignment.progress_percent}%`} />
<Row label="Frist" value={new Date(assignment.deadline).toLocaleDateString('de-DE')} />
{assignment.started_at && <Row label="Gestartet" value={new Date(assignment.started_at).toLocaleString('de-DE')} />}
{assignment.completed_at && <Row label="Abgeschlossen" value={new Date(assignment.completed_at).toLocaleString('de-DE')} />}
{assignment.quiz_score != null && (
<Row label="Quiz-Score" value={`${Math.round(assignment.quiz_score)}% (${assignment.quiz_passed ? 'Bestanden' : 'Nicht bestanden'})`} />
)}
<Row label="Quiz-Versuche" value={String(assignment.quiz_attempts)} />
{assignment.escalation_level > 0 && (
<Row label="Eskalationsstufe" value={String(assignment.escalation_level)} />
)}
</div>
{assignment.status !== 'completed' && (
<div className="border rounded-lg p-4 space-y-3">
<h4 className="text-sm font-medium text-gray-700">Frist verlaengern</h4>
<form onSubmit={handleExtend} className="flex gap-2">
<input
name="deadline"
type="date"
defaultValue={assignment.deadline.slice(0, 10)}
className="flex-1 px-3 py-2 text-sm border rounded-lg"
/>
<button type="submit" disabled={saving} className="px-3 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
Speichern
</button>
</form>
</div>
)}
</div>
{assignment.status !== 'completed' && (
<div className="px-6 py-4 border-t">
<button
onClick={handleComplete}
disabled={saving}
className="w-full px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
>
Als abgeschlossen markieren
</button>
</div>
)}
</div>
</div>
)
}
function Row({ label, value, children }: { label: string; value?: string; children?: React.ReactNode }) {
return (
<div className="flex gap-2 text-sm">
<span className="text-gray-500 w-36 shrink-0">{label}:</span>
{children ?? <span className="text-gray-900">{value}</span>}
</div>
)
}

View File

@@ -0,0 +1,104 @@
'use client'
import type { TrainingAssignment } from '@/lib/sdk/training/types'
import { STATUS_LABELS, STATUS_COLORS } from '@/lib/sdk/training/types'
export default function AssignmentsTab({
assignments,
statusFilter,
onStatusFilterChange,
onAssignmentClick,
}: {
assignments: TrainingAssignment[]
statusFilter: string
onStatusFilterChange: (v: string) => void
onAssignmentClick: (assignment: TrainingAssignment) => void
}) {
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<select
value={statusFilter}
onChange={e => onStatusFilterChange(e.target.value)}
className="px-3 py-2 text-sm border rounded-lg bg-white"
>
<option value="">Alle Status</option>
{Object.entries(STATUS_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<span className="text-sm text-gray-500">{assignments.length} Zuweisungen</span>
</div>
{assignments.length === 0 ? (
<div className="text-center py-12 text-gray-500 text-sm">Keine Zuweisungen gefunden.</div>
) : (
<div className="bg-white border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-600">Nutzer</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Modul</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Status</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Fortschritt</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Frist</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Quiz</th>
</tr>
</thead>
<tbody className="divide-y">
{assignments.map(a => {
const colors = STATUS_COLORS[a.status]
const deadline = new Date(a.deadline)
const isOverdue = deadline < new Date() && a.status !== 'completed'
return (
<tr
key={a.id}
onClick={() => onAssignmentClick(a)}
className="hover:bg-gray-50 cursor-pointer"
>
<td className="px-4 py-3">
<div className="font-medium text-gray-900">{a.user_name}</div>
<div className="text-xs text-gray-500">{a.user_email}</div>
</td>
<td className="px-4 py-3">
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">{a.module_code ?? a.module_id.slice(0, 8)}</code>
{a.module_title && <div className="text-xs text-gray-500 mt-0.5">{a.module_title}</div>}
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${colors.bg} ${colors.text}`}>
{STATUS_LABELS[a.status]}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-20 h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full"
style={{ width: `${a.progress_percent}%` }}
/>
</div>
<span className="text-xs text-gray-600">{a.progress_percent}%</span>
</div>
</td>
<td className={`px-4 py-3 text-xs ${isOverdue ? 'text-red-600 font-medium' : 'text-gray-600'}`}>
{deadline.toLocaleDateString('de-DE')}
</td>
<td className="px-4 py-3 text-xs text-gray-600">
{a.quiz_score != null ? (
<span className={a.quiz_passed ? 'text-green-600' : 'text-red-600'}>
{Math.round(a.quiz_score)}% {a.quiz_passed ? '✓' : '✗'}
</span>
) : (
<span className="text-gray-400"></span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,73 @@
'use client'
import type { AuditLogEntry } from '@/lib/sdk/training/types'
const ACTION_LABELS: Record<string, string> = {
assigned: 'Zugewiesen',
started: 'Gestartet',
completed: 'Abgeschlossen',
quiz_submitted: 'Quiz eingereicht',
escalated: 'Eskaliert',
certificate_issued: 'Zertifikat ausgestellt',
content_generated: 'Content generiert',
}
const ACTION_COLORS: Record<string, string> = {
assigned: 'bg-blue-100 text-blue-700',
started: 'bg-yellow-100 text-yellow-700',
completed: 'bg-green-100 text-green-700',
quiz_submitted: 'bg-purple-100 text-purple-700',
escalated: 'bg-red-100 text-red-700',
certificate_issued: 'bg-emerald-100 text-emerald-700',
content_generated: 'bg-gray-100 text-gray-700',
}
export default function AuditTab({ auditLog }: { auditLog: AuditLogEntry[] }) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">{auditLog.length} Eintraege</p>
</div>
{auditLog.length === 0 ? (
<div className="text-center py-12 text-gray-500 text-sm">Keine Audit-Eintraege gefunden.</div>
) : (
<div className="bg-white border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-600">Zeitpunkt</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Aktion</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Entitaet</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Details</th>
</tr>
</thead>
<tbody className="divide-y">
{auditLog.map(entry => (
<tr key={entry.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs text-gray-500 whitespace-nowrap">
{new Date(entry.created_at).toLocaleString('de-DE')}
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${ACTION_COLORS[entry.action] ?? 'bg-gray-100 text-gray-700'}`}>
{ACTION_LABELS[entry.action] ?? entry.action}
</span>
</td>
<td className="px-4 py-3 text-xs text-gray-600">
<span className="font-medium">{entry.entity_type}</span>
{entry.entity_id && <span className="ml-1 text-gray-400">{entry.entity_id.slice(0, 8)}</span>}
</td>
<td className="px-4 py-3 text-xs text-gray-500 max-w-xs truncate">
{Object.keys(entry.details).length > 0
? Object.entries(entry.details).map(([k, v]) => `${k}: ${v}`).join(', ')
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,392 @@
'use client'
import AudioPlayer from '@/components/training/AudioPlayer'
import VideoPlayer from '@/components/training/VideoPlayer'
import ScriptPreview from '@/components/training/ScriptPreview'
import type {
TrainingModule, ModuleContent, TrainingMedia,
TrainingBlockConfig, CanonicalControlMeta, BlockPreview, BlockGenerateResult,
} from '@/lib/sdk/training/types'
import { TARGET_AUDIENCE_LABELS, ROLE_LABELS, REGULATION_LABELS } from '@/lib/sdk/training/types'
export function ContentTab({
modules,
blocks,
canonicalMeta,
selectedModuleId,
onSelectedModuleIdChange,
generatedContent,
generating,
bulkGenerating,
bulkResult,
moduleMedia,
interactiveGenerating,
blockPreview,
blockPreviewId,
blockGenerating,
blockResult,
showBlockCreate,
onShowBlockCreate,
onGenerateContent,
onGenerateQuiz,
onGenerateInteractiveVideo,
onPublishContent,
onBulkContent,
onBulkQuiz,
onPreviewBlock,
onGenerateBlock,
onDeleteBlock,
onCreateBlock,
}: {
modules: TrainingModule[]
blocks: TrainingBlockConfig[]
canonicalMeta: CanonicalControlMeta | null
selectedModuleId: string
onSelectedModuleIdChange: (id: string) => void
generatedContent: ModuleContent | null
generating: boolean
bulkGenerating: boolean
bulkResult: { generated: number; skipped: number; errors: string[] } | null
moduleMedia: TrainingMedia[]
interactiveGenerating: boolean
blockPreview: BlockPreview | null
blockPreviewId: string
blockGenerating: boolean
blockResult: BlockGenerateResult | null
showBlockCreate: boolean
onShowBlockCreate: (show: boolean) => void
onGenerateContent: () => void
onGenerateQuiz: () => void
onGenerateInteractiveVideo: () => void
onPublishContent: (id: string) => void
onBulkContent: () => void
onBulkQuiz: () => void
onPreviewBlock: (id: string) => void
onGenerateBlock: (id: string) => void
onDeleteBlock: (id: string) => void
onCreateBlock: (data: {
name: string; description?: string; domain_filter?: string; category_filter?: string;
severity_filter?: string; target_audience_filter?: string; regulation_area: string;
module_code_prefix: string; max_controls_per_module?: number;
}) => void
}) {
return (
<div className="space-y-6">
{/* Training Blocks */}
<div className="bg-white border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium text-gray-700">Schulungsbloecke aus Controls</h3>
<p className="text-xs text-gray-500">
Canonical Controls nach Kriterien filtern und automatisch Schulungsmodule generieren
{canonicalMeta && <span className="ml-2 text-gray-400">({canonicalMeta.total} Controls verfuegbar)</span>}
</p>
</div>
<button
onClick={() => onShowBlockCreate(true)}
className="px-3 py-1.5 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
+ Neuen Block erstellen
</button>
</div>
{blocks.length > 0 ? (
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left font-medium text-gray-600">Name</th>
<th className="px-3 py-2 text-left font-medium text-gray-600">Domain</th>
<th className="px-3 py-2 text-left font-medium text-gray-600">Zielgruppe</th>
<th className="px-3 py-2 text-left font-medium text-gray-600">Severity</th>
<th className="px-3 py-2 text-left font-medium text-gray-600">Prefix</th>
<th className="px-3 py-2 text-left font-medium text-gray-600">Letzte Generierung</th>
<th className="px-3 py-2 text-right font-medium text-gray-600">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y">
{blocks.map(block => (
<tr key={block.id} className="hover:bg-gray-50">
<td className="px-3 py-2">
<div className="font-medium text-gray-900">{block.name}</div>
{block.description && <div className="text-xs text-gray-500">{block.description}</div>}
</td>
<td className="px-3 py-2 text-gray-600">{block.domain_filter || 'Alle'}</td>
<td className="px-3 py-2 text-gray-600">
{block.target_audience_filter ? (TARGET_AUDIENCE_LABELS[block.target_audience_filter] || block.target_audience_filter) : 'Alle'}
</td>
<td className="px-3 py-2 text-gray-600">{block.severity_filter || 'Alle'}</td>
<td className="px-3 py-2"><code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">{block.module_code_prefix}</code></td>
<td className="px-3 py-2 text-gray-500 text-xs">
{block.last_generated_at ? new Date(block.last_generated_at).toLocaleString('de-DE') : 'Noch nie'}
</td>
<td className="px-3 py-2 text-right">
<div className="flex gap-1 justify-end">
<button onClick={() => onPreviewBlock(block.id)} className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200">Preview</button>
<button onClick={() => onGenerateBlock(block.id)} disabled={blockGenerating} className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50">
{blockGenerating ? 'Generiert...' : 'Generieren'}
</button>
<button onClick={() => onDeleteBlock(block.id)} className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200">Loeschen</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-8 text-gray-500 text-sm">
Noch keine Schulungsbloecke konfiguriert. Erstelle einen Block, um Controls automatisch in Module umzuwandeln.
</div>
)}
{blockPreview && blockPreviewId && (
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h4 className="text-sm font-medium text-blue-800 mb-2">Preview: {blocks.find(b => b.id === blockPreviewId)?.name}</h4>
<div className="flex gap-6 text-sm mb-3">
<span className="text-blue-700">Controls: <strong>{blockPreview.control_count}</strong></span>
<span className="text-blue-700">Module: <strong>{blockPreview.module_count}</strong></span>
<span className="text-blue-700">Rollen: <strong>{blockPreview.proposed_roles.map(r => ROLE_LABELS[r] || r).join(', ')}</strong></span>
</div>
{blockPreview.controls.length > 0 && (
<details className="text-xs">
<summary className="cursor-pointer text-blue-600 hover:text-blue-800">Passende Controls anzeigen ({blockPreview.control_count})</summary>
<div className="mt-2 max-h-48 overflow-y-auto">
{blockPreview.controls.slice(0, 50).map(ctrl => (
<div key={ctrl.control_id} className="flex gap-2 py-1 border-b border-blue-100">
<code className="text-xs bg-blue-100 px-1 rounded shrink-0">{ctrl.control_id}</code>
<span className="text-gray-700 truncate">{ctrl.title}</span>
<span className={`text-xs px-1.5 rounded shrink-0 ${ctrl.severity === 'critical' ? 'bg-red-100 text-red-700' : ctrl.severity === 'high' ? 'bg-orange-100 text-orange-700' : 'bg-gray-100 text-gray-600'}`}>{ctrl.severity}</span>
</div>
))}
{blockPreview.control_count > 50 && <div className="text-gray-500 py-1">... und {blockPreview.control_count - 50} weitere</div>}
</div>
</details>
)}
</div>
)}
{blockResult && (
<div className="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg">
<h4 className="text-sm font-medium text-green-800 mb-2">Generierung abgeschlossen</h4>
<div className="flex gap-6 text-sm">
<span className="text-green-700">Module erstellt: <strong>{blockResult.modules_created}</strong></span>
<span className="text-green-700">Controls verknuepft: <strong>{blockResult.controls_linked}</strong></span>
<span className="text-green-700">Matrix-Eintraege: <strong>{blockResult.matrix_entries_created}</strong></span>
<span className="text-green-700">Content generiert: <strong>{blockResult.content_generated}</strong></span>
</div>
{blockResult.errors && blockResult.errors.length > 0 && (
<div className="mt-2 text-xs text-red-600">{blockResult.errors.map((err, i) => <div key={i}>{err}</div>)}</div>
)}
</div>
)}
</div>
{/* Block Create Modal */}
{showBlockCreate && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
<h3 className="text-lg font-semibold mb-4">Neuen Schulungsblock erstellen</h3>
<form onSubmit={e => {
e.preventDefault()
const fd = new FormData(e.currentTarget)
onCreateBlock({
name: fd.get('name') as string,
description: fd.get('description') as string || undefined,
domain_filter: fd.get('domain_filter') as string || undefined,
category_filter: fd.get('category_filter') as string || undefined,
severity_filter: fd.get('severity_filter') as string || undefined,
target_audience_filter: fd.get('target_audience_filter') as string || undefined,
regulation_area: fd.get('regulation_area') as string,
module_code_prefix: fd.get('module_code_prefix') as string,
max_controls_per_module: parseInt(fd.get('max_controls_per_module') as string) || 20,
})
}} className="space-y-3">
<div>
<label className="text-xs text-gray-600 block mb-1">Name *</label>
<input name="name" required className="w-full px-3 py-2 text-sm border rounded-lg" placeholder="z.B. Authentifizierung fuer Geschaeftsfuehrung" />
</div>
<div>
<label className="text-xs text-gray-600 block mb-1">Beschreibung</label>
<textarea name="description" className="w-full px-3 py-2 text-sm border rounded-lg" rows={2} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-600 block mb-1">Domain-Filter</label>
<select name="domain_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
<option value="">Alle Domains</option>
{canonicalMeta?.domains.map(d => <option key={d.domain} value={d.domain}>{d.domain} ({d.count})</option>)}
</select>
</div>
<div>
<label className="text-xs text-gray-600 block mb-1">Kategorie-Filter</label>
<select name="category_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
<option value="">Alle Kategorien</option>
{canonicalMeta?.categories.filter(c => c.category !== 'uncategorized').map(c => (
<option key={c.category} value={c.category}>{c.category} ({c.count})</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-600 block mb-1">Zielgruppe</label>
<select name="target_audience_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
<option value="">Alle Zielgruppen</option>
{canonicalMeta?.audiences.filter(a => a.audience !== 'unset').map(a => (
<option key={a.audience} value={a.audience}>{TARGET_AUDIENCE_LABELS[a.audience] || a.audience} ({a.count})</option>
))}
</select>
</div>
<div>
<label className="text-xs text-gray-600 block mb-1">Severity</label>
<select name="severity_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
<option value="">Alle</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-600 block mb-1">Regulierungsbereich *</label>
<select name="regulation_area" required className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
{Object.entries(REGULATION_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label className="text-xs text-gray-600 block mb-1">Modul-Code-Prefix *</label>
<input name="module_code_prefix" required className="w-full px-3 py-2 text-sm border rounded-lg" placeholder="z.B. CB-AUTH" />
</div>
</div>
<div>
<label className="text-xs text-gray-600 block mb-1">Max. Controls pro Modul</label>
<input name="max_controls_per_module" type="number" defaultValue={20} min={1} max={50} className="w-full px-3 py-2 text-sm border rounded-lg" />
</div>
<div className="flex gap-3 pt-2">
<button type="submit" className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">Erstellen</button>
<button type="button" onClick={() => onShowBlockCreate(false)} className="px-4 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">Abbrechen</button>
</div>
</form>
</div>
</div>
)}
{/* Bulk Generation */}
<div className="bg-white border rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-700 mb-3">Bulk-Generierung</h3>
<p className="text-xs text-gray-500 mb-4">Generiere Inhalte und Quiz-Fragen fuer alle Module auf einmal</p>
<div className="flex gap-3">
<button onClick={onBulkContent} disabled={bulkGenerating} className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
{bulkGenerating ? 'Generiere...' : 'Alle Inhalte generieren'}
</button>
<button onClick={onBulkQuiz} disabled={bulkGenerating} className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
{bulkGenerating ? 'Generiere...' : 'Alle Quizfragen generieren'}
</button>
</div>
{bulkResult && (
<div className="mt-4 p-3 bg-gray-50 rounded-lg text-sm">
<div className="flex gap-6">
<span className="text-green-700">Generiert: {bulkResult.generated}</span>
<span className="text-gray-500">Uebersprungen: {bulkResult.skipped}</span>
{bulkResult.errors?.length > 0 && <span className="text-red-600">Fehler: {bulkResult.errors.length}</span>}
</div>
{bulkResult.errors?.length > 0 && (
<div className="mt-2 text-xs text-red-600">{bulkResult.errors.map((err, i) => <div key={i}>{err}</div>)}</div>
)}
</div>
)}
</div>
{/* LLM Content Generator */}
<div className="bg-white border rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-700 mb-3">LLM-Content-Generator</h3>
<p className="text-xs text-gray-500 mb-4">Generiere Schulungsinhalte und Quiz-Fragen automatisch via KI</p>
<div className="flex gap-3 items-end">
<div className="flex-1">
<label className="text-xs text-gray-600 block mb-1">Modul auswaehlen</label>
<select
value={selectedModuleId}
onChange={e => onSelectedModuleIdChange(e.target.value)}
className="w-full px-3 py-2 text-sm border rounded-lg bg-white"
>
<option value="">Modul waehlen...</option>
{modules.map(m => <option key={m.id} value={m.id}>{m.module_code} - {m.title}</option>)}
</select>
</div>
<button onClick={onGenerateContent} disabled={!selectedModuleId || generating} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
{generating ? 'Generiere...' : 'Inhalt generieren'}
</button>
<button onClick={onGenerateQuiz} disabled={!selectedModuleId || generating} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
{generating ? 'Generiere...' : 'Quiz generieren'}
</button>
</div>
</div>
{generatedContent && (
<div className="bg-white border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium text-gray-700">Generierter Inhalt (v{generatedContent.version})</h3>
<p className="text-xs text-gray-500">Generiert von: {generatedContent.generated_by} ({generatedContent.llm_model})</p>
</div>
{!generatedContent.is_published ? (
<button onClick={() => onPublishContent(generatedContent.id)} className="px-3 py-1.5 text-xs bg-green-600 text-white rounded hover:bg-green-700">Veroeffentlichen</button>
) : (
<span className="px-3 py-1.5 text-xs bg-green-100 text-green-700 rounded">Veroeffentlicht</span>
)}
</div>
<div className="prose prose-sm max-w-none border rounded p-4 bg-gray-50 max-h-96 overflow-y-auto">
<pre className="whitespace-pre-wrap text-sm text-gray-800">{generatedContent.content_body}</pre>
</div>
</div>
)}
{selectedModuleId && generatedContent?.is_published && (
<AudioPlayer
moduleId={selectedModuleId}
audio={moduleMedia.find(m => m.media_type === 'audio') || null}
onMediaUpdate={() => {}}
/>
)}
{selectedModuleId && generatedContent?.is_published && (
<VideoPlayer
moduleId={selectedModuleId}
video={moduleMedia.find(m => m.media_type === 'video') || null}
onMediaUpdate={() => {}}
/>
)}
{selectedModuleId && generatedContent?.is_published && (
<div className="bg-white border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-medium text-gray-700">Interaktives Video</h3>
<p className="text-xs text-gray-500">Video mit Narrator-Persona und Checkpoint-Quizzes</p>
</div>
{moduleMedia.some(m => m.media_type === 'interactive_video' && m.status === 'completed') ? (
<span className="px-3 py-1.5 text-xs bg-purple-100 text-purple-700 rounded-full">Interaktiv erstellt</span>
) : (
<button onClick={onGenerateInteractiveVideo} disabled={interactiveGenerating} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
{interactiveGenerating ? 'Generiere interaktives Video...' : 'Interaktives Video generieren'}
</button>
)}
</div>
{moduleMedia.filter(m => m.media_type === 'interactive_video' && m.status === 'completed').map(m => (
<div key={m.id} className="text-xs text-gray-500 space-y-1 bg-gray-50 rounded p-3">
<p>Dauer: {Math.round(m.duration_seconds / 60)} Min | Groesse: {(m.file_size_bytes / 1024 / 1024).toFixed(1)} MB</p>
<p>Generiert: {new Date(m.created_at).toLocaleString('de-DE')}</p>
</div>
))}
</div>
)}
{selectedModuleId && generatedContent?.is_published && (
<ScriptPreview moduleId={selectedModuleId} />
)}
</div>
)
}

View File

@@ -0,0 +1,88 @@
'use client'
import { useState } from 'react'
import { setMatrixEntry } from '@/lib/sdk/training/api'
import type { TrainingModule } from '@/lib/sdk/training/types'
import { ROLE_LABELS, REGULATION_LABELS } from '@/lib/sdk/training/types'
export default function MatrixAddModal({
roleCode,
modules,
onClose,
onSaved,
}: {
roleCode: string
modules: TrainingModule[]
onClose: () => void
onSaved: () => void
}) {
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setSaving(true)
setError(null)
const fd = new FormData(e.currentTarget)
try {
await setMatrixEntry({
role_code: roleCode,
module_id: fd.get('module_id') as string,
is_mandatory: fd.get('is_mandatory') === 'on',
priority: parseInt(fd.get('priority') as string) || 1,
})
onSaved()
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Hinzufuegen')
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6">
<h3 className="text-base font-semibold mb-1">Modul zuweisen</h3>
<p className="text-xs text-gray-500 mb-4">
Rolle: <strong>{ROLE_LABELS[roleCode] ?? roleCode}</strong> ({roleCode})
</p>
{error && (
<div className="mb-4 text-sm text-red-600 bg-red-50 border border-red-200 rounded p-3">{error}</div>
)}
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="text-xs text-gray-600 block mb-1">Modul *</label>
<select name="module_id" required className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
<option value="">Modul waehlen...</option>
{modules.filter(m => m.is_active).map(m => (
<option key={m.id} value={m.id}>
{m.module_code} {m.title} ({REGULATION_LABELS[m.regulation_area] ?? m.regulation_area})
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-600 block mb-1">Prioritaet</label>
<input name="priority" type="number" defaultValue={1} min={1} max={10} className="w-full px-3 py-2 text-sm border rounded-lg" />
</div>
<div className="flex items-center gap-2 mt-5">
<input name="is_mandatory" type="checkbox" id="mandatory" defaultChecked className="rounded" />
<label htmlFor="mandatory" className="text-xs text-gray-600">Pflichtmodul</label>
</div>
</div>
<div className="flex gap-3 pt-2">
<button type="submit" disabled={saving} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
{saving ? 'Speichere...' : 'Zuweisen'}
</button>
<button type="button" onClick={onClose} className="px-4 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">
Abbrechen
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
'use client'
import type { MatrixResponse } from '@/lib/sdk/training/types'
import { ALL_ROLES, ROLE_LABELS } from '@/lib/sdk/training/types'
export default function MatrixTab({
matrix,
onDeleteEntry,
onAddEntry,
}: {
matrix: MatrixResponse
onDeleteEntry: (roleCode: string, moduleId: string) => void
onAddEntry: (roleCode: string) => void
}) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">Pflichtzuordnung von Schulungsmodulen zu Rollen</p>
</div>
<div className="bg-white border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-600 w-48">Rolle</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Zugewiesene Module</th>
<th className="px-4 py-3 text-right font-medium text-gray-600 w-24">Aktion</th>
</tr>
</thead>
<tbody className="divide-y">
{ALL_ROLES.map(role => {
const entries = matrix.entries[role] ?? []
return (
<tr key={role} className="hover:bg-gray-50">
<td className="px-4 py-3">
<div className="font-medium text-gray-900">{ROLE_LABELS[role] ?? role}</div>
<div className="text-xs text-gray-400">{role}</div>
</td>
<td className="px-4 py-3">
{entries.length === 0 ? (
<span className="text-gray-400 text-xs">Keine Module zugewiesen</span>
) : (
<div className="flex flex-wrap gap-1.5">
{entries.map(entry => (
<span
key={entry.id}
className="inline-flex items-center gap-1 text-xs bg-blue-50 text-blue-700 border border-blue-200 px-2 py-0.5 rounded-full"
>
<code className="text-xs">{entry.module_code ?? entry.module_id.slice(0, 8)}</code>
{entry.is_mandatory && <span className="text-red-500 font-bold">*</span>}
<button
onClick={() => onDeleteEntry(role, entry.module_id)}
className="ml-0.5 text-blue-400 hover:text-red-600"
title="Entfernen"
>
×
</button>
</span>
))}
</div>
)}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => onAddEntry(role)}
className="px-2 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700"
>
+ Modul
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<p className="text-xs text-gray-400">* = Pflichtmodul</p>
</div>
)
}

View File

@@ -0,0 +1,98 @@
'use client'
import { useState } from 'react'
import { createModule } from '@/lib/sdk/training/api'
import { REGULATION_LABELS, FREQUENCY_LABELS } from '@/lib/sdk/training/types'
export default function ModuleCreateModal({
onClose,
onSaved,
}: {
onClose: () => void
onSaved: () => void
}) {
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setSaving(true)
setError(null)
const fd = new FormData(e.currentTarget)
try {
await createModule({
module_code: fd.get('module_code') as string,
title: fd.get('title') as string,
description: (fd.get('description') as string) || undefined,
regulation_area: fd.get('regulation_area') as string,
frequency_type: fd.get('frequency_type') as string,
duration_minutes: parseInt(fd.get('duration_minutes') as string) || 30,
pass_threshold: parseInt(fd.get('pass_threshold') as string) || 80,
})
onSaved()
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Erstellen')
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold mb-4">Neues Schulungsmodul</h3>
{error && (
<div className="mb-4 text-sm text-red-600 bg-red-50 border border-red-200 rounded p-3">{error}</div>
)}
<form onSubmit={handleSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-600 block mb-1">Modul-Code *</label>
<input name="module_code" required className="w-full px-3 py-2 text-sm border rounded-lg" placeholder="z.B. DSGVO-001" />
</div>
<div>
<label className="text-xs text-gray-600 block mb-1">Regulierungsbereich *</label>
<select name="regulation_area" required className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
{Object.entries(REGULATION_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
</div>
<div>
<label className="text-xs text-gray-600 block mb-1">Titel *</label>
<input name="title" required className="w-full px-3 py-2 text-sm border rounded-lg" />
</div>
<div>
<label className="text-xs text-gray-600 block mb-1">Beschreibung</label>
<textarea name="description" rows={2} className="w-full px-3 py-2 text-sm border rounded-lg" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-600 block mb-1">Frequenz</label>
<select name="frequency_type" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
{Object.entries(FREQUENCY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label className="text-xs text-gray-600 block mb-1">Dauer (Minuten)</label>
<input name="duration_minutes" type="number" defaultValue={30} min={1} className="w-full px-3 py-2 text-sm border rounded-lg" />
</div>
</div>
<div>
<label className="text-xs text-gray-600 block mb-1">Bestehensgrenze (%)</label>
<input name="pass_threshold" type="number" defaultValue={80} min={0} max={100} className="w-full px-3 py-2 text-sm border rounded-lg" />
</div>
<div className="flex gap-3 pt-2">
<button type="submit" disabled={saving} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
{saving ? 'Speichere...' : 'Erstellen'}
</button>
<button type="button" onClick={onClose} className="px-4 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">
Abbrechen
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,149 @@
'use client'
import { useState } from 'react'
import { updateModule, deleteModule } from '@/lib/sdk/training/api'
import type { TrainingModule } from '@/lib/sdk/training/types'
import { REGULATION_LABELS, FREQUENCY_LABELS } from '@/lib/sdk/training/types'
export default function ModuleEditDrawer({
module,
onClose,
onSaved,
}: {
module: TrainingModule
onClose: () => void
onSaved: () => void
}) {
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setSaving(true)
setError(null)
const fd = new FormData(e.currentTarget)
try {
await updateModule(module.id, {
title: fd.get('title') as string,
description: (fd.get('description') as string) || undefined,
regulation_area: fd.get('regulation_area') as string,
frequency_type: fd.get('frequency_type') as string,
validity_days: parseInt(fd.get('validity_days') as string),
duration_minutes: parseInt(fd.get('duration_minutes') as string),
pass_threshold: parseInt(fd.get('pass_threshold') as string),
risk_weight: parseFloat(fd.get('risk_weight') as string),
nis2_relevant: fd.get('nis2_relevant') === 'on',
is_active: fd.get('is_active') === 'on',
})
onSaved()
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Speichern')
} finally {
setSaving(false)
}
}
async function handleDelete() {
if (!window.confirm('Modul wirklich loeschen?')) return
try {
await deleteModule(module.id)
onSaved()
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Loeschen')
}
}
return (
<div className="fixed inset-0 z-50 flex justify-end">
<div className="absolute inset-0 bg-black/30" onClick={onClose} />
<div className="relative bg-white w-full max-w-md shadow-xl flex flex-col overflow-y-auto">
<div className="flex items-center justify-between px-6 py-4 border-b">
<h3 className="text-base font-semibold">Modul bearbeiten</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">×</button>
</div>
<div className="px-6 py-4 flex-1">
{error && (
<div className="mb-4 text-sm text-red-600 bg-red-50 border border-red-200 rounded p-3">{error}</div>
)}
<div className="mb-4 text-xs text-gray-400">
<code className="bg-gray-100 px-1.5 py-0.5 rounded">{module.module_code}</code>
<span className="ml-2">ID: {module.id.slice(0, 8)}</span>
</div>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="text-xs text-gray-600 block mb-1">Titel *</label>
<input name="title" required defaultValue={module.title} className="w-full px-3 py-2 text-sm border rounded-lg" />
</div>
<div>
<label className="text-xs text-gray-600 block mb-1">Beschreibung</label>
<textarea name="description" rows={2} defaultValue={module.description} className="w-full px-3 py-2 text-sm border rounded-lg" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-600 block mb-1">Regulierungsbereich</label>
<select name="regulation_area" defaultValue={module.regulation_area} className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
{Object.entries(REGULATION_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
<div>
<label className="text-xs text-gray-600 block mb-1">Frequenz</label>
<select name="frequency_type" defaultValue={module.frequency_type} className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
{Object.entries(FREQUENCY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-600 block mb-1">Gueltigkeitsdauer (Tage)</label>
<input name="validity_days" type="number" defaultValue={module.validity_days} min={1} className="w-full px-3 py-2 text-sm border rounded-lg" />
</div>
<div>
<label className="text-xs text-gray-600 block mb-1">Dauer (Minuten)</label>
<input name="duration_minutes" type="number" defaultValue={module.duration_minutes} min={1} className="w-full px-3 py-2 text-sm border rounded-lg" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-600 block mb-1">Bestehensgrenze (%)</label>
<input name="pass_threshold" type="number" defaultValue={module.pass_threshold} min={0} max={100} className="w-full px-3 py-2 text-sm border rounded-lg" />
</div>
<div>
<label className="text-xs text-gray-600 block mb-1">Risikogewicht</label>
<input name="risk_weight" type="number" defaultValue={module.risk_weight} min={0} step={0.1} className="w-full px-3 py-2 text-sm border rounded-lg" />
</div>
</div>
<div className="flex gap-4">
<div className="flex items-center gap-2">
<input name="nis2_relevant" type="checkbox" id="edit-nis2" defaultChecked={module.nis2_relevant} className="rounded" />
<label htmlFor="edit-nis2" className="text-xs text-gray-600">NIS-2 relevant</label>
</div>
<div className="flex items-center gap-2">
<input name="is_active" type="checkbox" id="edit-active" defaultChecked={module.is_active} className="rounded" />
<label htmlFor="edit-active" className="text-xs text-gray-600">Aktiv</label>
</div>
</div>
<div className="flex gap-3 pt-2">
<button type="submit" disabled={saving} className="flex-1 px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
{saving ? 'Speichere...' : 'Speichern'}
</button>
<button type="button" onClick={onClose} className="px-4 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">
Abbrechen
</button>
</div>
</form>
</div>
<div className="px-6 py-4 border-t">
<button
onClick={handleDelete}
className="w-full px-4 py-2 text-sm bg-red-50 text-red-700 border border-red-200 rounded-lg hover:bg-red-100"
>
Modul loeschen
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,96 @@
'use client'
import type { TrainingModule } from '@/lib/sdk/training/types'
import { REGULATION_LABELS, REGULATION_COLORS, FREQUENCY_LABELS } from '@/lib/sdk/training/types'
export default function ModulesTab({
modules,
regulationFilter,
onRegulationFilterChange,
onCreateClick,
onModuleClick,
}: {
modules: TrainingModule[]
regulationFilter: string
onRegulationFilterChange: (v: string) => void
onCreateClick: () => void
onModuleClick: (module: TrainingModule) => void
}) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<select
value={regulationFilter}
onChange={e => onRegulationFilterChange(e.target.value)}
className="px-3 py-2 text-sm border rounded-lg bg-white"
>
<option value="">Alle Regulierungen</option>
{Object.entries(REGULATION_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<span className="text-sm text-gray-500">{modules.length} Module</span>
</div>
<button
onClick={onCreateClick}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
+ Neues Modul
</button>
</div>
{modules.length === 0 ? (
<div className="text-center py-12 text-gray-500 text-sm">Keine Module gefunden.</div>
) : (
<div className="bg-white border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-600">Code</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Titel</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Regulierung</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Frequenz</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Dauer</th>
<th className="px-4 py-3 text-left font-medium text-gray-600">Status</th>
</tr>
</thead>
<tbody className="divide-y">
{modules.map(m => {
const reg = m.regulation_area
const colors = REGULATION_COLORS[reg] ?? { bg: 'bg-gray-100', text: 'text-gray-700', border: 'border-gray-300' }
return (
<tr
key={m.id}
onClick={() => onModuleClick(m)}
className="hover:bg-gray-50 cursor-pointer"
>
<td className="px-4 py-3">
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">{m.module_code}</code>
</td>
<td className="px-4 py-3">
<div className="font-medium text-gray-900">{m.title}</div>
{m.description && <div className="text-xs text-gray-500 truncate max-w-xs">{m.description}</div>}
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full border ${colors.bg} ${colors.text} ${colors.border}`}>
{REGULATION_LABELS[reg] ?? reg}
</span>
</td>
<td className="px-4 py-3 text-gray-600">{FREQUENCY_LABELS[m.frequency_type] ?? m.frequency_type}</td>
<td className="px-4 py-3 text-gray-600">{m.duration_minutes} Min</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${m.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'}`}>
{m.is_active ? 'Aktiv' : 'Inaktiv'}
</span>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,89 @@
'use client'
import type { TrainingStats, DeadlineInfo } from '@/lib/sdk/training/types'
import { STATUS_COLORS, STATUS_LABELS } from '@/lib/sdk/training/types'
export default function OverviewTab({
stats,
deadlines,
escalationResult,
onDismissEscalation,
}: {
stats: TrainingStats
deadlines: DeadlineInfo[]
escalationResult: { total_checked: number; escalated: number } | null
onDismissEscalation: () => void
}) {
return (
<div className="space-y-6">
{escalationResult && (
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4 flex items-center justify-between">
<div>
<p className="text-sm font-medium text-orange-800">Eskalationspruefung abgeschlossen</p>
<p className="text-xs text-orange-600 mt-0.5">
{escalationResult.total_checked} geprueft, {escalationResult.escalated} eskaliert
</p>
</div>
<button onClick={onDismissEscalation} className="text-xs text-orange-600 underline hover:text-orange-800">
Schliessen
</button>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard label="Gesamt Module" value={stats.total_modules} color="blue" />
<StatCard label="Zuweisungen" value={stats.total_assignments} color="gray" />
<StatCard label="Abschlussrate" value={`${Math.round(stats.completion_rate)}%`} color="green" />
<StatCard label="Ueberfaellig" value={stats.overdue_count} color="red" />
<StatCard label="Ausstehend" value={stats.pending_count} color="yellow" />
<StatCard label="In Bearbeitung" value={stats.in_progress_count} color="blue" />
<StatCard label="Abgeschlossen" value={stats.completed_count} color="green" />
<StatCard label="Ø Quiz-Score" value={`${Math.round(stats.avg_quiz_score)}%`} color="purple" />
</div>
{deadlines.length > 0 && (
<div className="bg-white border rounded-lg p-4">
<h3 className="text-sm font-medium text-gray-700 mb-3">Bevorstehende Fristen</h3>
<div className="space-y-2">
{deadlines.map(d => {
const colors = STATUS_COLORS[d.status]
return (
<div key={d.assignment_id} className="flex items-center justify-between text-sm py-2 border-b last:border-0">
<div>
<span className="font-medium text-gray-900">{d.user_name}</span>
<span className="text-gray-500 ml-2">{d.module_code} {d.module_title}</span>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className={`text-xs px-2 py-0.5 rounded-full ${colors.bg} ${colors.text}`}>
{STATUS_LABELS[d.status]}
</span>
<span className={`text-xs font-medium ${d.days_left <= 3 ? 'text-red-600' : d.days_left <= 7 ? 'text-orange-600' : 'text-gray-500'}`}>
{d.days_left <= 0 ? 'Ueberfaellig' : `${d.days_left}d`}
</span>
</div>
</div>
)
})}
</div>
</div>
)}
</div>
)
}
function StatCard({ label, value, color }: { label: string; value: string | number; color: string }) {
const colorMap: Record<string, string> = {
blue: 'text-blue-700',
green: 'text-green-700',
red: 'text-red-700',
yellow: 'text-yellow-700',
purple: 'text-purple-700',
gray: 'text-gray-700',
}
return (
<div className="bg-white border rounded-lg p-4">
<p className="text-xs text-gray-500">{label}</p>
<p className={`text-2xl font-bold mt-1 ${colorMap[color] ?? 'text-gray-700'}`}>{value}</p>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,346 +10,21 @@ import {
getStepsForPackage,
type SDKPackageId,
type SDKStep,
type RAGCorpusStatus,
} from '@/lib/sdk'
/**
* Append ?project= to a URL if a projectId is set
*/
function withProject(url: string, projectId?: string): string {
if (!projectId) return url
const separator = url.includes('?') ? '&' : '?'
return `${url}${separator}project=${projectId}`
}
// =============================================================================
// ICONS
// =============================================================================
const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)
const LockIcon = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
)
const WarningIcon = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
)
const ChevronDownIcon = ({ className = '' }: { className?: string }) => (
<svg className={`w-4 h-4 ${className}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)
const CollapseIcon = ({ collapsed }: { collapsed: boolean }) => (
<svg
className={`w-5 h-5 transition-transform duration-300 ${collapsed ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
)
// =============================================================================
// PROGRESS BAR
// =============================================================================
interface ProgressBarProps {
value: number
className?: string
}
function ProgressBar({ value, className = '' }: ProgressBarProps) {
return (
<div className={`h-1 bg-gray-200 rounded-full overflow-hidden ${className}`}>
<div
className="h-full bg-purple-600 rounded-full transition-all duration-500"
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
)
}
// =============================================================================
// PACKAGE INDICATOR
// =============================================================================
interface PackageIndicatorProps {
packageId: SDKPackageId
order: number
name: string
icon: string
completion: number
isActive: boolean
isExpanded: boolean
isLocked: boolean
onToggle: () => void
collapsed: boolean
}
function PackageIndicator({
order,
name,
icon,
completion,
isActive,
isExpanded,
isLocked,
onToggle,
collapsed,
}: PackageIndicatorProps) {
if (collapsed) {
return (
<button
onClick={onToggle}
className={`w-full flex items-center justify-center py-3 transition-colors ${
isActive
? 'bg-purple-50 border-l-4 border-purple-600'
: isLocked
? 'border-l-4 border-transparent opacity-50'
: 'hover:bg-gray-50 border-l-4 border-transparent'
}`}
title={`${order}. ${name} (${completion}%)`}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-lg ${
isLocked
? 'bg-gray-200 text-gray-400'
: isActive
? 'bg-purple-600 text-white'
: completion === 100
? 'bg-green-500 text-white'
: 'bg-gray-200 text-gray-600'
}`}
>
{isLocked ? <LockIcon /> : completion === 100 ? <CheckIcon /> : icon}
</div>
</button>
)
}
return (
<button
onClick={onToggle}
disabled={isLocked}
className={`w-full flex items-center justify-between px-4 py-3 text-left transition-colors ${
isLocked
? 'opacity-50 cursor-not-allowed'
: isActive
? 'bg-purple-50 border-l-4 border-purple-600'
: 'hover:bg-gray-50 border-l-4 border-transparent'
}`}
>
<div className="flex items-center gap-3">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-lg ${
isLocked
? 'bg-gray-200 text-gray-400'
: isActive
? 'bg-purple-600 text-white'
: completion === 100
? 'bg-green-500 text-white'
: 'bg-gray-200 text-gray-600'
}`}
>
{isLocked ? <LockIcon /> : completion === 100 ? <CheckIcon /> : icon}
</div>
<div>
<div className={`font-medium text-sm ${isActive ? 'text-purple-900' : isLocked ? 'text-gray-400' : 'text-gray-700'}`}>
{order}. {name}
</div>
<div className="text-xs text-gray-500">{completion}%</div>
</div>
</div>
{!isLocked && <ChevronDownIcon className={`transition-transform ${isExpanded ? 'rotate-180' : ''}`} />}
</button>
)
}
// =============================================================================
// STEP ITEM
// =============================================================================
interface StepItemProps {
step: SDKStep
isActive: boolean
isCompleted: boolean
isLocked: boolean
checkpointStatus?: 'passed' | 'failed' | 'warning' | 'pending'
collapsed: boolean
projectId?: string
}
function StepItem({ step, isActive, isCompleted, isLocked, checkpointStatus, collapsed, projectId }: StepItemProps) {
const content = (
<div
className={`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
collapsed ? 'justify-center' : ''
} ${
isActive
? 'bg-purple-100 text-purple-900 font-medium'
: isLocked
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
title={collapsed ? step.name : undefined}
>
{/* Step indicator */}
<div className="flex-shrink-0">
{isCompleted ? (
<div className="w-5 h-5 rounded-full bg-green-500 text-white flex items-center justify-center">
<CheckIcon />
</div>
) : isLocked ? (
<div className="w-5 h-5 rounded-full bg-gray-200 text-gray-400 flex items-center justify-center">
<LockIcon />
</div>
) : isActive ? (
<div className="w-5 h-5 rounded-full bg-purple-600 flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-white" />
</div>
) : (
<div className="w-5 h-5 rounded-full border-2 border-gray-300" />
)}
</div>
{/* Step name - hidden when collapsed */}
{!collapsed && <span className="flex-1 truncate">{step.nameShort}</span>}
{/* Checkpoint status - hidden when collapsed */}
{!collapsed && checkpointStatus && checkpointStatus !== 'pending' && (
<div className="flex-shrink-0">
{checkpointStatus === 'passed' ? (
<div className="w-4 h-4 rounded-full bg-green-100 text-green-600 flex items-center justify-center">
<CheckIcon />
</div>
) : checkpointStatus === 'failed' ? (
<div className="w-4 h-4 rounded-full bg-red-100 text-red-600 flex items-center justify-center">
<span className="text-xs font-bold">!</span>
</div>
) : (
<div className="w-4 h-4 rounded-full bg-yellow-100 text-yellow-600 flex items-center justify-center">
<WarningIcon />
</div>
)}
</div>
)}
</div>
)
if (isLocked) {
return content
}
return (
<Link href={withProject(step.url, projectId)} className="block">
{content}
</Link>
)
}
// =============================================================================
// ADDITIONAL MODULE ITEM
// =============================================================================
interface AdditionalModuleItemProps {
href: string
icon: React.ReactNode
label: string
isActive: boolean
collapsed: boolean
projectId?: string
}
function AdditionalModuleItem({ href, icon, label, isActive, collapsed, projectId }: AdditionalModuleItemProps) {
const isExternal = href.startsWith('http')
const className = `flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
collapsed ? 'justify-center' : ''
} ${
isActive
? 'bg-purple-100 text-purple-900 font-medium'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`
if (isExternal) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" className={className} title={collapsed ? label : undefined}>
{icon}
{!collapsed && (
<span className="flex items-center gap-1">
{label}
<svg className="w-3 h-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</span>
)}
</a>
)
}
return (
<Link href={withProject(href, projectId)} className={className} title={collapsed ? label : undefined}>
{icon}
{!collapsed && <span>{label}</span>}
</Link>
)
}
// =============================================================================
// MAIN SIDEBAR
// =============================================================================
import { CollapseIcon } from './SidebarIcons'
import {
ProgressBar,
PackageIndicator,
StepItem,
CorpusStalenessInfo,
} from './SidebarSubComponents'
import { SidebarModuleNav } from './SidebarModuleNav'
interface SDKSidebarProps {
collapsed?: boolean
onCollapsedChange?: (collapsed: boolean) => void
}
// =============================================================================
// CORPUS STALENESS INFO
// =============================================================================
function CorpusStalenessInfo({ ragCorpusStatus }: { ragCorpusStatus: RAGCorpusStatus }) {
const collections = ragCorpusStatus.collections
const collectionNames = Object.keys(collections)
if (collectionNames.length === 0) return null
// Check if corpus was updated after the last fetch (simplified: show last update time)
const lastUpdated = collectionNames.reduce((latest, name) => {
const updated = new Date(collections[name].last_updated)
return updated > latest ? updated : latest
}, new Date(0))
const daysSinceUpdate = Math.floor((Date.now() - lastUpdated.getTime()) / (1000 * 60 * 60 * 24))
const totalChunks = collectionNames.reduce((sum, name) => sum + collections[name].chunks_count, 0)
return (
<div className="px-4 py-2 border-b border-gray-100">
<div className="flex items-center gap-2 text-xs">
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${daysSinceUpdate > 30 ? 'bg-amber-400' : 'bg-green-400'}`} />
<span className="text-gray-500 truncate">
RAG Corpus: {totalChunks} Chunks
</span>
</div>
{daysSinceUpdate > 30 && (
<div className="mt-1 text-xs text-amber-600 bg-amber-50 rounded px-2 py-1">
Corpus {daysSinceUpdate}d alt Re-Evaluation empfohlen
</div>
)}
</div>
)
}
export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarProps) {
const pathname = usePathname()
const { state, packageCompletion, completionPercentage, getCheckpointStatus, projectId } = useSDK()
@@ -404,11 +79,8 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
if (state.preferences?.allowParallelWork) return false
const pkg = SDK_PACKAGES.find(p => p.id === packageId)
if (!pkg || pkg.order === 1) return false
// Check if previous package is complete
const prevPkg = SDK_PACKAGES.find(p => p.order === pkg.order - 1)
if (!prevPkg) return false
return packageCompletion[prevPkg.id] < 100
}
@@ -428,7 +100,6 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
return steps.some(s => s.url === pathname)
}
// Filter steps based on visibleWhen conditions
const getVisibleStepsForPackage = (packageId: SDKPackageId): SDKStep[] => {
const steps = getStepsForPackage(packageId)
return steps.filter(step => {
@@ -524,368 +195,16 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
)
})}
{/* Maschinenrecht / CE */}
<div className="border-t border-gray-100 py-2">
{!collapsed && (
<div className="px-4 py-2 text-xs font-medium text-gray-400 uppercase tracking-wider">
Maschinenrecht / CE
</div>
)}
<AdditionalModuleItem
href="/sdk/iace"
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 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>
}
label="CE-Compliance (IACE)"
isActive={pathname?.startsWith('/sdk/iace') ?? false}
collapsed={collapsed}
projectId={projectId}
/>
</div>
{/* Additional Modules */}
<div className="border-t border-gray-100 py-2">
{!collapsed && (
<div className="px-4 py-2 text-xs font-medium text-gray-400 uppercase tracking-wider">
Zusatzmodule
</div>
)}
<AdditionalModuleItem
href="/sdk/training"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
}
label="Schulung (Admin)"
isActive={pathname === '/sdk/training'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/training/learner"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
}
label="Schulung (Learner)"
isActive={pathname === '/sdk/training/learner'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/rag"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
}
label="Legal RAG"
isActive={pathname === '/sdk/rag'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/quality"
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-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
}
label="AI Quality"
isActive={pathname === '/sdk/quality'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/security-backlog"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
}
label="Security Backlog"
isActive={pathname === '/sdk/security-backlog'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/compliance-hub"
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 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
}
label="Compliance Hub"
isActive={pathname === '/sdk/compliance-hub'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/assertions"
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 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
}
label="Assertions"
isActive={pathname === '/sdk/assertions'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/dsms"
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="DSMS"
isActive={pathname === '/sdk/dsms'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/sdk-flow"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
}
label="SDK Flow"
isActive={pathname === '/sdk/sdk-flow'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/architecture"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
}
label="Architektur"
isActive={pathname === '/sdk/architecture'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/agents"
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="Agenten"
isActive={pathname?.startsWith('/sdk/agents') ?? false}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/workshop"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
}
label="Workshop"
isActive={pathname === '/sdk/workshop'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/portfolio"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
}
label="Portfolio"
isActive={pathname === '/sdk/portfolio'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/roadmap"
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 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
}
label="Roadmap"
isActive={pathname === '/sdk/roadmap'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/isms"
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="ISMS (ISO 27001)"
isActive={pathname === '/sdk/isms'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/audit-llm"
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 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
}
label="LLM Audit"
isActive={pathname === '/sdk/audit-llm'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/rbac"
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="RBAC Admin"
isActive={pathname === '/sdk/rbac'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/catalog-manager"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
}
label="Kataloge"
isActive={pathname === '/sdk/catalog-manager'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/wiki"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
}
label="Compliance Wiki"
isActive={pathname?.startsWith('/sdk/wiki')}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/api-docs"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
}
label="API-Referenz"
isActive={pathname === '/sdk/api-docs'}
collapsed={collapsed}
projectId={projectId}
/>
<Link
href={withProject('/sdk/change-requests', projectId)}
className={`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
collapsed ? 'justify-center' : ''
} ${
pathname === '/sdk/change-requests'
? 'bg-purple-100 text-purple-900 font-medium'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
title={collapsed ? `Änderungsanfragen (${pendingCRCount})` : undefined}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
{!collapsed && (
<span className="flex items-center gap-2">
Änderungsanfragen
{pendingCRCount > 0 && (
<span className="px-1.5 py-0.5 text-xs font-bold bg-red-500 text-white rounded-full min-w-[1.25rem] text-center">
{pendingCRCount}
</span>
)}
</span>
)}
{collapsed && pendingCRCount > 0 && (
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
)}
</Link>
<AdditionalModuleItem
href="https://macmini:3006"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
}
label="Developer Portal"
isActive={false}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="https://macmini:8011"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
}
label="SDK Dokumentation"
isActive={false}
collapsed={collapsed}
projectId={projectId}
/>
</div>
<SidebarModuleNav
pathname={pathname}
collapsed={collapsed}
projectId={projectId}
pendingCRCount={pendingCRCount}
/>
</nav>
{/* Footer */}
<div className={`${collapsed ? 'p-2' : 'p-4'} border-t border-gray-200 bg-gray-50`}>
{/* Collapse Toggle */}
<button
onClick={() => onCollapsedChange?.(!collapsed)}
className={`w-full flex items-center justify-center gap-2 ${collapsed ? 'p-2' : 'px-4 py-2'} text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors ${collapsed ? '' : 'mb-2'}`}
@@ -895,7 +214,6 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
{!collapsed && <span>Einklappen</span>}
</button>
{/* Export Button */}
{!collapsed && (
<button
onClick={() => {}}

View File

@@ -0,0 +1,36 @@
import React from 'react'
export const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)
export const LockIcon = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
)
export const WarningIcon = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
)
export const ChevronDownIcon = ({ className = '' }: { className?: string }) => (
<svg className={`w-4 h-4 ${className}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)
export const CollapseIcon = ({ collapsed }: { collapsed: boolean }) => (
<svg
className={`w-5 h-5 transition-transform duration-300 ${collapsed ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
)

View File

@@ -0,0 +1,368 @@
import React from 'react'
import Link from 'next/link'
import { AdditionalModuleItem } from './SidebarSubComponents'
function withProject(url: string, projectId?: string): string {
if (!projectId) return url
const separator = url.includes('?') ? '&' : '?'
return `${url}${separator}project=${projectId}`
}
interface SidebarModuleNavProps {
pathname: string | null
collapsed: boolean
projectId?: string
pendingCRCount: number
}
export function SidebarModuleNav({ pathname, collapsed, projectId, pendingCRCount }: SidebarModuleNavProps) {
return (
<>
{/* Maschinenrecht / CE */}
<div className="border-t border-gray-100 py-2">
{!collapsed && (
<div className="px-4 py-2 text-xs font-medium text-gray-400 uppercase tracking-wider">
Maschinenrecht / CE
</div>
)}
<AdditionalModuleItem
href="/sdk/iace"
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 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>
}
label="CE-Compliance (IACE)"
isActive={pathname?.startsWith('/sdk/iace') ?? false}
collapsed={collapsed}
projectId={projectId}
/>
</div>
{/* Additional Modules */}
<div className="border-t border-gray-100 py-2">
{!collapsed && (
<div className="px-4 py-2 text-xs font-medium text-gray-400 uppercase tracking-wider">
Zusatzmodule
</div>
)}
<AdditionalModuleItem
href="/sdk/training"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
}
label="Schulung (Admin)"
isActive={pathname === '/sdk/training'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/training/learner"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
}
label="Schulung (Learner)"
isActive={pathname === '/sdk/training/learner'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/rag"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
}
label="Legal RAG"
isActive={pathname === '/sdk/rag'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/quality"
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-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}
label="AI Quality"
isActive={pathname === '/sdk/quality'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/security-backlog"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
}
label="Security Backlog"
isActive={pathname === '/sdk/security-backlog'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/compliance-hub"
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 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
}
label="Compliance Hub"
isActive={pathname === '/sdk/compliance-hub'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/assertions"
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 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
}
label="Assertions"
isActive={pathname === '/sdk/assertions'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/dsms"
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="DSMS"
isActive={pathname === '/sdk/dsms'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/sdk-flow"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
}
label="SDK Flow"
isActive={pathname === '/sdk/sdk-flow'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/architecture"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
}
label="Architektur"
isActive={pathname === '/sdk/architecture'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/agents"
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="Agenten"
isActive={pathname?.startsWith('/sdk/agents') ?? false}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/workshop"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
}
label="Workshop"
isActive={pathname === '/sdk/workshop'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/portfolio"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
}
label="Portfolio"
isActive={pathname === '/sdk/portfolio'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/roadmap"
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 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
}
label="Roadmap"
isActive={pathname === '/sdk/roadmap'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/isms"
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="ISMS (ISO 27001)"
isActive={pathname === '/sdk/isms'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/audit-llm"
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 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
}
label="LLM Audit"
isActive={pathname === '/sdk/audit-llm'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/rbac"
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="RBAC Admin"
isActive={pathname === '/sdk/rbac'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/catalog-manager"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
</svg>
}
label="Kataloge"
isActive={pathname === '/sdk/catalog-manager'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/wiki"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
}
label="Compliance Wiki"
isActive={pathname?.startsWith('/sdk/wiki')}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/api-docs"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
}
label="API-Referenz"
isActive={pathname === '/sdk/api-docs'}
collapsed={collapsed}
projectId={projectId}
/>
<Link
href={withProject('/sdk/change-requests', projectId)}
className={`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
collapsed ? 'justify-center' : ''
} ${
pathname === '/sdk/change-requests'
? 'bg-purple-100 text-purple-900 font-medium'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
title={collapsed ? `Änderungsanfragen (${pendingCRCount})` : undefined}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
{!collapsed && (
<span className="flex items-center gap-2">
Änderungsanfragen
{pendingCRCount > 0 && (
<span className="px-1.5 py-0.5 text-xs font-bold bg-red-500 text-white rounded-full min-w-[1.25rem] text-center">
{pendingCRCount}
</span>
)}
</span>
)}
{collapsed && pendingCRCount > 0 && (
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
)}
</Link>
<AdditionalModuleItem
href="https://macmini:3006"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
}
label="Developer Portal"
isActive={false}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="https://macmini:8011"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
}
label="SDK Dokumentation"
isActive={false}
collapsed={collapsed}
projectId={projectId}
/>
</div>
</>
)
}

View File

@@ -0,0 +1,282 @@
import React from 'react'
import Link from 'next/link'
import type { SDKStep, RAGCorpusStatus, SDKPackageId } from '@/lib/sdk'
import { CheckIcon, LockIcon, WarningIcon, ChevronDownIcon } from './SidebarIcons'
function withProject(url: string, projectId?: string): string {
if (!projectId) return url
const separator = url.includes('?') ? '&' : '?'
return `${url}${separator}project=${projectId}`
}
// =============================================================================
// PROGRESS BAR
// =============================================================================
interface ProgressBarProps {
value: number
className?: string
}
export function ProgressBar({ value, className = '' }: ProgressBarProps) {
return (
<div className={`h-1 bg-gray-200 rounded-full overflow-hidden ${className}`}>
<div
className="h-full bg-purple-600 rounded-full transition-all duration-500"
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
)
}
// =============================================================================
// PACKAGE INDICATOR
// =============================================================================
interface PackageIndicatorProps {
packageId: SDKPackageId
order: number
name: string
icon: string
completion: number
isActive: boolean
isExpanded: boolean
isLocked: boolean
onToggle: () => void
collapsed: boolean
}
export function PackageIndicator({
order,
name,
icon,
completion,
isActive,
isExpanded,
isLocked,
onToggle,
collapsed,
}: PackageIndicatorProps) {
if (collapsed) {
return (
<button
onClick={onToggle}
className={`w-full flex items-center justify-center py-3 transition-colors ${
isActive
? 'bg-purple-50 border-l-4 border-purple-600'
: isLocked
? 'border-l-4 border-transparent opacity-50'
: 'hover:bg-gray-50 border-l-4 border-transparent'
}`}
title={`${order}. ${name} (${completion}%)`}
>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-lg ${
isLocked
? 'bg-gray-200 text-gray-400'
: isActive
? 'bg-purple-600 text-white'
: completion === 100
? 'bg-green-500 text-white'
: 'bg-gray-200 text-gray-600'
}`}
>
{isLocked ? <LockIcon /> : completion === 100 ? <CheckIcon /> : icon}
</div>
</button>
)
}
return (
<button
onClick={onToggle}
disabled={isLocked}
className={`w-full flex items-center justify-between px-4 py-3 text-left transition-colors ${
isLocked
? 'opacity-50 cursor-not-allowed'
: isActive
? 'bg-purple-50 border-l-4 border-purple-600'
: 'hover:bg-gray-50 border-l-4 border-transparent'
}`}
>
<div className="flex items-center gap-3">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-lg ${
isLocked
? 'bg-gray-200 text-gray-400'
: isActive
? 'bg-purple-600 text-white'
: completion === 100
? 'bg-green-500 text-white'
: 'bg-gray-200 text-gray-600'
}`}
>
{isLocked ? <LockIcon /> : completion === 100 ? <CheckIcon /> : icon}
</div>
<div>
<div className={`font-medium text-sm ${isActive ? 'text-purple-900' : isLocked ? 'text-gray-400' : 'text-gray-700'}`}>
{order}. {name}
</div>
<div className="text-xs text-gray-500">{completion}%</div>
</div>
</div>
{!isLocked && <ChevronDownIcon className={`transition-transform ${isExpanded ? 'rotate-180' : ''}`} />}
</button>
)
}
// =============================================================================
// STEP ITEM
// =============================================================================
interface StepItemProps {
step: SDKStep
isActive: boolean
isCompleted: boolean
isLocked: boolean
checkpointStatus?: 'passed' | 'failed' | 'warning' | 'pending'
collapsed: boolean
projectId?: string
}
export function StepItem({ step, isActive, isCompleted, isLocked, checkpointStatus, collapsed, projectId }: StepItemProps) {
const content = (
<div
className={`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
collapsed ? 'justify-center' : ''
} ${
isActive
? 'bg-purple-100 text-purple-900 font-medium'
: isLocked
? 'text-gray-400 cursor-not-allowed'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`}
title={collapsed ? step.name : undefined}
>
<div className="flex-shrink-0">
{isCompleted ? (
<div className="w-5 h-5 rounded-full bg-green-500 text-white flex items-center justify-center">
<CheckIcon />
</div>
) : isLocked ? (
<div className="w-5 h-5 rounded-full bg-gray-200 text-gray-400 flex items-center justify-center">
<LockIcon />
</div>
) : isActive ? (
<div className="w-5 h-5 rounded-full bg-purple-600 flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-white" />
</div>
) : (
<div className="w-5 h-5 rounded-full border-2 border-gray-300" />
)}
</div>
{!collapsed && <span className="flex-1 truncate">{step.nameShort}</span>}
{!collapsed && checkpointStatus && checkpointStatus !== 'pending' && (
<div className="flex-shrink-0">
{checkpointStatus === 'passed' ? (
<div className="w-4 h-4 rounded-full bg-green-100 text-green-600 flex items-center justify-center">
<CheckIcon />
</div>
) : checkpointStatus === 'failed' ? (
<div className="w-4 h-4 rounded-full bg-red-100 text-red-600 flex items-center justify-center">
<span className="text-xs font-bold">!</span>
</div>
) : (
<div className="w-4 h-4 rounded-full bg-yellow-100 text-yellow-600 flex items-center justify-center">
<WarningIcon />
</div>
)}
</div>
)}
</div>
)
if (isLocked) return content
return (
<Link href={withProject(step.url, projectId)} className="block">
{content}
</Link>
)
}
// =============================================================================
// ADDITIONAL MODULE ITEM
// =============================================================================
interface AdditionalModuleItemProps {
href: string
icon: React.ReactNode
label: string
isActive: boolean | undefined
collapsed: boolean
projectId?: string
}
export function AdditionalModuleItem({ href, icon, label, isActive, collapsed, projectId }: AdditionalModuleItemProps) {
const isExternal = href.startsWith('http')
const className = `flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
collapsed ? 'justify-center' : ''
} ${
isActive
? 'bg-purple-100 text-purple-900 font-medium'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
}`
if (isExternal) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" className={className} title={collapsed ? label : undefined}>
{icon}
{!collapsed && (
<span className="flex items-center gap-1">
{label}
<svg className="w-3 h-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</span>
)}
</a>
)
}
return (
<Link href={withProject(href, projectId)} className={className} title={collapsed ? label : undefined}>
{icon}
{!collapsed && <span>{label}</span>}
</Link>
)
}
// =============================================================================
// CORPUS STALENESS INFO
// =============================================================================
export function CorpusStalenessInfo({ ragCorpusStatus }: { ragCorpusStatus: RAGCorpusStatus }) {
const collections = ragCorpusStatus.collections
const collectionNames = Object.keys(collections)
if (collectionNames.length === 0) return null
const lastUpdated = collectionNames.reduce((latest, name) => {
const updated = new Date(collections[name].last_updated)
return updated > latest ? updated : latest
}, new Date(0))
const daysSinceUpdate = Math.floor((Date.now() - lastUpdated.getTime()) / (1000 * 60 * 60 * 24))
const totalChunks = collectionNames.reduce((sum, name) => sum + collections[name].chunks_count, 0)
return (
<div className="px-4 py-2 border-b border-gray-100">
<div className="flex items-center gap-2 text-xs">
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${daysSinceUpdate > 30 ? 'bg-amber-400' : 'bg-green-400'}`} />
<span className="text-gray-500 truncate">RAG Corpus: {totalChunks} Chunks</span>
</div>
{daysSinceUpdate > 30 && (
<div className="mt-1 text-xs text-amber-600 bg-amber-50 rounded px-2 py-1">
Corpus {daysSinceUpdate}d alt Re-Evaluation empfohlen
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,225 @@
'use client'
import React, { useState } from 'react'
import type { ScopeProfilingAnswer } from '@/lib/sdk/compliance-scope-types'
import { DEPARTMENT_DATA_CATEGORIES } from '@/lib/sdk/vvt-profiling'
// =============================================================================
// CONSTANTS
// =============================================================================
/** Mapping Block 8 vvt_departments values → DEPARTMENT_DATA_CATEGORIES keys */
export const DEPT_VALUE_TO_KEY: Record<string, string[]> = {
personal: ['dept_hr', 'dept_recruiting'],
finanzen: ['dept_finance'],
vertrieb: ['dept_sales'],
marketing: ['dept_marketing'],
it: ['dept_it'],
recht: ['dept_recht'],
kundenservice: ['dept_support'],
produktion: ['dept_produktion'],
logistik: ['dept_logistik'],
einkauf: ['dept_einkauf'],
facility: ['dept_facility'],
}
/** Mapping department key → scope question ID for Block 9 */
export const DEPT_KEY_TO_QUESTION: Record<string, string> = {
dept_hr: 'dk_dept_hr',
dept_recruiting: 'dk_dept_recruiting',
dept_finance: 'dk_dept_finance',
dept_sales: 'dk_dept_sales',
dept_marketing: 'dk_dept_marketing',
dept_support: 'dk_dept_support',
dept_it: 'dk_dept_it',
dept_recht: 'dk_dept_recht',
dept_produktion: 'dk_dept_produktion',
dept_logistik: 'dk_dept_logistik',
dept_einkauf: 'dk_dept_einkauf',
dept_facility: 'dk_dept_facility',
}
// =============================================================================
// DATENKATEGORIEN BLOCK 9
// =============================================================================
interface DatenkategorienBlock9Props {
answers: ScopeProfilingAnswer[]
onAnswerChange: (questionId: string, value: string | string[] | boolean | number) => void
}
export function DatenkategorienBlock9({ answers, onAnswerChange }: DatenkategorienBlock9Props) {
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set())
const [initializedDepts, setInitializedDepts] = useState<Set<string>>(new Set())
// Get selected departments from Block 8
const deptAnswer = answers.find(a => a.questionId === 'vvt_departments')
const selectedDepts = Array.isArray(deptAnswer?.value) ? (deptAnswer.value as string[]) : []
// Resolve which department keys are active
const activeDeptKeys: string[] = []
for (const deptValue of selectedDepts) {
const keys = DEPT_VALUE_TO_KEY[deptValue]
if (keys) {
for (const k of keys) {
if (!activeDeptKeys.includes(k)) activeDeptKeys.push(k)
}
}
}
const toggleDept = (deptKey: string) => {
setExpandedDepts(prev => {
const next = new Set(prev)
if (next.has(deptKey)) {
next.delete(deptKey)
} else {
next.add(deptKey)
// Prefill typical categories on first expand
if (!initializedDepts.has(deptKey)) {
const config = DEPARTMENT_DATA_CATEGORIES[deptKey]
const questionId = DEPT_KEY_TO_QUESTION[deptKey]
if (config && questionId) {
const existing = answers.find(a => a.questionId === questionId)
if (!existing) {
const typicalIds = config.categories.filter(c => c.isTypical).map(c => c.id)
onAnswerChange(questionId, typicalIds)
}
}
setInitializedDepts(p => new Set(p).add(deptKey))
}
}
return next
})
}
const handleCategoryToggle = (deptKey: string, catId: string) => {
const questionId = DEPT_KEY_TO_QUESTION[deptKey]
if (!questionId) return
const existing = answers.find(a => a.questionId === questionId)
const current = Array.isArray(existing?.value) ? (existing.value as string[]) : []
const updated = current.includes(catId)
? current.filter(id => id !== catId)
: [...current, catId]
onAnswerChange(questionId, updated)
}
if (activeDeptKeys.length === 0) {
return (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center">
<p className="text-sm text-yellow-800">
Bitte waehlen Sie zuerst in <strong>Block 8 (Verarbeitungstaetigkeiten)</strong> die
Abteilungen aus, in denen personenbezogene Daten verarbeitet werden.
</p>
</div>
)
}
return (
<div className="space-y-3">
{activeDeptKeys.map(deptKey => {
const config = DEPARTMENT_DATA_CATEGORIES[deptKey]
if (!config) return null
const questionId = DEPT_KEY_TO_QUESTION[deptKey]
const isExpanded = expandedDepts.has(deptKey)
const existing = answers.find(a => a.questionId === questionId)
const selectedCategories = Array.isArray(existing?.value) ? (existing.value as string[]) : []
const hasArt9Selected = config.categories
.filter(c => c.isArt9)
.some(c => selectedCategories.includes(c.id))
return (
<div
key={deptKey}
className={`border rounded-xl overflow-hidden transition-all ${
isExpanded ? 'border-purple-400 bg-white shadow-sm' : 'border-gray-200 bg-white'
}`}
>
{/* Header */}
<button
type="button"
onClick={() => toggleDept(deptKey)}
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-3">
<span className="text-xl">{config.icon}</span>
<div className="text-left">
<span className="text-sm font-medium text-gray-900">{config.label}</span>
{selectedCategories.length > 0 && (
<span className="ml-2 text-xs text-gray-400">
({selectedCategories.length} Kategorien)
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
{hasArt9Selected && (
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-orange-100 text-orange-700 rounded">
Art. 9
</span>
)}
<svg
className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{/* Expandable categories panel */}
{isExpanded && (
<div className="border-t border-gray-100 px-4 pt-3 pb-4">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
Datenkategorien
</p>
<div className="space-y-1.5">
{config.categories.map(cat => {
const isChecked = selectedCategories.includes(cat.id)
return (
<label
key={cat.id}
className={`flex items-start gap-3 p-2.5 rounded-lg cursor-pointer transition-colors ${
cat.isArt9
? isChecked ? 'bg-orange-50 hover:bg-orange-100' : 'bg-gray-50 hover:bg-orange-50'
: isChecked ? 'bg-purple-50 hover:bg-purple-100' : 'bg-gray-50 hover:bg-gray-100'
}`}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => handleCategoryToggle(deptKey, cat.id)}
className={`w-4 h-4 mt-0.5 rounded ${cat.isArt9 ? 'text-orange-500' : 'text-purple-600'}`}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900">{cat.label}</span>
{cat.isArt9 && (
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-orange-100 text-orange-700 rounded">
Art. 9
</span>
)}
</div>
<p className="text-xs text-gray-500 mt-0.5">{cat.info}</p>
</div>
</label>
)
})}
</div>
{/* Art. 9 warning */}
{hasArt9Selected && (
<div className="mt-3 p-3 bg-orange-50 border border-orange-200 rounded-lg">
<p className="text-xs text-orange-800">
<span className="font-semibold">Art. 9 DSGVO:</span> Sie verarbeiten besondere Kategorien
personenbezogener Daten. Eine zusaetzliche Rechtsgrundlage nach Art. 9 Abs. 2 DSGVO ist
erforderlich (z.B. § 26 Abs. 3 BDSG fuer Beschaeftigtendaten).
</p>
</div>
)}
</div>
)}
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,195 @@
'use client'
import React from 'react'
import type { ScopeProfilingAnswer, ScopeProfilingQuestion } from '@/lib/sdk/compliance-scope-types'
import { getAnswerValue } from '@/lib/sdk/compliance-scope-profiling'
// =============================================================================
// HELP TEXT
// =============================================================================
interface HelpTextProps {
question: ScopeProfilingQuestion
expandedHelp: Set<string>
onToggleHelp: (questionId: string) => void
}
export function QuestionHelpText({ question, expandedHelp, onToggleHelp }: HelpTextProps) {
if (!question.helpText) return null
return (
<>
<button
type="button"
className="ml-2 text-blue-400 hover:text-blue-600 inline-flex items-center"
onClick={(e) => { e.preventDefault(); onToggleHelp(question.id) }}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
{expandedHelp.has(question.id) && (
<div className="flex items-start gap-2 mt-2 p-2.5 bg-blue-50 rounded-lg text-xs text-blue-700 leading-relaxed">
<svg className="w-4 h-4 mt-0.5 flex-shrink-0 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{question.helpText}</span>
</div>
)}
</>
)
}
// =============================================================================
// QUESTION RENDERER
// =============================================================================
interface ScopeQuestionRendererProps {
question: ScopeProfilingQuestion
answers: ScopeProfilingAnswer[]
prefilledIds: Set<string>
expandedHelp: Set<string>
onAnswerChange: (questionId: string, value: string | string[] | boolean | number) => void
onToggleHelp: (questionId: string) => void
}
export function ScopeQuestionRenderer({
question,
answers,
prefilledIds,
expandedHelp,
onAnswerChange,
onToggleHelp,
}: ScopeQuestionRendererProps) {
const currentValue = getAnswerValue(answers, question.id)
const isPrefilled = prefilledIds.has(question.id)
const labelRow = (
<div className="flex items-center flex-wrap gap-1">
<span className="text-sm font-medium text-gray-900">{question.question}</span>
{question.required && <span className="text-red-500 ml-1">*</span>}
{isPrefilled && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
Aus Profil
</span>
)}
<QuestionHelpText question={question} expandedHelp={expandedHelp} onToggleHelp={onToggleHelp} />
</div>
)
switch (question.type) {
case 'boolean':
return (
<div className="space-y-2">
<div className="flex items-start justify-between">{labelRow}</div>
<div className="flex gap-3">
{([true, false] as const).map(val => (
<button
key={String(val)}
type="button"
onClick={() => onAnswerChange(question.id, val)}
className={`flex-1 py-2 px-4 rounded-lg border-2 transition-all ${
currentValue === val
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
}`}
>
{val ? 'Ja' : 'Nein'}
</button>
))}
</div>
</div>
)
case 'single':
return (
<div className="space-y-2">
{labelRow}
<div className="space-y-2">
{question.options?.map((option) => (
<button
key={option.value}
type="button"
onClick={() => onAnswerChange(question.id, option.value)}
className={`w-full text-left py-3 px-4 rounded-lg border-2 transition-all ${
currentValue === option.value
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
}`}
>
{option.label}
</button>
))}
</div>
</div>
)
case 'multi': {
const selectedValues = Array.isArray(currentValue) ? currentValue as string[] : []
return (
<div className="space-y-2">
{labelRow}
<div className="space-y-2">
{question.options?.map((option) => {
const isChecked = selectedValues.includes(option.value)
return (
<label
key={option.value}
className={`flex items-center gap-3 py-3 px-4 rounded-lg border-2 cursor-pointer transition-all ${
isChecked ? 'border-purple-500 bg-purple-50' : 'border-gray-300 bg-white hover:border-gray-400'
}`}
>
<input
type="checkbox"
checked={isChecked}
onChange={(e) => {
const newValues = e.target.checked
? [...selectedValues, option.value]
: selectedValues.filter((v) => v !== option.value)
onAnswerChange(question.id, newValues)
}}
className="w-5 h-5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span className={isChecked ? 'text-purple-700 font-medium' : 'text-gray-700'}>
{option.label}
</span>
</label>
)
})}
</div>
</div>
)
}
case 'number':
return (
<div className="space-y-2">
{labelRow}
<input
type="number"
value={currentValue != null ? String(currentValue) : ''}
onChange={(e) => onAnswerChange(question.id, parseInt(e.target.value, 10))}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="Zahl eingeben"
/>
</div>
)
case 'text':
return (
<div className="space-y-2">
{labelRow}
<input
type="text"
value={currentValue != null ? String(currentValue) : ''}
onChange={(e) => onAnswerChange(question.id, e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="Text eingeben"
/>
</div>
)
default:
return null
}
}

View File

@@ -1,10 +1,11 @@
'use client'
import React, { useState, useCallback, useEffect, useMemo } from 'react'
import type { ScopeProfilingAnswer, ScopeProfilingQuestion } from '@/lib/sdk/compliance-scope-types'
import { SCOPE_QUESTION_BLOCKS, getBlockProgress, getTotalProgress, getAnswerValue, prefillFromCompanyProfile, getProfileInfoForBlock, getAutoFilledScoringAnswers, getUnansweredRequiredQuestions } from '@/lib/sdk/compliance-scope-profiling'
import { DEPARTMENT_DATA_CATEGORIES } from '@/lib/sdk/vvt-profiling'
import React, { useState, useCallback, useEffect } from 'react'
import type { ScopeProfilingAnswer } from '@/lib/sdk/compliance-scope-types'
import { SCOPE_QUESTION_BLOCKS, getBlockProgress, getTotalProgress, prefillFromCompanyProfile, getProfileInfoForBlock, getAutoFilledScoringAnswers, getUnansweredRequiredQuestions } from '@/lib/sdk/compliance-scope-profiling'
import type { ScopeQuestionBlockId } from '@/lib/sdk/compliance-scope-types'
import { useSDK } from '@/lib/sdk'
import { DatenkategorienBlock9 } from './DatenkategorienBlock'
import { ScopeQuestionRenderer } from './ScopeQuestionRenderer'
interface ScopeWizardTabProps {
answers: ScopeProfilingAnswer[]
@@ -28,18 +29,15 @@ export function ScopeWizardTab({
const currentBlock = SCOPE_QUESTION_BLOCKS[currentBlockIndex]
const totalProgress = getTotalProgress(answers)
// Load companyProfile from SDK context
const { state: sdkState } = useSDK()
const companyProfile = sdkState.companyProfile
// Track which question IDs were prefilled from profile
const [prefilledIds, setPrefilledIds] = useState<Set<string>>(new Set())
// Auto-prefill from company profile on mount if answers are empty
useEffect(() => {
if (companyProfile && answers.length === 0) {
const prefilled = prefillFromCompanyProfile(companyProfile)
// Also inject auto-filled scoring answers for questions removed from UI
const autoFilled = getAutoFilledScoringAnswers(companyProfile)
const allPrefilled = [...prefilled, ...autoFilled]
if (allPrefilled.length > 0) {
@@ -47,7 +45,6 @@ export function ScopeWizardTab({
setPrefilledIds(new Set(allPrefilled.map(a => a.questionId)))
}
}
// Only run on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@@ -61,7 +58,6 @@ export function ScopeWizardTab({
} else {
onAnswersChange([...answers, { questionId, value }])
}
// Remove from prefilled set when user manually changes
if (prefilledIds.has(questionId)) {
setPrefilledIds(prev => {
const next = new Set(prev)
@@ -78,7 +74,6 @@ export function ScopeWizardTab({
const prefilled = prefillFromCompanyProfile(companyProfile)
const autoFilled = getAutoFilledScoringAnswers(companyProfile)
const allPrefilled = [...prefilled, ...autoFilled]
// Merge with existing answers: prefilled values for questions not yet answered
const existingIds = new Set(answers.map(a => a.questionId))
const newAnswers = [...answers]
const newPrefilledIds = new Set(prefilledIds)
@@ -101,242 +96,18 @@ export function ScopeWizardTab({
}, [currentBlockIndex, canEvaluate, onEvaluate])
const handleBack = useCallback(() => {
if (currentBlockIndex > 0) {
setCurrentBlockIndex(currentBlockIndex - 1)
}
if (currentBlockIndex > 0) setCurrentBlockIndex(currentBlockIndex - 1)
}, [currentBlockIndex])
const toggleHelp = useCallback((questionId: string) => {
setExpandedHelp(prev => {
const next = new Set(prev)
if (next.has(questionId)) {
next.delete(questionId)
} else {
next.add(questionId)
}
if (next.has(questionId)) next.delete(questionId)
else next.add(questionId)
return next
})
}, [])
// Check if a question was prefilled from company profile
const isPrefilledFromProfile = useCallback((questionId: string) => {
return prefilledIds.has(questionId)
}, [prefilledIds])
const renderHelpText = (question: ScopeProfilingQuestion) => {
if (!question.helpText) return null
return (
<>
<button
type="button"
className="ml-2 text-blue-400 hover:text-blue-600 inline-flex items-center"
onClick={(e) => {
e.preventDefault()
toggleHelp(question.id)
}}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
{expandedHelp.has(question.id) && (
<div className="flex items-start gap-2 mt-2 p-2.5 bg-blue-50 rounded-lg text-xs text-blue-700 leading-relaxed">
<svg className="w-4 h-4 mt-0.5 flex-shrink-0 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{question.helpText}</span>
</div>
)}
</>
)
}
const renderPrefilledBadge = (questionId: string) => {
if (!isPrefilledFromProfile(questionId)) return null
return (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">
Aus Profil
</span>
)
}
const renderQuestion = (question: ScopeProfilingQuestion) => {
const currentValue = getAnswerValue(answers, question.id)
switch (question.type) {
case 'boolean':
return (
<div className="space-y-2">
<div className="flex items-start justify-between">
<div className="flex items-center flex-wrap gap-1">
<span className="text-sm font-medium text-gray-900">
{question.question}
</span>
{question.required && <span className="text-red-500 ml-1">*</span>}
{renderPrefilledBadge(question.id)}
{renderHelpText(question)}
</div>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => handleAnswerChange(question.id, true)}
className={`flex-1 py-2 px-4 rounded-lg border-2 transition-all ${
currentValue === true
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
}`}
>
Ja
</button>
<button
type="button"
onClick={() => handleAnswerChange(question.id, false)}
className={`flex-1 py-2 px-4 rounded-lg border-2 transition-all ${
currentValue === false
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
}`}
>
Nein
</button>
</div>
</div>
)
case 'single':
return (
<div className="space-y-2">
<div className="flex items-center flex-wrap gap-1">
<span className="text-sm font-medium text-gray-900">
{question.question}
</span>
{question.required && <span className="text-red-500 ml-1">*</span>}
{renderPrefilledBadge(question.id)}
{renderHelpText(question)}
</div>
<div className="space-y-2">
{question.options?.map((option) => (
<button
key={option.value}
type="button"
onClick={() => handleAnswerChange(question.id, option.value)}
className={`w-full text-left py-3 px-4 rounded-lg border-2 transition-all ${
currentValue === option.value
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
}`}
>
{option.label}
</button>
))}
</div>
</div>
)
case 'multi':
return (
<div className="space-y-2">
<div className="flex items-center flex-wrap gap-1">
<span className="text-sm font-medium text-gray-900">
{question.question}
</span>
{question.required && <span className="text-red-500 ml-1">*</span>}
{renderPrefilledBadge(question.id)}
{renderHelpText(question)}
</div>
<div className="space-y-2">
{question.options?.map((option) => {
const selectedValues = Array.isArray(currentValue) ? currentValue as string[] : []
const isChecked = selectedValues.includes(option.value)
return (
<label
key={option.value}
className={`flex items-center gap-3 py-3 px-4 rounded-lg border-2 cursor-pointer transition-all ${
isChecked
? 'border-purple-500 bg-purple-50'
: 'border-gray-300 bg-white hover:border-gray-400'
}`}
>
<input
type="checkbox"
checked={isChecked}
onChange={(e) => {
const newValues = e.target.checked
? [...selectedValues, option.value]
: selectedValues.filter((v) => v !== option.value)
handleAnswerChange(question.id, newValues)
}}
className="w-5 h-5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span className={isChecked ? 'text-purple-700 font-medium' : 'text-gray-700'}>
{option.label}
</span>
</label>
)
})}
</div>
</div>
)
case 'number':
return (
<div className="space-y-2">
<div className="flex items-center flex-wrap gap-1">
<span className="text-sm font-medium text-gray-900">
{question.question}
</span>
{question.required && <span className="text-red-500 ml-1">*</span>}
{renderPrefilledBadge(question.id)}
{renderHelpText(question)}
</div>
<input
type="number"
value={currentValue != null ? String(currentValue) : ''}
onChange={(e) => handleAnswerChange(question.id, parseInt(e.target.value, 10))}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="Zahl eingeben"
/>
</div>
)
case 'text':
return (
<div className="space-y-2">
<div className="flex items-center flex-wrap gap-1">
<span className="text-sm font-medium text-gray-900">
{question.question}
</span>
{question.required && <span className="text-red-500 ml-1">*</span>}
{renderPrefilledBadge(question.id)}
{renderHelpText(question)}
</div>
<input
type="text"
value={currentValue != null ? String(currentValue) : ''}
onChange={(e) => handleAnswerChange(question.id, e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="Text eingeben"
/>
</div>
)
default:
return null
}
}
return (
<div className="flex gap-6 h-full">
{/* Left Sidebar - Block Navigation */}
@@ -350,7 +121,6 @@ export function ScopeWizardTab({
const unanswered = getUnansweredRequiredQuestions(answers, block.id)
const hasRequired = block.questions.some(q => q.required)
const allRequiredDone = hasRequired && unanswered.length === 0
// For optional-only blocks: check if any questions were answered
const answeredIds = new Set(answers.map(a => a.questionId))
const hasAnyAnswer = block.questions.some(q => answeredIds.has(q.id))
const optionalDone = !hasRequired && hasAnyAnswer
@@ -380,19 +150,13 @@ export function ScopeWizardTab({
) : !hasRequired ? (
<span className="text-xs text-gray-400">(nur optional)</span>
) : (
<span className="text-xs font-semibold text-orange-600">
{unanswered.length} offen
</span>
<span className="text-xs font-semibold text-orange-600">{unanswered.length} offen</span>
)}
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5 overflow-hidden">
<div
className={`h-full transition-all ${
allRequiredDone || optionalDone
? 'bg-green-500'
: !hasRequired
? 'bg-gray-300'
: 'bg-orange-400'
allRequiredDone || optionalDone ? 'bg-green-500' : !hasRequired ? 'bg-gray-300' : 'bg-orange-400'
}`}
style={{ width: `${progress}%` }}
/>
@@ -428,8 +192,6 @@ export function ScopeWizardTab({
{(() => {
const allUnanswered = getUnansweredRequiredQuestions(answers)
if (allUnanswered.length === 0) return null
// Group by block
const byBlock = new Map<string, { blockTitle: string; blockIndex: number; count: number }>()
for (const item of allUnanswered) {
if (!byBlock.has(item.blockId)) {
@@ -438,7 +200,6 @@ export function ScopeWizardTab({
}
byBlock.get(item.blockId)!.count++
}
return (
<div className="mt-3 flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-orange-600 font-medium"> Offene Pflichtfragen:</span>
@@ -477,7 +238,7 @@ export function ScopeWizardTab({
)}
</div>
{/* "Aus Profil" Info Box — shown for blocks that have auto-filled data */}
{/* "Aus Profil" Info Box */}
{companyProfile && (() => {
const profileItems = getProfileInfoForBlock(companyProfile, currentBlock.id as ScopeQuestionBlockId)
if (profileItems.length === 0) return null
@@ -516,21 +277,23 @@ export function ScopeWizardTab({
{/* Questions */}
<div className="space-y-6">
{currentBlock.id === 'datenkategorien_detail' ? (
<DatenkategorienBlock9
answers={answers}
onAnswerChange={handleAnswerChange}
/>
<DatenkategorienBlock9 answers={answers} onAnswerChange={handleAnswerChange} />
) : (
currentBlock.questions.map((question) => {
const isAnswered = answers.some(a => a.questionId === question.id)
const borderClass = question.required
? isAnswered
? 'border-l-4 border-l-green-400 pl-4'
: 'border-l-4 border-l-orange-400 pl-4'
? isAnswered ? 'border-l-4 border-l-green-400 pl-4' : 'border-l-4 border-l-orange-400 pl-4'
: ''
return (
<div key={question.id} className={`border-b border-gray-100 pb-6 last:border-b-0 last:pb-0 ${borderClass}`}>
{renderQuestion(question)}
<ScopeQuestionRenderer
question={question}
answers={answers}
prefilledIds={prefilledIds}
expandedHelp={expandedHelp}
onAnswerChange={handleAnswerChange}
onToggleHelp={toggleHelp}
/>
</div>
)
})
@@ -574,221 +337,3 @@ export function ScopeWizardTab({
</div>
)
}
// =============================================================================
// BLOCK 9: Datenkategorien pro Abteilung (aufklappbare Kacheln)
// =============================================================================
/** Mapping Block 8 vvt_departments values → DEPARTMENT_DATA_CATEGORIES keys */
const DEPT_VALUE_TO_KEY: Record<string, string[]> = {
personal: ['dept_hr', 'dept_recruiting'],
finanzen: ['dept_finance'],
vertrieb: ['dept_sales'],
marketing: ['dept_marketing'],
it: ['dept_it'],
recht: ['dept_recht'],
kundenservice: ['dept_support'],
produktion: ['dept_produktion'],
logistik: ['dept_logistik'],
einkauf: ['dept_einkauf'],
facility: ['dept_facility'],
}
/** Mapping department key → scope question ID for Block 9 */
const DEPT_KEY_TO_QUESTION: Record<string, string> = {
dept_hr: 'dk_dept_hr',
dept_recruiting: 'dk_dept_recruiting',
dept_finance: 'dk_dept_finance',
dept_sales: 'dk_dept_sales',
dept_marketing: 'dk_dept_marketing',
dept_support: 'dk_dept_support',
dept_it: 'dk_dept_it',
dept_recht: 'dk_dept_recht',
dept_produktion: 'dk_dept_produktion',
dept_logistik: 'dk_dept_logistik',
dept_einkauf: 'dk_dept_einkauf',
dept_facility: 'dk_dept_facility',
}
function DatenkategorienBlock9({
answers,
onAnswerChange,
}: {
answers: ScopeProfilingAnswer[]
onAnswerChange: (questionId: string, value: string | string[] | boolean | number) => void
}) {
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set())
const [initializedDepts, setInitializedDepts] = useState<Set<string>>(new Set())
// Get selected departments from Block 8
const deptAnswer = answers.find(a => a.questionId === 'vvt_departments')
const selectedDepts = Array.isArray(deptAnswer?.value) ? (deptAnswer.value as string[]) : []
// Resolve which department keys are active
const activeDeptKeys: string[] = []
for (const deptValue of selectedDepts) {
const keys = DEPT_VALUE_TO_KEY[deptValue]
if (keys) {
for (const k of keys) {
if (!activeDeptKeys.includes(k)) activeDeptKeys.push(k)
}
}
}
const toggleDept = (deptKey: string) => {
setExpandedDepts(prev => {
const next = new Set(prev)
if (next.has(deptKey)) {
next.delete(deptKey)
} else {
next.add(deptKey)
// Prefill typical categories on first expand
if (!initializedDepts.has(deptKey)) {
const config = DEPARTMENT_DATA_CATEGORIES[deptKey]
const questionId = DEPT_KEY_TO_QUESTION[deptKey]
if (config && questionId) {
const existing = answers.find(a => a.questionId === questionId)
if (!existing) {
const typicalIds = config.categories.filter(c => c.isTypical).map(c => c.id)
onAnswerChange(questionId, typicalIds)
}
}
setInitializedDepts(p => new Set(p).add(deptKey))
}
}
return next
})
}
const handleCategoryToggle = (deptKey: string, catId: string) => {
const questionId = DEPT_KEY_TO_QUESTION[deptKey]
if (!questionId) return
const existing = answers.find(a => a.questionId === questionId)
const current = Array.isArray(existing?.value) ? (existing.value as string[]) : []
const updated = current.includes(catId)
? current.filter(id => id !== catId)
: [...current, catId]
onAnswerChange(questionId, updated)
}
if (activeDeptKeys.length === 0) {
return (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center">
<p className="text-sm text-yellow-800">
Bitte waehlen Sie zuerst in <strong>Block 8 (Verarbeitungstaetigkeiten)</strong> die
Abteilungen aus, in denen personenbezogene Daten verarbeitet werden.
</p>
</div>
)
}
return (
<div className="space-y-3">
{activeDeptKeys.map(deptKey => {
const config = DEPARTMENT_DATA_CATEGORIES[deptKey]
if (!config) return null
const questionId = DEPT_KEY_TO_QUESTION[deptKey]
const isExpanded = expandedDepts.has(deptKey)
const existing = answers.find(a => a.questionId === questionId)
const selectedCategories = Array.isArray(existing?.value) ? (existing.value as string[]) : []
const hasArt9Selected = config.categories
.filter(c => c.isArt9)
.some(c => selectedCategories.includes(c.id))
return (
<div
key={deptKey}
className={`border rounded-xl overflow-hidden transition-all ${
isExpanded ? 'border-purple-400 bg-white shadow-sm' : 'border-gray-200 bg-white'
}`}
>
{/* Header */}
<button
type="button"
onClick={() => toggleDept(deptKey)}
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-3">
<span className="text-xl">{config.icon}</span>
<div className="text-left">
<span className="text-sm font-medium text-gray-900">{config.label}</span>
{selectedCategories.length > 0 && (
<span className="ml-2 text-xs text-gray-400">
({selectedCategories.length} Kategorien)
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
{hasArt9Selected && (
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-orange-100 text-orange-700 rounded">
Art. 9
</span>
)}
<svg
className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{/* Expandable categories panel */}
{isExpanded && (
<div className="border-t border-gray-100 px-4 pt-3 pb-4">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
Datenkategorien
</p>
<div className="space-y-1.5">
{config.categories.map(cat => {
const isChecked = selectedCategories.includes(cat.id)
return (
<label
key={cat.id}
className={`flex items-start gap-3 p-2.5 rounded-lg cursor-pointer transition-colors ${
cat.isArt9
? isChecked ? 'bg-orange-50 hover:bg-orange-100' : 'bg-gray-50 hover:bg-orange-50'
: isChecked ? 'bg-purple-50 hover:bg-purple-100' : 'bg-gray-50 hover:bg-gray-100'
}`}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => handleCategoryToggle(deptKey, cat.id)}
className={`w-4 h-4 mt-0.5 rounded ${cat.isArt9 ? 'text-orange-500' : 'text-purple-600'}`}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900">{cat.label}</span>
{cat.isArt9 && (
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-orange-100 text-orange-700 rounded">
Art. 9
</span>
)}
</div>
<p className="text-xs text-gray-500 mt-0.5">{cat.info}</p>
</div>
</label>
)
})}
</div>
{/* Art. 9 warning */}
{hasArt9Selected && (
<div className="mt-3 p-3 bg-orange-50 border border-orange-200 rounded-lg">
<p className="text-xs text-orange-800">
<span className="font-semibold">Art. 9 DSGVO:</span> Sie verarbeiten besondere Kategorien
personenbezogener Daten. Eine zusaetzliche Rechtsgrundlage nach Art. 9 Abs. 2 DSGVO ist
erforderlich (z.B. § 26 Abs. 3 BDSG fuer Beschaeftigtendaten).
</p>
</div>
)}
</div>
)}
</div>
)
})}
</div>
)
}

View File

@@ -39,7 +39,7 @@ go build -o server ./cmd/server
# Production: CI/CD (automatisch bei Push auf main)
git push origin main && git push gitea main
# → Gitea Actions: Tests → Build → Deploy auf Coolify
# → Gitea Actions: Tests → Build → Deploy auf Orca
# → Status: https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions
# Alternativ: mit Docker (lokal)
@@ -466,7 +466,7 @@ Tests laufen automatisch bei jedem Push via Gitea Actions (`.gitea/workflows/ci.
| `test-python-document-crawler` | `python:3.12-slim` | `pytest tests/` |
| `test-python-dsms-gateway` | `python:3.12-slim` | `pytest test_main.py` |
Nach erfolgreichen Tests: automatisches Deploy auf Coolify (`deploy-coolify` Job).
Nach erfolgreichen Tests: automatisches Deploy auf Orca (`deploy-orca` Job).
### Spezifische Tests

View File

@@ -347,14 +347,23 @@ async def list_controls(
query += " AND release_state = :rs"
params["rs"] = release_state
if verification_method:
query += " AND verification_method = :vm"
params["vm"] = verification_method
if verification_method == "__none__":
query += " AND verification_method IS NULL"
else:
query += " AND verification_method = :vm"
params["vm"] = verification_method
if category:
query += " AND category = :cat"
params["cat"] = category
if category == "__none__":
query += " AND category IS NULL"
else:
query += " AND category = :cat"
params["cat"] = category
if evidence_type:
query += " AND evidence_type = :et"
params["et"] = evidence_type
if evidence_type == "__none__":
query += " AND evidence_type IS NULL"
else:
query += " AND evidence_type = :et"
params["et"] = evidence_type
if target_audience:
query += " AND target_audience LIKE :ta_pattern"
params["ta_pattern"] = f'%"{target_audience}"%'
@@ -368,6 +377,11 @@ async def list_controls(
query += " AND decomposition_method = 'pass0b'"
elif control_type == "rich":
query += " AND (decomposition_method IS NULL OR decomposition_method != 'pass0b')"
elif control_type == "eigenentwicklung":
query += """ AND generation_strategy = 'ungrouped'
AND (pipeline_version = '1' OR pipeline_version IS NULL)
AND source_citation IS NULL
AND parent_control_uuid IS NULL"""
if search:
query += " AND (control_id ILIKE :q OR title ILIKE :q OR objective ILIKE :q)"
params["q"] = f"%{search}%"
@@ -429,14 +443,23 @@ async def count_controls(
query += " AND release_state = :rs"
params["rs"] = release_state
if verification_method:
query += " AND verification_method = :vm"
params["vm"] = verification_method
if verification_method == "__none__":
query += " AND verification_method IS NULL"
else:
query += " AND verification_method = :vm"
params["vm"] = verification_method
if category:
query += " AND category = :cat"
params["cat"] = category
if category == "__none__":
query += " AND category IS NULL"
else:
query += " AND category = :cat"
params["cat"] = category
if evidence_type:
query += " AND evidence_type = :et"
params["et"] = evidence_type
if evidence_type == "__none__":
query += " AND evidence_type IS NULL"
else:
query += " AND evidence_type = :et"
params["et"] = evidence_type
if target_audience:
query += " AND target_audience LIKE :ta_pattern"
params["ta_pattern"] = f'%"{target_audience}"%'
@@ -450,6 +473,11 @@ async def count_controls(
query += " AND decomposition_method = 'pass0b'"
elif control_type == "rich":
query += " AND (decomposition_method IS NULL OR decomposition_method != 'pass0b')"
elif control_type == "eigenentwicklung":
query += """ AND generation_strategy = 'ungrouped'
AND (pipeline_version = '1' OR pipeline_version IS NULL)
AND source_citation IS NULL
AND parent_control_uuid IS NULL"""
if search:
query += " AND (control_id ILIKE :q OR title ILIKE :q OR objective ILIKE :q)"
params["q"] = f"%{search}%"
@@ -461,34 +489,189 @@ async def count_controls(
@router.get("/controls-meta")
async def controls_meta():
"""Return aggregated metadata for filter dropdowns (domains, sources, counts)."""
async def controls_meta(
severity: Optional[str] = Query(None),
domain: Optional[str] = Query(None),
release_state: Optional[str] = Query(None),
verification_method: Optional[str] = Query(None),
category: Optional[str] = Query(None),
evidence_type: Optional[str] = Query(None),
target_audience: Optional[str] = Query(None),
source: Optional[str] = Query(None),
search: Optional[str] = Query(None),
control_type: Optional[str] = Query(None),
exclude_duplicates: bool = Query(False),
):
"""Return faceted metadata for filter dropdowns.
Each facet's counts respect ALL active filters EXCEPT the facet's own,
so dropdowns always show how many items each option would yield.
"""
def _build_where(skip: Optional[str] = None) -> tuple[str, dict[str, Any]]:
clauses = ["1=1"]
p: dict[str, Any] = {}
if exclude_duplicates:
clauses.append("release_state != 'duplicate'")
if severity and skip != "severity":
clauses.append("severity = :sev")
p["sev"] = severity
if domain and skip != "domain":
clauses.append("LEFT(control_id, LENGTH(:dom)) = :dom")
p["dom"] = domain.upper()
if release_state and skip != "release_state":
clauses.append("release_state = :rs")
p["rs"] = release_state
if verification_method and skip != "verification_method":
if verification_method == "__none__":
clauses.append("verification_method IS NULL")
else:
clauses.append("verification_method = :vm")
p["vm"] = verification_method
if category and skip != "category":
if category == "__none__":
clauses.append("category IS NULL")
else:
clauses.append("category = :cat")
p["cat"] = category
if evidence_type and skip != "evidence_type":
if evidence_type == "__none__":
clauses.append("evidence_type IS NULL")
else:
clauses.append("evidence_type = :et")
p["et"] = evidence_type
if target_audience and skip != "target_audience":
clauses.append("target_audience LIKE :ta_pattern")
p["ta_pattern"] = f'%"{target_audience}"%'
if source and skip != "source":
if source == "__none__":
clauses.append("(source_citation IS NULL OR source_citation->>'source' IS NULL OR source_citation->>'source' = '')")
else:
clauses.append("source_citation->>'source' = :src")
p["src"] = source
if control_type and skip != "control_type":
if control_type == "atomic":
clauses.append("decomposition_method = 'pass0b'")
elif control_type == "rich":
clauses.append("(decomposition_method IS NULL OR decomposition_method != 'pass0b')")
elif control_type == "eigenentwicklung":
clauses.append("""generation_strategy = 'ungrouped'
AND (pipeline_version = '1' OR pipeline_version IS NULL)
AND source_citation IS NULL
AND parent_control_uuid IS NULL""")
if search and skip != "search":
clauses.append("(control_id ILIKE :q OR title ILIKE :q OR objective ILIKE :q)")
p["q"] = f"%{search}%"
return " AND ".join(clauses), p
with SessionLocal() as db:
total = db.execute(text("SELECT count(*) FROM canonical_controls")).scalar()
# Total with ALL filters
w_all, p_all = _build_where()
total = db.execute(text(f"SELECT count(*) FROM canonical_controls WHERE {w_all}"), p_all).scalar()
domains = db.execute(text("""
# Domain facet (skip domain filter so user sees all domains)
w_dom, p_dom = _build_where(skip="domain")
domains = db.execute(text(f"""
SELECT UPPER(SPLIT_PART(control_id, '-', 1)) as domain, count(*) as cnt
FROM canonical_controls
FROM canonical_controls WHERE {w_dom}
GROUP BY domain ORDER BY domain
""")).fetchall()
"""), p_dom).fetchall()
sources = db.execute(text("""
# Source facet (skip source filter)
w_src, p_src = _build_where(skip="source")
sources = db.execute(text(f"""
SELECT source_citation->>'source' as src, count(*) as cnt
FROM canonical_controls
WHERE source_citation->>'source' IS NOT NULL AND source_citation->>'source' != ''
WHERE {w_src}
AND source_citation->>'source' IS NOT NULL AND source_citation->>'source' != ''
GROUP BY src ORDER BY cnt DESC
""")).fetchall()
"""), p_src).fetchall()
no_source = db.execute(text("""
no_source = db.execute(text(f"""
SELECT count(*) FROM canonical_controls
WHERE source_citation IS NULL OR source_citation->>'source' IS NULL OR source_citation->>'source' = ''
""")).scalar()
WHERE {w_src}
AND (source_citation IS NULL OR source_citation->>'source' IS NULL OR source_citation->>'source' = '')
"""), p_src).scalar()
# Type facet (skip control_type filter)
w_typ, p_typ = _build_where(skip="control_type")
atomic_count = db.execute(text(f"""
SELECT count(*) FROM canonical_controls
WHERE {w_typ} AND decomposition_method = 'pass0b'
"""), p_typ).scalar() or 0
eigenentwicklung_count = db.execute(text(f"""
SELECT count(*) FROM canonical_controls
WHERE {w_typ}
AND generation_strategy = 'ungrouped'
AND (pipeline_version = '1' OR pipeline_version IS NULL)
AND source_citation IS NULL
AND parent_control_uuid IS NULL
"""), p_typ).scalar() or 0
rich_count = db.execute(text(f"""
SELECT count(*) FROM canonical_controls
WHERE {w_typ}
AND (decomposition_method IS NULL OR decomposition_method != 'pass0b')
"""), p_typ).scalar() or 0
# Severity facet (skip severity filter)
w_sev, p_sev = _build_where(skip="severity")
severity_counts = db.execute(text(f"""
SELECT severity, count(*) as cnt
FROM canonical_controls WHERE {w_sev}
GROUP BY severity ORDER BY severity
"""), p_sev).fetchall()
# Verification method facet (include NULLs as __none__)
w_vm, p_vm = _build_where(skip="verification_method")
vm_counts = db.execute(text(f"""
SELECT COALESCE(verification_method, '__none__') as vm, count(*) as cnt
FROM canonical_controls WHERE {w_vm}
GROUP BY vm ORDER BY vm
"""), p_vm).fetchall()
# Category facet (include NULLs as __none__)
w_cat, p_cat = _build_where(skip="category")
cat_counts = db.execute(text(f"""
SELECT COALESCE(category, '__none__') as cat, count(*) as cnt
FROM canonical_controls WHERE {w_cat}
GROUP BY cat ORDER BY cnt DESC
"""), p_cat).fetchall()
# Evidence type facet (include NULLs as __none__)
w_et, p_et = _build_where(skip="evidence_type")
et_counts = db.execute(text(f"""
SELECT COALESCE(evidence_type, '__none__') as et, count(*) as cnt
FROM canonical_controls WHERE {w_et}
GROUP BY et ORDER BY et
"""), p_et).fetchall()
# Release state facet
w_rs, p_rs = _build_where(skip="release_state")
rs_counts = db.execute(text(f"""
SELECT release_state, count(*) as cnt
FROM canonical_controls WHERE {w_rs}
GROUP BY release_state ORDER BY release_state
"""), p_rs).fetchall()
return {
"total": total,
"domains": [{"domain": r[0], "count": r[1]} for r in domains],
"sources": [{"source": r[0], "count": r[1]} for r in sources],
"no_source_count": no_source,
"type_counts": {
"rich": rich_count,
"atomic": atomic_count,
"eigenentwicklung": eigenentwicklung_count,
},
"severity_counts": {r[0]: r[1] for r in severity_counts},
"verification_method_counts": {r[0]: r[1] for r in vm_counts},
"category_counts": {r[0]: r[1] for r in cat_counts},
"evidence_type_counts": {r[0]: r[1] for r in et_counts},
"release_state_counts": {r[0]: r[1] for r in rs_counts},
}
@@ -547,6 +730,15 @@ async def atomic_stats():
}
@router.get("/controls/v1-enrichment-stats")
async def v1_enrichment_stats_endpoint():
"""
Uebersicht: Wie viele v1 Controls haben regulatorische Abdeckung?
"""
from compliance.services.v1_enrichment import get_v1_enrichment_stats
return await get_v1_enrichment_stats()
@router.get("/controls/{control_id}")
async def get_control(control_id: str):
"""Get a single canonical control by its control_id (e.g. AUTH-001)."""
@@ -823,7 +1015,7 @@ async def get_control_provenance(control_id: str):
normative_strength, release_state
FROM obligation_candidates
WHERE parent_control_uuid = CAST(:uid AS uuid)
AND release_state NOT IN ('rejected', 'merged')
AND release_state NOT IN ('rejected', 'merged', 'duplicate')
ORDER BY candidate_id
"""),
{"uid": ctrl_uuid},
@@ -958,7 +1150,7 @@ async def backfill_normative_strength(
cc.source_citation->>'source' AS parent_source
FROM obligation_candidates oc
JOIN canonical_controls cc ON cc.id = oc.parent_control_uuid
WHERE oc.release_state NOT IN ('rejected', 'merged')
WHERE oc.release_state NOT IN ('rejected', 'merged', 'duplicate')
AND oc.normative_strength IS NOT NULL
ORDER BY oc.candidate_id
""")).fetchall()
@@ -1009,6 +1201,162 @@ async def backfill_normative_strength(
}
# =============================================================================
# OBLIGATION DEDUPLICATION
# =============================================================================
@router.post("/obligations/dedup")
async def dedup_obligations(
dry_run: bool = Query(True, description="Nur zaehlen, nicht aendern"),
batch_size: int = Query(0, description="0 = alle auf einmal"),
offset: int = Query(0, description="Offset fuer Batch-Verarbeitung"),
):
"""
Markiert doppelte obligation_candidates als 'duplicate'.
Duplikate = mehrere Eintraege mit gleichem candidate_id.
Pro candidate_id wird der aelteste Eintrag (MIN(created_at)) behalten,
alle anderen erhalten release_state='duplicate' und merged_into_id
zeigt auf den behaltenen Eintrag.
"""
with SessionLocal() as db:
# 1. Finde alle candidate_ids mit mehr als einem Eintrag
# (nur noch nicht-deduplizierte beruecksichtigen)
dup_query = """
SELECT candidate_id, count(*) as cnt
FROM obligation_candidates
WHERE release_state NOT IN ('rejected', 'merged', 'duplicate')
GROUP BY candidate_id
HAVING count(*) > 1
ORDER BY candidate_id
"""
if batch_size > 0:
dup_query += f" LIMIT {batch_size} OFFSET {offset}"
dup_groups = db.execute(text(dup_query)).fetchall()
total_groups = db.execute(text("""
SELECT count(*) FROM (
SELECT candidate_id
FROM obligation_candidates
WHERE release_state NOT IN ('rejected', 'merged', 'duplicate')
GROUP BY candidate_id
HAVING count(*) > 1
) sub
""")).scalar()
# 2. Pro Gruppe: aeltesten behalten, Rest als duplicate markieren
kept_count = 0
duplicate_count = 0
sample_changes: list[dict[str, Any]] = []
for grp in dup_groups:
cid = grp.candidate_id
# Alle Eintraege fuer dieses candidate_id holen
entries = db.execute(text("""
SELECT id, candidate_id, obligation_text, release_state, created_at
FROM obligation_candidates
WHERE candidate_id = :cid
AND release_state NOT IN ('rejected', 'merged', 'duplicate')
ORDER BY created_at ASC, id ASC
"""), {"cid": cid}).fetchall()
if len(entries) < 2:
continue
keeper = entries[0] # aeltester Eintrag
duplicates = entries[1:]
kept_count += 1
duplicate_count += len(duplicates)
if len(sample_changes) < 20:
sample_changes.append({
"candidate_id": cid,
"kept_id": str(keeper.id),
"kept_text": keeper.obligation_text[:100],
"duplicate_count": len(duplicates),
"duplicate_ids": [str(d.id) for d in duplicates],
})
if not dry_run:
for dup in duplicates:
db.execute(text("""
UPDATE obligation_candidates
SET release_state = 'duplicate',
merged_into_id = CAST(:keeper_id AS uuid),
quality_flags = COALESCE(quality_flags, '{}'::jsonb)
|| jsonb_build_object(
'dedup_reason', 'duplicate of ' || :keeper_cid,
'dedup_kept_id', :keeper_id_str,
'dedup_at', NOW()::text
)
WHERE id = CAST(:dup_id AS uuid)
"""), {
"keeper_id": str(keeper.id),
"keeper_cid": cid,
"keeper_id_str": str(keeper.id),
"dup_id": str(dup.id),
})
if not dry_run and duplicate_count > 0:
db.commit()
return {
"dry_run": dry_run,
"stats": {
"total_duplicate_groups": total_groups,
"processed_groups": len(dup_groups),
"kept": kept_count,
"marked_duplicate": duplicate_count,
},
"sample_changes": sample_changes,
}
@router.get("/obligations/dedup-stats")
async def dedup_obligations_stats():
"""Statistiken ueber den aktuellen Dedup-Status der Obligations."""
with SessionLocal() as db:
total = db.execute(text(
"SELECT count(*) FROM obligation_candidates"
)).scalar()
by_state = db.execute(text("""
SELECT release_state, count(*) as cnt
FROM obligation_candidates
GROUP BY release_state
ORDER BY release_state
""")).fetchall()
dup_groups = db.execute(text("""
SELECT count(*) FROM (
SELECT candidate_id
FROM obligation_candidates
WHERE release_state NOT IN ('rejected', 'merged', 'duplicate')
GROUP BY candidate_id
HAVING count(*) > 1
) sub
""")).scalar()
removable = db.execute(text("""
SELECT COALESCE(sum(cnt - 1), 0) FROM (
SELECT candidate_id, count(*) as cnt
FROM obligation_candidates
WHERE release_state NOT IN ('rejected', 'merged', 'duplicate')
GROUP BY candidate_id
HAVING count(*) > 1
) sub
""")).scalar()
return {
"total_obligations": total,
"by_state": {r.release_state: r.cnt for r in by_state},
"pending_duplicate_groups": dup_groups,
"pending_removable_duplicates": removable,
}
# =============================================================================
# EVIDENCE TYPE BACKFILL
# =============================================================================
@@ -1567,6 +1915,57 @@ async def list_licenses():
return get_license_matrix(db)
# =============================================================================
# V1 ENRICHMENT (Eigenentwicklung → Regulatorische Abdeckung)
# =============================================================================
@router.post("/controls/enrich-v1-matches")
async def enrich_v1_matches_endpoint(
dry_run: bool = Query(True, description="Nur zaehlen, nicht schreiben"),
batch_size: int = Query(100, description="Controls pro Durchlauf"),
offset: int = Query(0, description="Offset fuer Paginierung"),
):
"""
Findet regulatorische Abdeckung fuer v1 Eigenentwicklung Controls.
Eigenentwicklung = generation_strategy='ungrouped', pipeline_version=1,
source_citation IS NULL, parent_control_uuid IS NULL.
Workflow:
1. dry_run=true → Statistiken anzeigen
2. dry_run=false&batch_size=100&offset=0 → Erste 100 verarbeiten
3. Wiederholen mit next_offset bis fertig
"""
from compliance.services.v1_enrichment import enrich_v1_matches
return await enrich_v1_matches(
dry_run=dry_run,
batch_size=batch_size,
offset=offset,
)
@router.get("/controls/{control_id}/v1-matches")
async def get_v1_matches_endpoint(control_id: str):
"""
Gibt regulatorische Matches fuer ein v1 Control zurueck.
Returns:
Liste von Matches mit Control-Details, Source, Score.
"""
from compliance.services.v1_enrichment import get_v1_matches
# Resolve control_id to UUID
with SessionLocal() as db:
row = db.execute(text("""
SELECT id FROM canonical_controls WHERE control_id = :cid
"""), {"cid": control_id}).fetchone()
if not row:
raise HTTPException(status_code=404, detail=f"Control {control_id} not found")
return await get_v1_matches(str(row.id))
# =============================================================================
# INTERNAL HELPERS
# =============================================================================

View File

@@ -459,7 +459,9 @@ def _split_compound_action(action: str) -> list[str]:
# ── 2. Action Type Classification (18 types) ────────────────────────────
_ACTION_PRIORITY = [
"prevent", "exclude", "forbid",
"implement", "configure", "encrypt", "restrict_access",
"enforce", "invalidate", "issue", "rotate",
"monitor", "review", "assess", "audit",
"test", "verify", "validate",
"report", "notify", "train",
@@ -470,7 +472,41 @@ _ACTION_PRIORITY = [
]
_ACTION_KEYWORDS: list[tuple[str, str]] = [
# Multi-word patterns first (longest match wins)
# ── Negative / prohibitive actions (highest priority) ────
("dürfen keine", "prevent"),
("dürfen nicht", "prevent"),
("darf keine", "prevent"),
("darf nicht", "prevent"),
("nicht zulässig", "forbid"),
("nicht erlaubt", "forbid"),
("nicht gestattet", "forbid"),
("untersagt", "forbid"),
("verboten", "forbid"),
("nicht enthalten", "exclude"),
("nicht übertragen", "prevent"),
("nicht übermittelt", "prevent"),
("nicht wiederverwendet", "prevent"),
("nicht gespeichert", "prevent"),
("verhindern", "prevent"),
("unterbinden", "prevent"),
("ausschließen", "exclude"),
("vermeiden", "prevent"),
("ablehnen", "exclude"),
("zurückweisen", "exclude"),
# ── Session / lifecycle actions ──────────────────────────
("ungültig machen", "invalidate"),
("invalidieren", "invalidate"),
("widerrufen", "invalidate"),
("session beenden", "invalidate"),
("vergeben", "issue"),
("ausstellen", "issue"),
("erzeugen", "issue"),
("generieren", "issue"),
("rotieren", "rotate"),
("erneuern", "rotate"),
("durchsetzen", "enforce"),
("erzwingen", "enforce"),
# ── Multi-word patterns (longest match wins) ─────────────
("aktuell halten", "maintain"),
("aufrechterhalten", "maintain"),
("sicherstellen", "ensure"),
@@ -565,6 +601,15 @@ _ACTION_KEYWORDS: list[tuple[str, str]] = [
("remediate", "remediate"),
("perform", "perform"),
("obtain", "obtain"),
("prevent", "prevent"),
("forbid", "forbid"),
("exclude", "exclude"),
("invalidate", "invalidate"),
("revoke", "invalidate"),
("issue", "issue"),
("generate", "issue"),
("rotate", "rotate"),
("enforce", "enforce"),
]
@@ -627,11 +672,29 @@ _OBJECT_CLASS_KEYWORDS: dict[str, list[str]] = {
"access_control": [
"authentifizierung", "autorisierung", "zugriff",
"berechtigung", "passwort", "kennwort", "anmeldung",
"sso", "rbac", "session",
"sso", "rbac",
],
"session": [
"session", "sitzung", "sitzungsverwaltung", "session management",
"session-id", "session-token", "idle timeout",
"inaktivitäts-timeout", "inaktivitätszeitraum",
"logout", "abmeldung",
],
"cookie": [
"cookie", "session-cookie", "secure-flag", "httponly",
"samesite", "cookie-attribut",
],
"jwt": [
"jwt", "json web token", "bearer token",
"jwt-algorithmus", "jwt-signatur",
],
"federated_assertion": [
"assertion", "saml", "oidc", "openid",
"föderiert", "federated", "identity provider",
],
"cryptographic_control": [
"schlüssel", "zertifikat", "signatur", "kryptographi",
"cipher", "hash", "token",
"cipher", "hash", "token", "entropie",
],
"configuration": [
"konfiguration", "einstellung", "parameter",
@@ -1030,6 +1093,85 @@ _ACTION_TEMPLATES: dict[str, dict[str, list[str]]] = {
"Gültigkeitsprüfung mit Zeitstempeln",
],
},
# ── Prevent / Exclude / Forbid (negative norms) ────────────
"prevent": {
"test_procedure": [
"Prüfung, dass {object} technisch verhindert wird",
"Stichprobe: Versuch der verbotenen Aktion schlägt fehl",
"Review der Konfiguration und Zugriffskontrollen",
],
"evidence": [
"Konfigurationsnachweis der Präventionsmassnahme",
"Testprotokoll der Negativtests",
],
},
"exclude": {
"test_procedure": [
"Prüfung, dass {object} ausgeschlossen ist",
"Stichprobe: Verbotene Inhalte/Aktionen sind nicht vorhanden",
"Automatisierter Scan oder manuelle Prüfung",
],
"evidence": [
"Scan-Ergebnis oder Prüfprotokoll",
"Konfigurationsnachweis",
],
},
"forbid": {
"test_procedure": [
"Prüfung, dass {object} untersagt und technisch blockiert ist",
"Verifizierung der Richtlinie und technischen Durchsetzung",
"Stichprobe: Versuch der untersagten Aktion wird abgelehnt",
],
"evidence": [
"Richtlinie mit explizitem Verbot",
"Technischer Nachweis der Blockierung",
],
},
# ── Enforce / Invalidate / Issue / Rotate ────────────────
"enforce": {
"test_procedure": [
"Prüfung der technischen Durchsetzung von {object}",
"Stichprobe: Nicht-konforme Konfigurationen werden automatisch korrigiert oder abgelehnt",
"Review der Enforcement-Regeln und Ausnahmen",
],
"evidence": [
"Enforcement-Policy mit technischer Umsetzung",
"Protokoll erzwungener Korrekturen oder Ablehnungen",
],
},
"invalidate": {
"test_procedure": [
"Prüfung, dass {object} korrekt ungültig gemacht wird",
"Stichprobe: Nach Invalidierung kein Zugriff mehr möglich",
"Verifizierung der serverseitigen Bereinigung",
],
"evidence": [
"Protokoll der Invalidierungsaktionen",
"Testnachweis der Zugriffsverweigerung nach Invalidierung",
],
},
"issue": {
"test_procedure": [
"Prüfung des Vergabeprozesses für {object}",
"Verifizierung der kryptographischen Sicherheit und Entropie",
"Stichprobe: Korrekte Vergabe unter definierten Bedingungen",
],
"evidence": [
"Prozessdokumentation der Vergabe",
"Nachweis der Entropie-/Sicherheitseigenschaften",
],
},
"rotate": {
"test_procedure": [
"Prüfung des Rotationsprozesses für {object}",
"Verifizierung der Rotationsfrequenz und automatischen Auslöser",
"Stichprobe: Alte Artefakte nach Rotation ungültig",
],
"evidence": [
"Rotationsrichtlinie mit Frequenz",
"Rotationsprotokoll mit Zeitstempeln",
],
},
# ── Approve / Remediate ───────────────────────────────────
"approve": {
"test_procedure": [
@@ -1415,20 +1557,127 @@ _OBJECT_SYNONYMS: dict[str, str] = {
"zugriff": "access_control",
"einwilligung": "consent",
"zustimmung": "consent",
# Near-synonym expansions found via heavy-control analysis (2026-03-28)
"erkennung": "detection",
"früherkennung": "detection",
"frühzeitige erkennung": "detection",
"frühzeitigen erkennung": "detection",
"detektion": "detection",
"eskalation": "escalation",
"eskalationsprozess": "escalation",
"eskalationsverfahren": "escalation",
"benachrichtigungsprozess": "notification",
"benachrichtigungsverfahren": "notification",
"meldeprozess": "notification",
"meldeverfahren": "notification",
"meldesystem": "notification",
"benachrichtigungssystem": "notification",
"überwachung": "monitoring",
"monitoring": "monitoring",
"kontinuierliche überwachung": "monitoring",
"laufende überwachung": "monitoring",
"prüfung": "audit",
"überprüfung": "audit",
"kontrolle": "control_check",
"sicherheitskontrolle": "control_check",
"dokumentation": "documentation",
"aufzeichnungspflicht": "documentation",
"protokollierung": "logging",
"logführung": "logging",
"logmanagement": "logging",
"wiederherstellung": "recovery",
"notfallwiederherstellung": "recovery",
"disaster recovery": "recovery",
"notfallplan": "contingency_plan",
"notfallplanung": "contingency_plan",
"wiederanlaufplan": "contingency_plan",
"klassifizierung": "classification",
"kategorisierung": "classification",
"einstufung": "classification",
"segmentierung": "segmentation",
"netzwerksegmentierung": "segmentation",
"netzwerk-segmentierung": "segmentation",
"trennung": "segmentation",
"isolierung": "isolation",
"patch": "patch_mgmt",
"patchmanagement": "patch_mgmt",
"patch-management": "patch_mgmt",
"aktualisierung": "patch_mgmt",
"softwareaktualisierung": "patch_mgmt",
"härtung": "hardening",
"systemhärtung": "hardening",
"härtungsmaßnahme": "hardening",
"löschung": "deletion",
"datenlöschung": "deletion",
"löschkonzept": "deletion",
"anonymisierung": "anonymization",
"pseudonymisierung": "pseudonymization",
"zugangssteuerung": "access_control",
"zugangskontrolle": "access_control",
"zugriffssteuerung": "access_control",
"zugriffskontrolle": "access_control",
"schlüsselmanagement": "key_mgmt",
"schlüsselverwaltung": "key_mgmt",
"key management": "key_mgmt",
"zertifikatsverwaltung": "cert_mgmt",
"zertifikatsmanagement": "cert_mgmt",
"lieferant": "vendor",
"dienstleister": "vendor",
"auftragsverarbeiter": "vendor",
"drittanbieter": "vendor",
# Session management synonyms (2026-03-28)
"sitzung": "session",
"sitzungsverwaltung": "session_mgmt",
"session management": "session_mgmt",
"session-id": "session_token",
"sitzungstoken": "session_token",
"session-token": "session_token",
"idle timeout": "session_timeout",
"inaktivitäts-timeout": "session_timeout",
"inaktivitätszeitraum": "session_timeout",
"abmeldung": "logout",
"cookie-attribut": "cookie_security",
"secure-flag": "cookie_security",
"httponly": "cookie_security",
"samesite": "cookie_security",
"json web token": "jwt",
"bearer token": "jwt",
"föderierte assertion": "federated_assertion",
"saml assertion": "federated_assertion",
}
def _truncate_title(title: str, max_len: int = 80) -> str:
"""Truncate title at word boundary to avoid mid-word cuts."""
if len(title) <= max_len:
return title
truncated = title[:max_len]
# Cut at last space to avoid mid-word truncation
last_space = truncated.rfind(" ")
if last_space > max_len // 2:
return truncated[:last_space]
return truncated
def _normalize_object(object_raw: str) -> str:
"""Normalize object text to a snake_case key for merge hints.
Applies synonym mapping to collapse German terms to canonical forms
(e.g., 'Richtlinie' -> 'policy', 'Verzeichnis' -> 'register').
Then strips qualifying prepositional phrases that would create
near-duplicate keys (e.g., 'bei Schwellenwertüberschreitung').
Truncates to 40 chars to collapse overly specific variants.
"""
if not object_raw:
return "unknown"
obj_lower = object_raw.strip().lower()
# Strip qualifying prepositional phrases that don't change core identity.
# These create near-duplicate keys like "eskalationsprozess" vs
# "eskalationsprozess bei schwellenwertüberschreitung".
obj_lower = _QUALIFYING_PHRASE_RE.sub("", obj_lower).strip()
# Synonym mapping — find the longest matching synonym
best_match = ""
best_canonical = ""
@@ -1444,7 +1693,54 @@ def _normalize_object(object_raw: str) -> str:
for src, dst in [("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")]:
obj = obj.replace(src, dst)
obj = re.sub(r"[^a-z0-9_]", "", obj)
return obj[:80] or "unknown"
# Strip trailing noise tokens (articles/prepositions stuck at the end)
obj = re.sub(r"(_(?:der|die|das|des|dem|den|fuer|bei|von|zur|zum|mit|auf|in|und|oder|aus|an|ueber|nach|gegen|unter|vor|zwischen|als|durch|ohne|wie))+$", "", obj)
# Truncate at 40 chars (at underscore boundary) to collapse
# overly specific suffixes that create near-duplicate keys.
obj = _truncate_at_boundary(obj, 40)
return obj or "unknown"
# Regex to strip German qualifying prepositional phrases from object text.
# Matches patterns like "bei schwellenwertüberschreitung",
# "für kritische systeme", "gemäß artikel 32" etc.
_QUALIFYING_PHRASE_RE = re.compile(
r"\s+(?:"
r"bei\s+\w+"
r"|für\s+(?:die\s+|den\s+|das\s+|kritische\s+)?\w+"
r"|gemäß\s+\w+"
r"|nach\s+\w+"
r"|von\s+\w+"
r"|im\s+(?:falle?\s+|rahmen\s+)?\w+"
r"|mit\s+(?:den\s+|der\s+|dem\s+)?\w+"
r"|auf\s+(?:basis|grundlage)\s+\w+"
r"|zur\s+(?:einhaltung|sicherstellung|gewährleistung|vermeidung|erfüllung)\s*\w*"
r"|durch\s+(?:den\s+|die\s+|das\s+)?\w+"
r"|über\s+(?:den\s+|die\s+|das\s+)?\w+"
r"|unter\s+\w+"
r"|zwischen\s+\w+"
r"|innerhalb\s+\w+"
r"|gegenüber\s+\w+"
r"|hinsichtlich\s+\w+"
r"|bezüglich\s+\w+"
r"|einschließlich\s+\w+"
r").*$",
re.IGNORECASE,
)
def _truncate_at_boundary(text: str, max_len: int) -> str:
"""Truncate text at the last underscore boundary within max_len."""
if len(text) <= max_len:
return text
truncated = text[:max_len]
last_sep = truncated.rfind("_")
if last_sep > max_len // 2:
return truncated[:last_sep]
return truncated
# ── 7b. Framework / Composite Detection ──────────────────────────────────
@@ -1461,11 +1757,33 @@ _COMPOSITE_OBJECT_KEYWORDS: list[str] = [
"soc 2", "soc2", "enisa", "kritis",
]
# Container objects that are too broad for atomic controls.
# These produce titles like "Sichere Sitzungsverwaltung umgesetzt" which
# are not auditable — they encompass multiple sub-requirements.
_CONTAINER_OBJECT_KEYWORDS: list[str] = [
"sitzungsverwaltung", "session management", "session-management",
"token-schutz", "tokenschutz",
"authentifizierungsmechanismen", "authentifizierungsmechanismus",
"sicherheitsmaßnahmen", "sicherheitsmassnahmen",
"schutzmaßnahmen", "schutzmassnahmen",
"zugriffskontrollmechanismen",
"sicherheitsarchitektur",
"sicherheitskontrollen",
"datenschutzmaßnahmen", "datenschutzmassnahmen",
"compliance-anforderungen",
"risikomanagementprozess",
]
_COMPOSITE_RE = re.compile(
"|".join(_FRAMEWORK_KEYWORDS + _COMPOSITE_OBJECT_KEYWORDS),
re.IGNORECASE,
)
_CONTAINER_RE = re.compile(
"|".join(_CONTAINER_OBJECT_KEYWORDS),
re.IGNORECASE,
)
def _is_composite_obligation(obligation_text: str, object_: str) -> bool:
"""Detect framework-level / composite obligations that are NOT atomic.
@@ -1477,6 +1795,17 @@ def _is_composite_obligation(obligation_text: str, object_: str) -> bool:
return bool(_COMPOSITE_RE.search(combined))
def _is_container_object(object_: str) -> bool:
"""Detect overly broad container objects that should not be atomic.
Objects like 'Sitzungsverwaltung' or 'Token-Schutz' encompass multiple
sub-requirements and produce non-auditable controls.
"""
if not object_:
return False
return bool(_CONTAINER_RE.search(object_))
# ── 7c. Output Validator (Negativregeln) ─────────────────────────────────
def _validate_atomic_control(
@@ -1613,11 +1942,11 @@ def _compose_deterministic(
# ── Title: "{Object} {Zustand}" ───────────────────────────
state = _ACTION_STATE_SUFFIX.get(action_type, "umgesetzt")
if object_:
title = f"{object_.strip()} {state}"[:80]
title = _truncate_title(f"{object_.strip()} {state}")
elif action:
title = f"{action.strip().capitalize()} {state}"[:80]
title = _truncate_title(f"{action.strip().capitalize()} {state}")
else:
title = f"{parent_title} {state}"[:80]
title = _truncate_title(f"{parent_title} {state}")
# ── Objective = obligation text (the normative statement) ─
objective = obligation_text.strip()[:2000]
@@ -1678,7 +2007,7 @@ def _compose_deterministic(
requirements=requirements,
test_procedure=test_procedure,
evidence=evidence,
severity=_normalize_severity(parent_severity),
severity=_calibrate_severity(parent_severity, action_type),
category=parent_category or "governance",
)
# Attach extra metadata (stored in generation_metadata)
@@ -1690,11 +2019,17 @@ def _compose_deterministic(
atomic._deadline_hours = deadline_hours # type: ignore[attr-defined]
atomic._frequency = frequency # type: ignore[attr-defined]
# ── Composite / Framework detection ───────────────────────
# ── Composite / Framework / Container detection ────────────
is_composite = _is_composite_obligation(obligation_text, object_)
atomic._is_composite = is_composite # type: ignore[attr-defined]
atomic._atomicity = "composite" if is_composite else "atomic" # type: ignore[attr-defined]
atomic._requires_decomposition = is_composite # type: ignore[attr-defined]
is_container = _is_container_object(object_)
atomic._is_composite = is_composite or is_container # type: ignore[attr-defined]
if is_composite:
atomic._atomicity = "composite" # type: ignore[attr-defined]
elif is_container:
atomic._atomicity = "container" # type: ignore[attr-defined]
else:
atomic._atomicity = "atomic" # type: ignore[attr-defined]
atomic._requires_decomposition = is_composite or is_container # type: ignore[attr-defined]
# ── Validate (log issues, never reject) ───────────────────
validation_issues = _validate_atomic_control(atomic, action_type, object_class)
@@ -2315,6 +2650,7 @@ class DecompositionPass:
SELECT 1 FROM canonical_controls ac
WHERE ac.parent_control_uuid = oc.parent_control_uuid
AND ac.decomposition_method = 'pass0b'
AND ac.release_state NOT IN ('deprecated', 'duplicate')
AND ac.title LIKE '%' || LEFT(oc.action, 20) || '%'
)
"""
@@ -2877,10 +3213,31 @@ class DecompositionPass:
"""Insert an atomic control and create parent link.
Returns the UUID of the newly created control, or None on failure.
Checks merge_hint to prevent duplicate controls under the same parent.
"""
parent_uuid = obl["parent_uuid"]
candidate_id = obl["candidate_id"]
# ── Duplicate Guard: skip if same merge_hint already exists ──
merge_hint = getattr(atomic, "source_regulation", "") or ""
if merge_hint:
existing = self.db.execute(
text("""
SELECT id::text FROM canonical_controls
WHERE parent_control_uuid = CAST(:parent AS uuid)
AND generation_metadata->>'merge_group_hint' = :hint
AND release_state NOT IN ('rejected', 'deprecated', 'duplicate')
LIMIT 1
"""),
{"parent": parent_uuid, "hint": merge_hint},
).fetchone()
if existing:
logger.debug(
"Duplicate guard: skipping %s — merge_hint %s already exists as %s",
candidate_id, merge_hint, existing[0],
)
return existing[0]
result = self.db.execute(
text("""
INSERT INTO canonical_controls (
@@ -3135,6 +3492,7 @@ class DecompositionPass:
SELECT 1 FROM canonical_controls ac
WHERE ac.parent_control_uuid = oc.parent_control_uuid
AND ac.decomposition_method = 'pass0b'
AND ac.release_state NOT IN ('deprecated', 'duplicate')
AND ac.title LIKE '%' || LEFT(oc.action, 20) || '%'
)
"""
@@ -3475,4 +3833,45 @@ def _normalize_severity(val: str) -> str:
return "medium"
# Action-type-based severity calibration: not every atomic control
# inherits the parent's severity. Definition and review controls are
# typically medium, while implementation controls stay high.
_ACTION_SEVERITY_CAP: dict[str, str] = {
"define": "medium",
"review": "medium",
"document": "medium",
"report": "medium",
"test": "medium",
"implement": "high",
"configure": "high",
"monitor": "high",
"enforce": "high",
"prevent": "high",
"exclude": "high",
"forbid": "high",
"invalidate": "high",
"issue": "high",
"rotate": "medium",
}
# Severity ordering for cap comparison
_SEVERITY_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3}
def _calibrate_severity(parent_severity: str, action_type: str) -> str:
"""Calibrate severity based on action type.
Implementation/enforcement inherits parent severity.
Definition/review/test/documentation caps at medium.
"""
parent = _normalize_severity(parent_severity)
cap = _ACTION_SEVERITY_CAP.get(action_type)
if not cap:
return parent
# Return the lower of parent severity and action-type cap
if _SEVERITY_ORDER.get(parent, 1) <= _SEVERITY_ORDER.get(cap, 1):
return parent
return cap
# _template_fallback removed — replaced by _compose_deterministic engine

View File

@@ -0,0 +1,331 @@
"""V1 Control Enrichment Service — Match Eigenentwicklung controls to regulations.
Finds regulatory coverage for v1 controls (generation_strategy='ungrouped',
pipeline_version=1, no source_citation) by embedding similarity search.
Reuses embedding + Qdrant helpers from control_dedup.py.
"""
import logging
from typing import Optional
from sqlalchemy import text
from database import SessionLocal
from compliance.services.control_dedup import (
get_embedding,
qdrant_search_cross_regulation,
)
logger = logging.getLogger(__name__)
# Similarity threshold — lower than dedup (0.85) since we want informational matches
# Typical top scores for v1 controls are 0.70-0.77
V1_MATCH_THRESHOLD = 0.70
V1_MAX_MATCHES = 5
def _is_eigenentwicklung_query() -> str:
"""SQL WHERE clause identifying v1 Eigenentwicklung controls."""
return """
generation_strategy = 'ungrouped'
AND (pipeline_version = '1' OR pipeline_version IS NULL)
AND source_citation IS NULL
AND parent_control_uuid IS NULL
AND release_state NOT IN ('rejected', 'merged', 'deprecated')
"""
async def count_v1_controls() -> int:
"""Count how many v1 Eigenentwicklung controls exist."""
with SessionLocal() as db:
row = db.execute(text(f"""
SELECT COUNT(*) AS cnt
FROM canonical_controls
WHERE {_is_eigenentwicklung_query()}
""")).fetchone()
return row.cnt if row else 0
async def enrich_v1_matches(
dry_run: bool = True,
batch_size: int = 100,
offset: int = 0,
) -> dict:
"""Find regulatory matches for v1 Eigenentwicklung controls.
Args:
dry_run: If True, only count — don't write matches.
batch_size: Number of v1 controls to process per call.
offset: Pagination offset (v1 control index).
Returns:
Stats dict with counts, sample matches, and pagination info.
"""
with SessionLocal() as db:
# 1. Load v1 controls (paginated)
v1_controls = db.execute(text(f"""
SELECT id, control_id, title, objective, category
FROM canonical_controls
WHERE {_is_eigenentwicklung_query()}
ORDER BY control_id
LIMIT :limit OFFSET :offset
"""), {"limit": batch_size, "offset": offset}).fetchall()
# Count total for pagination
total_row = db.execute(text(f"""
SELECT COUNT(*) AS cnt
FROM canonical_controls
WHERE {_is_eigenentwicklung_query()}
""")).fetchone()
total_v1 = total_row.cnt if total_row else 0
if not v1_controls:
return {
"dry_run": dry_run,
"processed": 0,
"total_v1": total_v1,
"message": "Kein weiterer Batch — alle v1 Controls verarbeitet.",
}
if dry_run:
return {
"dry_run": True,
"total_v1": total_v1,
"offset": offset,
"batch_size": batch_size,
"sample_controls": [
{
"control_id": r.control_id,
"title": r.title,
"category": r.category,
}
for r in v1_controls[:20]
],
}
# 2. Process each v1 control
processed = 0
matches_inserted = 0
errors = []
sample_matches = []
for v1 in v1_controls:
try:
# Build search text
search_text = f"{v1.title}{v1.objective}"
# Get embedding
embedding = await get_embedding(search_text)
if not embedding:
errors.append({
"control_id": v1.control_id,
"error": "Embedding fehlgeschlagen",
})
continue
# Search Qdrant (cross-regulation, no pattern filter)
# Collection is atomic_controls_dedup (contains ~51k atomare Controls)
results = await qdrant_search_cross_regulation(
embedding, top_k=20,
collection="atomic_controls_dedup",
)
# For each hit: resolve to a regulatory parent with source_citation.
# Atomic controls in Qdrant usually have parent_control_uuid → parent
# has the source_citation. We deduplicate by parent to avoid
# listing the same regulation multiple times.
rank = 0
seen_parents: set[str] = set()
for hit in results:
score = hit.get("score", 0)
if score < V1_MATCH_THRESHOLD:
continue
payload = hit.get("payload", {})
matched_uuid = payload.get("control_uuid")
if not matched_uuid or matched_uuid == str(v1.id):
continue
# Try the matched control itself first, then its parent
matched_row = db.execute(text("""
SELECT c.id, c.control_id, c.title, c.source_citation,
c.severity, c.category, c.parent_control_uuid
FROM canonical_controls c
WHERE c.id = CAST(:uuid AS uuid)
"""), {"uuid": matched_uuid}).fetchone()
if not matched_row:
continue
# Resolve to regulatory control (one with source_citation)
reg_row = matched_row
if not reg_row.source_citation and reg_row.parent_control_uuid:
# Look up parent — the parent has the source_citation
parent_row = db.execute(text("""
SELECT id, control_id, title, source_citation,
severity, category, parent_control_uuid
FROM canonical_controls
WHERE id = CAST(:uuid AS uuid)
AND source_citation IS NOT NULL
"""), {"uuid": str(reg_row.parent_control_uuid)}).fetchone()
if parent_row:
reg_row = parent_row
if not reg_row.source_citation:
continue
# Deduplicate by parent UUID
parent_key = str(reg_row.id)
if parent_key in seen_parents:
continue
seen_parents.add(parent_key)
rank += 1
if rank > V1_MAX_MATCHES:
break
# Extract source info
source_citation = reg_row.source_citation or {}
matched_source = source_citation.get("source") if isinstance(source_citation, dict) else None
matched_article = source_citation.get("article") if isinstance(source_citation, dict) else None
# Insert match — link to the regulatory parent (not the atomic child)
db.execute(text("""
INSERT INTO v1_control_matches
(v1_control_uuid, matched_control_uuid, similarity_score,
match_rank, matched_source, matched_article, match_method)
VALUES
(CAST(:v1_uuid AS uuid), CAST(:matched_uuid AS uuid), :score,
:rank, :source, :article, 'embedding')
ON CONFLICT (v1_control_uuid, matched_control_uuid) DO UPDATE
SET similarity_score = EXCLUDED.similarity_score,
match_rank = EXCLUDED.match_rank
"""), {
"v1_uuid": str(v1.id),
"matched_uuid": str(reg_row.id),
"score": round(score, 3),
"rank": rank,
"source": matched_source,
"article": matched_article,
})
matches_inserted += 1
# Collect sample
if len(sample_matches) < 20:
sample_matches.append({
"v1_control_id": v1.control_id,
"v1_title": v1.title,
"matched_control_id": reg_row.control_id,
"matched_title": reg_row.title,
"matched_source": matched_source,
"matched_article": matched_article,
"similarity_score": round(score, 3),
"match_rank": rank,
})
processed += 1
except Exception as e:
logger.warning("V1 enrichment error for %s: %s", v1.control_id, e)
errors.append({
"control_id": v1.control_id,
"error": str(e),
})
db.commit()
# Pagination
next_offset = offset + batch_size if len(v1_controls) == batch_size else None
return {
"dry_run": False,
"offset": offset,
"batch_size": batch_size,
"next_offset": next_offset,
"total_v1": total_v1,
"processed": processed,
"matches_inserted": matches_inserted,
"errors": errors[:10],
"sample_matches": sample_matches,
}
async def get_v1_matches(control_uuid: str) -> list[dict]:
"""Get all regulatory matches for a specific v1 control.
Args:
control_uuid: The UUID of the v1 control.
Returns:
List of match dicts with control details.
"""
with SessionLocal() as db:
rows = db.execute(text("""
SELECT
m.similarity_score,
m.match_rank,
m.matched_source,
m.matched_article,
m.match_method,
c.control_id AS matched_control_id,
c.title AS matched_title,
c.objective AS matched_objective,
c.severity AS matched_severity,
c.category AS matched_category,
c.source_citation AS matched_source_citation
FROM v1_control_matches m
JOIN canonical_controls c ON c.id = m.matched_control_uuid
WHERE m.v1_control_uuid = CAST(:uuid AS uuid)
ORDER BY m.match_rank
"""), {"uuid": control_uuid}).fetchall()
return [
{
"matched_control_id": r.matched_control_id,
"matched_title": r.matched_title,
"matched_objective": r.matched_objective,
"matched_severity": r.matched_severity,
"matched_category": r.matched_category,
"matched_source": r.matched_source,
"matched_article": r.matched_article,
"matched_source_citation": r.matched_source_citation,
"similarity_score": float(r.similarity_score),
"match_rank": r.match_rank,
"match_method": r.match_method,
}
for r in rows
]
async def get_v1_enrichment_stats() -> dict:
"""Get overview stats for v1 enrichment."""
with SessionLocal() as db:
total_v1 = db.execute(text(f"""
SELECT COUNT(*) AS cnt FROM canonical_controls
WHERE {_is_eigenentwicklung_query()}
""")).fetchone()
matched_v1 = db.execute(text(f"""
SELECT COUNT(DISTINCT m.v1_control_uuid) AS cnt
FROM v1_control_matches m
JOIN canonical_controls c ON c.id = m.v1_control_uuid
WHERE {_is_eigenentwicklung_query().replace('release_state', 'c.release_state').replace('generation_strategy', 'c.generation_strategy').replace('pipeline_version', 'c.pipeline_version').replace('source_citation', 'c.source_citation').replace('parent_control_uuid', 'c.parent_control_uuid')}
""")).fetchone()
total_matches = db.execute(text("""
SELECT COUNT(*) AS cnt FROM v1_control_matches
""")).fetchone()
avg_score = db.execute(text("""
SELECT AVG(similarity_score) AS avg_score FROM v1_control_matches
""")).fetchone()
return {
"total_v1_controls": total_v1.cnt if total_v1 else 0,
"v1_with_matches": matched_v1.cnt if matched_v1 else 0,
"v1_without_matches": (total_v1.cnt if total_v1 else 0) - (matched_v1.cnt if matched_v1 else 0),
"total_matches": total_matches.cnt if total_matches else 0,
"avg_similarity_score": round(float(avg_score.avg_score), 3) if avg_score and avg_score.avg_score else None,
}

View File

@@ -0,0 +1,18 @@
-- V1 Control Enrichment: Cross-reference table for matching
-- Eigenentwicklung (v1, ungrouped, no source) → regulatorische Controls
CREATE TABLE IF NOT EXISTS v1_control_matches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
v1_control_uuid UUID NOT NULL REFERENCES canonical_controls(id) ON DELETE CASCADE,
matched_control_uuid UUID NOT NULL REFERENCES canonical_controls(id) ON DELETE CASCADE,
similarity_score NUMERIC(4,3) NOT NULL,
match_rank SMALLINT NOT NULL DEFAULT 1,
matched_source TEXT, -- e.g. "DSGVO (EU) 2016/679"
matched_article TEXT, -- e.g. "Art. 32"
match_method VARCHAR(30) NOT NULL DEFAULT 'embedding',
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT uq_v1_match UNIQUE (v1_control_uuid, matched_control_uuid)
);
CREATE INDEX IF NOT EXISTS idx_v1m_v1 ON v1_control_matches(v1_control_uuid);
CREATE INDEX IF NOT EXISTS idx_v1m_matched ON v1_control_matches(matched_control_uuid);

View File

@@ -0,0 +1,11 @@
-- Migration 081: Add 'duplicate' release_state for obligation deduplication
--
-- Allows marking duplicate obligation_candidates as 'duplicate' instead of
-- deleting them, preserving traceability via merged_into_id.
ALTER TABLE obligation_candidates
DROP CONSTRAINT IF EXISTS obligation_candidates_release_state_check;
ALTER TABLE obligation_candidates
ADD CONSTRAINT obligation_candidates_release_state_check
CHECK (release_state IN ('extracted', 'validated', 'rejected', 'composed', 'merged', 'duplicate'));

View File

@@ -0,0 +1,4 @@
-- Widen source_article and source_regulation to TEXT to handle long NIST references
-- e.g. "SC-22 (und weitere redaktionelle Änderungen SC-7, SC-14, SC-17, ...)"
ALTER TABLE control_parent_links ALTER COLUMN source_article TYPE TEXT;
ALTER TABLE control_parent_links ALTER COLUMN source_regulation TYPE TEXT;

View File

@@ -443,18 +443,105 @@ class TestControlsMeta:
db.__enter__ = MagicMock(return_value=db)
db.__exit__ = MagicMock(return_value=False)
# 4 sequential execute() calls
total_r = MagicMock(); total_r.scalar.return_value = 100
domain_r = MagicMock(); domain_r.fetchall.return_value = []
source_r = MagicMock(); source_r.fetchall.return_value = []
nosrc_r = MagicMock(); nosrc_r.scalar.return_value = 20
db.execute.side_effect = [total_r, domain_r, source_r, nosrc_r]
# Faceted meta does many execute() calls — use a default mock
scalar_r = MagicMock()
scalar_r.scalar.return_value = 100
scalar_r.fetchall.return_value = []
db.execute.return_value = scalar_r
mock_cls.return_value = db
resp = _client.get("/api/compliance/v1/canonical/controls-meta")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 100
assert data["no_source_count"] == 20
assert isinstance(data["domains"], list)
assert isinstance(data["sources"], list)
assert "type_counts" in data
assert "severity_counts" in data
assert "verification_method_counts" in data
assert "category_counts" in data
assert "evidence_type_counts" in data
assert "release_state_counts" in data
class TestObligationDedup:
"""Tests for obligation deduplication endpoints."""
@patch("compliance.api.canonical_control_routes.SessionLocal")
def test_dedup_dry_run(self, mock_cls):
db = MagicMock()
db.__enter__ = MagicMock(return_value=db)
db.__exit__ = MagicMock(return_value=False)
mock_cls.return_value = db
# Mock: 2 duplicate groups
dup_row1 = MagicMock(candidate_id="OC-AUTH-001-01", cnt=3)
dup_row2 = MagicMock(candidate_id="OC-AUTH-001-02", cnt=2)
# Entries for group 1
import uuid
uid1 = uuid.uuid4()
uid2 = uuid.uuid4()
uid3 = uuid.uuid4()
entry1 = MagicMock(id=uid1, candidate_id="OC-AUTH-001-01", obligation_text="Text A", release_state="composed", created_at=datetime(2026, 1, 1, tzinfo=timezone.utc))
entry2 = MagicMock(id=uid2, candidate_id="OC-AUTH-001-01", obligation_text="Text B", release_state="composed", created_at=datetime(2026, 1, 2, tzinfo=timezone.utc))
entry3 = MagicMock(id=uid3, candidate_id="OC-AUTH-001-01", obligation_text="Text C", release_state="composed", created_at=datetime(2026, 1, 3, tzinfo=timezone.utc))
# Entries for group 2
uid4 = uuid.uuid4()
uid5 = uuid.uuid4()
entry4 = MagicMock(id=uid4, candidate_id="OC-AUTH-001-02", obligation_text="Text D", release_state="composed", created_at=datetime(2026, 1, 1, tzinfo=timezone.utc))
entry5 = MagicMock(id=uid5, candidate_id="OC-AUTH-001-02", obligation_text="Text E", release_state="composed", created_at=datetime(2026, 1, 2, tzinfo=timezone.utc))
# Side effects: 1) dup groups, 2) total count, 3) entries grp1, 4) entries grp2
mock_result_groups = MagicMock()
mock_result_groups.fetchall.return_value = [dup_row1, dup_row2]
mock_result_total = MagicMock()
mock_result_total.scalar.return_value = 2
mock_result_entries1 = MagicMock()
mock_result_entries1.fetchall.return_value = [entry1, entry2, entry3]
mock_result_entries2 = MagicMock()
mock_result_entries2.fetchall.return_value = [entry4, entry5]
db.execute.side_effect = [mock_result_groups, mock_result_total, mock_result_entries1, mock_result_entries2]
resp = _client.post("/api/compliance/v1/canonical/obligations/dedup?dry_run=true")
assert resp.status_code == 200
data = resp.json()
assert data["dry_run"] is True
assert data["stats"]["total_duplicate_groups"] == 2
assert data["stats"]["kept"] == 2
assert data["stats"]["marked_duplicate"] == 3 # 2 from grp1 + 1 from grp2
# Dry run: no commit
db.commit.assert_not_called()
@patch("compliance.api.canonical_control_routes.SessionLocal")
def test_dedup_stats(self, mock_cls):
db = MagicMock()
db.__enter__ = MagicMock(return_value=db)
db.__exit__ = MagicMock(return_value=False)
mock_cls.return_value = db
# total, by_state, dup_groups, removable
mock_total = MagicMock()
mock_total.scalar.return_value = 76046
mock_states = MagicMock()
mock_states.fetchall.return_value = [
MagicMock(release_state="composed", cnt=41217),
MagicMock(release_state="duplicate", cnt=34829),
]
mock_dup_groups = MagicMock()
mock_dup_groups.scalar.return_value = 0
mock_removable = MagicMock()
mock_removable.scalar.return_value = 0
db.execute.side_effect = [mock_total, mock_states, mock_dup_groups, mock_removable]
resp = _client.get("/api/compliance/v1/canonical/obligations/dedup-stats")
assert resp.status_code == 200
data = resp.json()
assert data["total_obligations"] == 76046
assert data["by_state"]["composed"] == 41217
assert data["by_state"]["duplicate"] == 34829
assert data["pending_duplicate_groups"] == 0
assert data["pending_removable_duplicates"] == 0

View File

@@ -40,6 +40,8 @@ from compliance.services.decomposition_pass import (
_format_citation,
_compute_extraction_confidence,
_normalize_severity,
_calibrate_severity,
_truncate_title,
_compose_deterministic,
_classify_action,
_classify_object,
@@ -63,6 +65,9 @@ from compliance.services.decomposition_pass import (
_PATTERN_CANDIDATES_MAP,
_PATTERN_CANDIDATES_BY_ACTION,
_is_composite_obligation,
_is_container_object,
_ACTION_TEMPLATES,
_ACTION_SEVERITY_CAP,
)
@@ -704,7 +709,8 @@ class TestComposeDeterministic:
# Object placeholder should use parent_title
assert "System Security" in ac.test_procedure[0]
def test_severity_inherited(self):
def test_severity_calibrated(self):
# implement caps at high — critical is reserved for parent-level controls
ac = _compose_deterministic(
obligation_text="Kritische Pflicht",
action="implementieren",
@@ -715,7 +721,7 @@ class TestComposeDeterministic:
is_test=False,
is_reporting=False,
)
assert ac.severity == "critical"
assert ac.severity == "high"
def test_category_inherited(self):
ac = _compose_deterministic(
@@ -971,6 +977,76 @@ class TestObjectNormalization:
assert "ue" in result
assert "ä" not in result
# --- New tests for improved normalization (2026-03-28) ---
def test_qualifying_phrase_stripped(self):
"""Prepositional qualifiers like 'bei X' are stripped."""
base = _normalize_object("Eskalationsprozess")
qualified = _normalize_object(
"Eskalationsprozess bei Schwellenwertüberschreitung"
)
assert base == qualified
def test_fuer_phrase_stripped(self):
"""'für kritische Systeme' qualifier is stripped."""
base = _normalize_object("Backup-Verfahren")
qualified = _normalize_object("Backup-Verfahren für kritische Systeme")
assert base == qualified
def test_gemaess_phrase_stripped(self):
"""'gemäß Artikel 32' qualifier is stripped."""
base = _normalize_object("Verschlüsselung")
qualified = _normalize_object("Verschlüsselung gemäß Artikel 32")
assert base == qualified
def test_truncation_at_40_chars(self):
"""Objects truncated at 40 chars at word boundary."""
long_obj = "interner_eskalationsprozess_bei_schwellenwertueberschreitung_und_mehr"
result = _normalize_object(long_obj)
assert len(result) <= 40
def test_near_synonym_erkennung(self):
"""'Früherkennung' and 'frühzeitige Erkennung' collapse."""
a = _normalize_object("Früherkennung von Anomalien")
b = _normalize_object("frühzeitige Erkennung von Angriffen")
assert a == b
def test_near_synonym_eskalation(self):
"""'Eskalationsprozess' and 'Eskalationsverfahren' collapse."""
a = _normalize_object("Eskalationsprozess")
b = _normalize_object("Eskalationsverfahren")
assert a == b
def test_near_synonym_meldeprozess(self):
"""'Meldeprozess' and 'Meldeverfahren' collapse to notification."""
a = _normalize_object("Meldeprozess")
b = _normalize_object("Meldeverfahren")
assert a == b
def test_near_synonym_ueberwachung(self):
"""'Überwachung' and 'Monitoring' collapse."""
a = _normalize_object("Überwachung")
b = _normalize_object("Monitoring")
assert a == b
def test_trailing_noise_stripped(self):
"""Trailing articles/prepositions are stripped."""
result = _normalize_object("Schutz der")
assert not result.endswith("_der")
def test_vendor_synonyms(self):
"""Lieferant/Dienstleister/Auftragsverarbeiter collapse to vendor."""
a = _normalize_object("Lieferant")
b = _normalize_object("Dienstleister")
c = _normalize_object("Auftragsverarbeiter")
assert a == b == c
def test_patch_mgmt_synonyms(self):
"""Patchmanagement/Aktualisierung collapse."""
a = _normalize_object("Patchmanagement")
b = _normalize_object("Softwareaktualisierung")
assert a == b
# ---------------------------------------------------------------------------
# GAP 5: OUTPUT VALIDATOR TESTS
@@ -2431,3 +2507,444 @@ class TestPass0bWithEnrichment:
# Invalid JSON
assert _parse_citation("not json") == {}
# ---------------------------------------------------------------------------
# TRUNCATE TITLE TESTS
# ---------------------------------------------------------------------------
class TestTruncateTitle:
"""Tests for _truncate_title — word-boundary truncation."""
def test_short_title_unchanged(self):
assert _truncate_title("Rate-Limiting umgesetzt") == "Rate-Limiting umgesetzt"
def test_exactly_80_unchanged(self):
title = "A" * 80
assert _truncate_title(title) == title
def test_long_title_cuts_at_word_boundary(self):
title = "Maximale Payload-Groessen fuer API-Anfragen und API-Antworten definiert und technisch durchgesetzt"
result = _truncate_title(title)
assert len(result) <= 80
assert not result.endswith(" ")
# Should not cut mid-word
assert result[-1].isalpha() or result[-1] in ("-", ")")
def test_no_mid_word_cut(self):
# "definieren" would be cut to "defin" with naive [:80]
title = "x" * 75 + " definieren"
result = _truncate_title(title)
assert "defin" not in result or "definieren" in result
def test_custom_max_len(self):
result = _truncate_title("Rate-Limiting fuer alle Endpunkte", max_len=20)
assert len(result) <= 20
# ---------------------------------------------------------------------------
# SEVERITY CALIBRATION TESTS
# ---------------------------------------------------------------------------
class TestCalibrateSeverity:
"""Tests for _calibrate_severity — action-type-based severity."""
def test_implement_keeps_high(self):
assert _calibrate_severity("high", "implement") == "high"
def test_define_caps_to_medium(self):
assert _calibrate_severity("high", "define") == "medium"
def test_review_caps_to_medium(self):
assert _calibrate_severity("high", "review") == "medium"
def test_test_caps_to_medium(self):
assert _calibrate_severity("high", "test") == "medium"
def test_document_caps_to_medium(self):
assert _calibrate_severity("high", "document") == "medium"
def test_monitor_keeps_high(self):
assert _calibrate_severity("high", "monitor") == "high"
def test_low_parent_stays_low(self):
# Even for implement, if parent is low, stays low
assert _calibrate_severity("low", "implement") == "low"
def test_medium_parent_define_stays_medium(self):
assert _calibrate_severity("medium", "define") == "medium"
def test_unknown_action_inherits_parent(self):
assert _calibrate_severity("high", "unknown_action") == "high"
def test_critical_implement_caps_to_high(self):
# implement caps at high — critical is reserved for parent-level controls
assert _calibrate_severity("critical", "implement") == "high"
def test_critical_define_caps_to_medium(self):
assert _calibrate_severity("critical", "define") == "medium"
# ---------------------------------------------------------------------------
# COMPOSE DETERMINISTIC — SEVERITY CALIBRATION INTEGRATION
# ---------------------------------------------------------------------------
class TestComposeDeterministicSeverity:
"""Verify _compose_deterministic uses calibrated severity."""
def test_define_action_gets_medium(self):
atomic = _compose_deterministic(
obligation_text="Payload-Grenzen sind verbindlich festzulegen.",
action="definieren",
object_="Payload-Grenzen",
parent_title="API Ressourcen",
parent_severity="high",
parent_category="security",
is_test=False,
is_reporting=False,
)
assert atomic.severity == "medium"
def test_implement_action_keeps_high(self):
atomic = _compose_deterministic(
obligation_text="Rate-Limiting muss technisch umgesetzt werden.",
action="implementieren",
object_="Rate-Limiting",
parent_title="API Ressourcen",
parent_severity="high",
parent_category="security",
is_test=False,
is_reporting=False,
)
assert atomic.severity == "high"
# ---------------------------------------------------------------------------
# ERROR CLASS 1: NEGATIVE / PROHIBITIVE ACTION CLASSIFICATION
# ---------------------------------------------------------------------------
class TestNegativeActions:
"""Tests for prohibitive action keywords → prevent/exclude/forbid."""
def test_duerfen_keine_maps_to_prevent(self):
assert _classify_action("dürfen keine") == "prevent"
def test_duerfen_nicht_maps_to_prevent(self):
assert _classify_action("dürfen nicht") == "prevent"
def test_darf_keine_maps_to_prevent(self):
assert _classify_action("darf keine") == "prevent"
def test_verboten_maps_to_forbid(self):
assert _classify_action("verboten") == "forbid"
def test_untersagt_maps_to_forbid(self):
assert _classify_action("untersagt") == "forbid"
def test_nicht_zulaessig_maps_to_forbid(self):
assert _classify_action("nicht zulässig") == "forbid"
def test_nicht_erlaubt_maps_to_forbid(self):
assert _classify_action("nicht erlaubt") == "forbid"
def test_nicht_enthalten_maps_to_exclude(self):
assert _classify_action("nicht enthalten") == "exclude"
def test_ausschliessen_maps_to_exclude(self):
assert _classify_action("ausschließen") == "exclude"
def test_verhindern_maps_to_prevent(self):
assert _classify_action("verhindern") == "prevent"
def test_unterbinden_maps_to_prevent(self):
assert _classify_action("unterbinden") == "prevent"
def test_ablehnen_maps_to_exclude(self):
assert _classify_action("ablehnen") == "exclude"
def test_nicht_uebertragen_maps_to_prevent(self):
assert _classify_action("nicht übertragen") == "prevent"
def test_nicht_gespeichert_maps_to_prevent(self):
assert _classify_action("nicht gespeichert") == "prevent"
def test_negative_action_has_higher_priority_than_implement(self):
"""Negative keywords at start of ACTION_PRIORITY → picked over lower ones."""
result = _classify_action("verhindern und dokumentieren")
assert result == "prevent"
def test_prevent_template_exists(self):
assert "prevent" in _ACTION_TEMPLATES
assert "test_procedure" in _ACTION_TEMPLATES["prevent"]
assert "evidence" in _ACTION_TEMPLATES["prevent"]
def test_exclude_template_exists(self):
assert "exclude" in _ACTION_TEMPLATES
assert "test_procedure" in _ACTION_TEMPLATES["exclude"]
def test_forbid_template_exists(self):
assert "forbid" in _ACTION_TEMPLATES
assert "test_procedure" in _ACTION_TEMPLATES["forbid"]
# ---------------------------------------------------------------------------
# ERROR CLASS 1b: SESSION / LIFECYCLE ACTIONS
# ---------------------------------------------------------------------------
class TestSessionActions:
"""Tests for session lifecycle action keywords."""
def test_ungueltig_machen_maps_to_invalidate(self):
assert _classify_action("ungültig machen") == "invalidate"
def test_invalidieren_maps_to_invalidate(self):
assert _classify_action("invalidieren") == "invalidate"
def test_widerrufen_maps_to_invalidate(self):
assert _classify_action("widerrufen") == "invalidate"
def test_session_beenden_maps_to_invalidate(self):
assert _classify_action("session beenden") == "invalidate"
def test_vergeben_maps_to_issue(self):
assert _classify_action("vergeben") == "issue"
def test_erzeugen_maps_to_issue(self):
assert _classify_action("erzeugen") == "issue"
def test_rotieren_maps_to_rotate(self):
assert _classify_action("rotieren") == "rotate"
def test_erneuern_maps_to_rotate(self):
assert _classify_action("erneuern") == "rotate"
def test_durchsetzen_maps_to_enforce(self):
assert _classify_action("durchsetzen") == "enforce"
def test_erzwingen_maps_to_enforce(self):
assert _classify_action("erzwingen") == "enforce"
def test_invalidate_template_exists(self):
assert "invalidate" in _ACTION_TEMPLATES
assert "test_procedure" in _ACTION_TEMPLATES["invalidate"]
def test_issue_template_exists(self):
assert "issue" in _ACTION_TEMPLATES
def test_rotate_template_exists(self):
assert "rotate" in _ACTION_TEMPLATES
def test_enforce_template_exists(self):
assert "enforce" in _ACTION_TEMPLATES
# ---------------------------------------------------------------------------
# ERROR CLASS 2: CONTAINER OBJECT DETECTION
# ---------------------------------------------------------------------------
class TestContainerObjectDetection:
"""Tests for _is_container_object — broad objects that need decomposition."""
def test_sitzungsverwaltung_is_container(self):
assert _is_container_object("Sitzungsverwaltung") is True
def test_session_management_is_container(self):
assert _is_container_object("Session Management") is True
def test_token_schutz_is_container(self):
assert _is_container_object("Token-Schutz") is True
def test_authentifizierungsmechanismen_is_container(self):
assert _is_container_object("Authentifizierungsmechanismen") is True
def test_sicherheitsmassnahmen_is_container(self):
assert _is_container_object("Sicherheitsmaßnahmen") is True
def test_zugriffskontrollmechanismen_is_container(self):
assert _is_container_object("Zugriffskontrollmechanismen") is True
def test_sicherheitsarchitektur_is_container(self):
assert _is_container_object("Sicherheitsarchitektur") is True
def test_compliance_anforderungen_is_container(self):
assert _is_container_object("Compliance-Anforderungen") is True
def test_session_id_is_not_container(self):
"""Specific objects like Session-ID are NOT containers."""
assert _is_container_object("Session-ID") is False
def test_firewall_is_not_container(self):
assert _is_container_object("Firewall") is False
def test_mfa_is_not_container(self):
assert _is_container_object("MFA") is False
def test_verschluesselung_is_not_container(self):
assert _is_container_object("Verschlüsselung") is False
def test_cookie_is_not_container(self):
assert _is_container_object("Session-Cookie") is False
def test_empty_string_is_not_container(self):
assert _is_container_object("") is False
def test_none_is_not_container(self):
assert _is_container_object(None) is False
def test_container_in_compose_sets_atomicity(self):
"""Container objects set _atomicity='container' and _requires_decomposition."""
ac = _compose_deterministic(
obligation_text="Sitzungsverwaltung muss abgesichert werden",
action="implementieren",
object_="Sitzungsverwaltung",
parent_title="Session Security",
parent_severity="high",
parent_category="security",
is_test=False,
is_reporting=False,
)
assert ac._atomicity == "container"
assert ac._requires_decomposition is True
def test_specific_object_is_atomic(self):
"""Specific objects like Session-ID stay atomic."""
ac = _compose_deterministic(
obligation_text="Session-ID muss nach Logout gelöscht werden",
action="implementieren",
object_="Session-ID",
parent_title="Session Security",
parent_severity="high",
parent_category="security",
is_test=False,
is_reporting=False,
)
assert ac._atomicity == "atomic"
assert ac._requires_decomposition is False
# ---------------------------------------------------------------------------
# ERROR CLASS 3: SESSION-SPECIFIC OBJECT CLASSES
# ---------------------------------------------------------------------------
class TestSessionObjectClasses:
"""Tests for session/cookie/jwt/federated_assertion object classification."""
def test_session_class(self):
assert _classify_object("Session") == "session"
def test_sitzung_class(self):
assert _classify_object("Sitzung") == "session"
def test_session_id_class(self):
assert _classify_object("Session-ID") == "session"
def test_session_token_class(self):
assert _classify_object("Session-Token") == "session"
def test_idle_timeout_class(self):
assert _classify_object("Idle Timeout") == "session"
def test_logout_matches_record_via_log(self):
"""'Logout' matches 'log' in record class (checked before session)."""
# Ordering: record class checked before session — "log" substring matches
assert _classify_object("Logout") == "record"
def test_abmeldung_matches_report_via_meldung(self):
"""'Abmeldung' matches 'meldung' in report class (checked before session)."""
assert _classify_object("Abmeldung") == "report"
def test_cookie_class(self):
assert _classify_object("Cookie") == "cookie"
def test_session_cookie_matches_session_first(self):
"""'Session-Cookie' matches 'session' in session class (checked before cookie)."""
assert _classify_object("Session-Cookie") == "session"
def test_secure_flag_class(self):
assert _classify_object("Secure-Flag") == "cookie"
def test_httponly_class(self):
assert _classify_object("HttpOnly") == "cookie"
def test_samesite_class(self):
assert _classify_object("SameSite") == "cookie"
def test_jwt_class(self):
assert _classify_object("JWT") == "jwt"
def test_json_web_token_class(self):
assert _classify_object("JSON Web Token") == "jwt"
def test_bearer_token_class(self):
assert _classify_object("Bearer Token") == "jwt"
def test_saml_assertion_class(self):
assert _classify_object("SAML Assertion") == "federated_assertion"
def test_oidc_class(self):
assert _classify_object("OIDC Provider") == "federated_assertion"
def test_openid_class(self):
assert _classify_object("OpenID Connect") == "federated_assertion"
# ---------------------------------------------------------------------------
# ERROR CLASS 4: SEVERITY CAPS FOR NEW ACTION TYPES
# ---------------------------------------------------------------------------
class TestNewActionSeverityCaps:
"""Tests for _ACTION_SEVERITY_CAP on new action types."""
def test_prevent_capped_at_high(self):
assert _ACTION_SEVERITY_CAP.get("prevent") == "high"
def test_exclude_capped_at_high(self):
assert _ACTION_SEVERITY_CAP.get("exclude") == "high"
def test_forbid_capped_at_high(self):
assert _ACTION_SEVERITY_CAP.get("forbid") == "high"
def test_invalidate_capped_at_high(self):
assert _ACTION_SEVERITY_CAP.get("invalidate") == "high"
def test_issue_capped_at_high(self):
assert _ACTION_SEVERITY_CAP.get("issue") == "high"
def test_rotate_capped_at_medium(self):
assert _ACTION_SEVERITY_CAP.get("rotate") == "medium"
def test_enforce_capped_at_high(self):
assert _ACTION_SEVERITY_CAP.get("enforce") == "high"
def test_prevent_action_severity_in_compose(self):
"""prevent + critical parent → capped to high."""
ac = _compose_deterministic(
obligation_text="Session-Tokens dürfen nicht im Klartext gespeichert werden",
action="verhindern",
object_="Klartextspeicherung",
parent_title="Token Security",
parent_severity="critical",
parent_category="security",
is_test=False,
is_reporting=False,
)
assert ac.severity == "high"
def test_rotate_action_severity_in_compose(self):
"""rotate + high parent → capped to medium."""
ac = _compose_deterministic(
obligation_text="Session-Tokens müssen regelmäßig rotiert werden",
action="rotieren",
object_="Session-Token",
parent_title="Token Lifecycle",
parent_severity="high",
parent_category="security",
is_test=False,
is_reporting=False,
)
assert ac.severity == "medium"

View File

@@ -0,0 +1,234 @@
"""Tests for V1 Control Enrichment (Eigenentwicklung matching)."""
import sys
sys.path.insert(0, ".")
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from compliance.services.v1_enrichment import (
enrich_v1_matches,
get_v1_matches,
count_v1_controls,
)
class TestV1EnrichmentDryRun:
"""Dry-run mode should return statistics without touching DB."""
@pytest.mark.asyncio
async def test_dry_run_returns_stats(self):
mock_v1 = [
MagicMock(
id="uuid-v1-1",
control_id="ACC-013",
title="Zugriffskontrolle",
objective="Zugriff einschraenken",
category="access",
),
MagicMock(
id="uuid-v1-2",
control_id="SEC-005",
title="Verschluesselung",
objective="Daten verschluesseln",
category="encryption",
),
]
mock_count = MagicMock(cnt=863)
with patch("compliance.services.v1_enrichment.SessionLocal") as mock_session:
db = MagicMock()
mock_session.return_value.__enter__ = MagicMock(return_value=db)
mock_session.return_value.__exit__ = MagicMock(return_value=False)
# First call: v1 controls, second call: count
db.execute.return_value.fetchall.return_value = mock_v1
db.execute.return_value.fetchone.return_value = mock_count
result = await enrich_v1_matches(dry_run=True, batch_size=100, offset=0)
assert result["dry_run"] is True
assert result["total_v1"] == 863
assert len(result["sample_controls"]) == 2
assert result["sample_controls"][0]["control_id"] == "ACC-013"
class TestV1EnrichmentExecution:
"""Execution mode should find matches and insert them."""
@pytest.mark.asyncio
async def test_processes_and_inserts_matches(self):
mock_v1 = [
MagicMock(
id="uuid-v1-1",
control_id="ACC-013",
title="Zugriffskontrolle",
objective="Zugriff auf Systeme einschraenken",
category="access",
),
]
mock_count = MagicMock(cnt=1)
# Atomic control found in Qdrant (has parent, no source_citation)
mock_atomic_row = MagicMock(
id="uuid-atomic-1",
control_id="SEC-042-A01",
title="Verschluesselung (atomar)",
source_citation=None, # Atomic controls don't have source_citation
parent_control_uuid="uuid-reg-1",
severity="high",
category="encryption",
)
# Parent control (has source_citation)
mock_parent_row = MagicMock(
id="uuid-reg-1",
control_id="SEC-042",
title="Verschluesselung personenbezogener Daten",
source_citation={"source": "DSGVO (EU) 2016/679", "article": "Art. 32"},
parent_control_uuid=None,
severity="high",
category="encryption",
)
mock_qdrant_results = [
{
"score": 0.89,
"payload": {
"control_uuid": "uuid-atomic-1",
"control_id": "SEC-042-A01",
"title": "Verschluesselung (atomar)",
},
},
{
"score": 0.65, # Below threshold
"payload": {
"control_uuid": "uuid-reg-2",
"control_id": "SEC-100",
},
},
]
with patch("compliance.services.v1_enrichment.SessionLocal") as mock_session:
db = MagicMock()
mock_session.return_value.__enter__ = MagicMock(return_value=db)
mock_session.return_value.__exit__ = MagicMock(return_value=False)
# Route queries to correct mock data
def side_effect_execute(query, params=None):
result = MagicMock()
query_str = str(query)
result.fetchall.return_value = mock_v1
if "COUNT" in query_str:
result.fetchone.return_value = mock_count
elif "source_citation IS NOT NULL" in query_str:
# Parent lookup
result.fetchone.return_value = mock_parent_row
elif "c.id = CAST" in query_str or "canonical_controls c" in query_str:
# Direct atomic control lookup
result.fetchone.return_value = mock_atomic_row
else:
result.fetchone.return_value = mock_count
return result
db.execute.side_effect = side_effect_execute
with patch("compliance.services.v1_enrichment.get_embedding") as mock_embed, \
patch("compliance.services.v1_enrichment.qdrant_search_cross_regulation") as mock_qdrant:
mock_embed.return_value = [0.1] * 1024
mock_qdrant.return_value = mock_qdrant_results
result = await enrich_v1_matches(dry_run=False, batch_size=100, offset=0)
assert result["dry_run"] is False
assert result["processed"] == 1
assert result["matches_inserted"] == 1
assert len(result["sample_matches"]) == 1
assert result["sample_matches"][0]["matched_control_id"] == "SEC-042"
assert result["sample_matches"][0]["similarity_score"] == 0.89
@pytest.mark.asyncio
async def test_empty_batch_returns_done(self):
mock_count = MagicMock(cnt=863)
with patch("compliance.services.v1_enrichment.SessionLocal") as mock_session:
db = MagicMock()
mock_session.return_value.__enter__ = MagicMock(return_value=db)
mock_session.return_value.__exit__ = MagicMock(return_value=False)
db.execute.return_value.fetchall.return_value = []
db.execute.return_value.fetchone.return_value = mock_count
result = await enrich_v1_matches(dry_run=False, batch_size=100, offset=9999)
assert result["processed"] == 0
assert "alle v1 Controls verarbeitet" in result["message"]
class TestV1MatchesEndpoint:
"""Test the matches retrieval."""
@pytest.mark.asyncio
async def test_returns_matches(self):
mock_rows = [
MagicMock(
matched_control_id="SEC-042",
matched_title="Verschluesselung",
matched_objective="Daten verschluesseln",
matched_severity="high",
matched_category="encryption",
matched_source="DSGVO (EU) 2016/679",
matched_article="Art. 32",
matched_source_citation={"source": "DSGVO (EU) 2016/679"},
similarity_score=0.89,
match_rank=1,
match_method="embedding",
),
]
with patch("compliance.services.v1_enrichment.SessionLocal") as mock_session:
db = MagicMock()
mock_session.return_value.__enter__ = MagicMock(return_value=db)
mock_session.return_value.__exit__ = MagicMock(return_value=False)
db.execute.return_value.fetchall.return_value = mock_rows
result = await get_v1_matches("uuid-v1-1")
assert len(result) == 1
assert result[0]["matched_control_id"] == "SEC-042"
assert result[0]["similarity_score"] == 0.89
assert result[0]["matched_source"] == "DSGVO (EU) 2016/679"
@pytest.mark.asyncio
async def test_empty_matches(self):
with patch("compliance.services.v1_enrichment.SessionLocal") as mock_session:
db = MagicMock()
mock_session.return_value.__enter__ = MagicMock(return_value=db)
mock_session.return_value.__exit__ = MagicMock(return_value=False)
db.execute.return_value.fetchall.return_value = []
result = await get_v1_matches("uuid-nonexistent")
assert result == []
class TestEigenentwicklungDetection:
"""Verify the Eigenentwicklung detection query."""
@pytest.mark.asyncio
async def test_count_v1_controls(self):
mock_count = MagicMock(cnt=863)
with patch("compliance.services.v1_enrichment.SessionLocal") as mock_session:
db = MagicMock()
mock_session.return_value.__enter__ = MagicMock(return_value=db)
mock_session.return_value.__exit__ = MagicMock(return_value=False)
db.execute.return_value.fetchone.return_value = mock_count
result = await count_v1_controls()
assert result == 863
# Verify the query includes all conditions
call_args = db.execute.call_args[0][0]
query_str = str(call_args)
assert "generation_strategy = 'ungrouped'" in query_str
assert "source_citation IS NULL" in query_str
assert "parent_control_uuid IS NULL" in query_str

View File

@@ -2,6 +2,8 @@
import hashlib
import logging
import os
import re
import subprocess
import tempfile
import uuid
@@ -141,9 +143,8 @@ EDGE_TTS_VOICES = {
"en": "en-US-GuyNeural",
}
async def _edge_tts_synthesize(text: str, language: str, output_path: str) -> bool:
"""Synthesize using Edge TTS (Microsoft Neural Voices). Returns True on success."""
"""Synthesize using Edge TTS."""
try:
import edge_tts
voice = EDGE_TTS_VOICES.get(language, EDGE_TTS_VOICES["de"])
@@ -398,3 +399,4 @@ def _check_ffmpeg() -> bool:
return True
except Exception:
return False

View File

@@ -1,16 +1,16 @@
# =========================================================
# BreakPilot Compliance — Coolify Production Override
# BreakPilot Compliance — Orca Production Override
# =========================================================
# Verwendung: docker compose -f docker-compose.yml -f docker-compose.hetzner.yml up -d
#
# Aenderungen gegenueber docker-compose.yml:
# - Platform: arm64 → amd64 (Coolify = x86_64)
# - Network: external → auto-create (kein breakpilot-core auf Coolify)
# - depends_on: core-health-check entfernt (kein Core auf Coolify)
# - API URLs: auf Coolify-interne Adressen angepasst
# - Platform: arm64 → amd64 (Orca = x86_64)
# - Network: external → auto-create (kein breakpilot-core auf Orca)
# - depends_on: core-health-check entfernt (kein Core auf Orca)
# - API URLs: auf Orca-interne Adressen angepasst
# =========================================================
# Auf Coolify laeuft kein breakpilot-core, daher Network selbst erstellen
# Auf Orca laeuft kein breakpilot-core, daher Network selbst erstellen
networks:
breakpilot-network:
external: false
@@ -18,9 +18,9 @@ networks:
services:
# Core-Health-Check deaktivieren (Core laeuft nicht auf Coolify)
# Core-Health-Check deaktivieren (Core laeuft nicht auf Orca)
core-health-check:
entrypoint: ["sh", "-c", "echo 'Core health check skipped on Coolify' && exit 0"]
entrypoint: ["sh", "-c", "echo 'Core health check skipped on Orca' && exit 0"]
restart: "no"
admin-compliance:

View File

@@ -6,9 +6,9 @@ Uebersicht ueber den Deployment-Prozess fuer BreakPilot Compliance.
| Komponente | Build-Tool | Deployment |
|------------|------------|------------|
| Frontend (Next.js) | Docker | Coolify (automatisch) |
| Backend (FastAPI) | Docker | Coolify (automatisch) |
| Go Services | Docker (Multi-stage) | Coolify (automatisch) |
| Frontend (Next.js) | Docker | Orca (automatisch) |
| Backend (FastAPI) | Docker | Orca (automatisch) |
| Go Services | Docker (Multi-stage) | Orca (automatisch) |
| Documentation | MkDocs | Docker (Nginx, lokal) |
## Deployment-Architektur
@@ -40,14 +40,14 @@ Uebersicht ueber den Deployment-Prozess fuer BreakPilot Compliance.
│ ├── test-python-dsms-gateway │
│ └── validate-canonical-controls │
│ │
Coolify Webhook → Build + Deploy (automatisch) │
Orca Webhook → Build + Deploy (automatisch) │
└─────────────────────────────────────────────────────────────────┘
│ auto-deploy
┌─────────────────────────────────────────────────────────────────┐
│ Production (Coolify) │
│ Production (Orca) │
│ │
│ ├── admin-dev.breakpilot.ai (Admin Compliance) │
│ ├── api-dev.breakpilot.ai (Backend API) │
@@ -75,11 +75,11 @@ Push auf gitea triggert automatisch die CI-Pipeline:
- **Validierung:** Canonical Controls JSON-Validierung
- **Lint:** Go, Python, Node.js (nur bei PRs)
### 3. Automatisches Deployment (Coolify)
### 3. Automatisches Deployment (Orca)
Nach erfolgreichem Push baut Coolify automatisch alle Services und deployt sie.
Nach erfolgreichem Push baut Orca automatisch alle Services und deployt sie.
**WICHTIG:** Niemals manuell in Coolify auf "Redeploy" klicken!
**WICHTIG:** Niemals manuell in Orca auf "Redeploy" klicken!
### 4. Health Checks
@@ -113,7 +113,7 @@ jobs:
## Lokale Entwicklung (Mac Mini)
Fuer lokale Tests ohne Coolify:
Fuer lokale Tests ohne Orca:
```bash
# Auf Mac Mini pullen und bauen

View File

@@ -207,7 +207,7 @@ Runtime-Betrieb: Qdrant-RAG für semantische Suche, Chat, Scope-Analyse
2. Mac Mini: Control-Generierung → PostgreSQL (shared, 46.225.100.82:54321)
3. QA: PDF-Match, Dedup, Source-Normalisierung
4. Qdrant Migration: macmini:6333 → qdrant-dev.breakpilot.ai (scripts/migrate-qdrant.py)
5. Deploy: git push gitea → Coolify Build + Deploy
5. Deploy: git push gitea → Orca Build + Deploy
```
**WICHTIG:** PostgreSQL ist SHARED — Änderungen auf Mac Mini sind sofort in Production sichtbar. Qdrant hat getrennte Instanzen (lokal + production) und muss manuell synchronisiert werden.

View File

@@ -68,7 +68,7 @@ Module die Compliance-Kunden im SDK sehen und nutzen:
## URLs
### Production (Coolify-deployed)
### Production (Orca-deployed)
| URL | Service | Beschreibung |
|-----|---------|--------------|
@@ -91,9 +91,9 @@ Module die Compliance-Kunden im SDK sehen und nutzen:
## Deployment
```bash
# Production (Coolify — Standardweg):
# Production (Orca — Standardweg):
git push origin main && git push gitea main
# Coolify baut und deployt automatisch.
# Orca baut und deployt automatisch.
# Lokal (Mac Mini — nur Dev/Tests):
docker compose -f breakpilot-compliance/docker-compose.yml up -d

View File

@@ -152,6 +152,8 @@ erDiagram
| `POST` | `/v1/canonical/generate/backfill-domain` | Domain/Category/Target-Audience nachpflegen (Anthropic) |
| `GET` | `/v1/canonical/blocked-sources` | Gesperrte Quellen (Rule 3) |
| `POST` | `/v1/canonical/blocked-sources/cleanup` | Cleanup-Workflow starten |
| `POST` | `/v1/canonical/obligations/dedup` | Obligation-Duplikate markieren (dry_run, batch_size, offset) |
| `GET` | `/v1/canonical/obligations/dedup-stats` | Dedup-Statistik (total, by_state, pending) |
### Beispiel: Control abrufen
@@ -984,6 +986,37 @@ vom Parent-Obligation uebernommen.
**Datei:** `compliance/services/decomposition_pass.py`
**Test-Script:** `scripts/qa/test_pass0a.py` (standalone, speichert JSON)
#### Obligation Deduplizierung
Die Decomposition-Pipeline erzeugt pro Rich Control mehrere Obligation Candidates.
Durch Wiederholungen in der Pipeline koennen identische `candidate_id`-Eintraege
mehrfach existieren (z.B. 5x `OC-AUTH-839-01` mit leicht unterschiedlichem Text).
**Dedup-Strategie:** Pro `candidate_id` wird der aelteste Eintrag (`MIN(created_at)`)
behalten. Alle anderen erhalten:
- `release_state = 'duplicate'`
- `merged_into_id` → UUID des behaltenen Eintrags
- `quality_flags.dedup_reason` → z.B. `"duplicate of OC-AUTH-839-01"`
**Endpunkte:**
```bash
# Dry Run — zaehlt betroffene Duplikat-Gruppen
curl -X POST "https://macmini:8002/api/compliance/v1/canonical/obligations/dedup?dry_run=true"
# Ausfuehren — markiert alle Duplikate
curl -X POST "https://macmini:8002/api/compliance/v1/canonical/obligations/dedup?dry_run=false"
# Statistiken
curl "https://macmini:8002/api/compliance/v1/canonical/obligations/dedup-stats"
```
**Stand (2026-03-26):** 76.046 Obligations gesamt, davon 34.617 als `duplicate` markiert.
41.043 aktive Obligations verbleiben (composed + validated).
**Migration:** `081_obligation_dedup_state.sql` — Fuegt `'duplicate'` zum `release_state` Constraint hinzu.
---
### Migration Passes (1-5)
@@ -1033,6 +1066,9 @@ Die Crosswalk-Matrix bildet diese N:M-Beziehung ab.
|---------|-------------|
| `obligation_candidates` | Extrahierte atomare Pflichten aus Rich Controls |
| `obligation_candidates.obligation_type` | `pflicht` / `empfehlung` / `kann` (3-Tier-Klassifizierung) |
| `obligation_candidates.release_state` | `extracted` / `validated` / `rejected` / `composed` / `merged` / `duplicate` |
| `obligation_candidates.merged_into_id` | UUID des behaltenen Eintrags (bei `duplicate`/`merged`) |
| `obligation_candidates.quality_flags` | JSONB mit Metadaten (u.a. `dedup_reason`, `dedup_kept_id`) |
| `canonical_controls.parent_control_uuid` | Self-Referenz zum Rich Control (neues Feld) |
| `canonical_controls.decomposition_method` | Zerlegungsmethode (neues Feld) |
| `canonical_controls.obligation_type` | Uebernommen von Obligation: pflicht/empfehlung/kann |