17 Commits

Author SHA1 Message Date
Sharang Parnerkar
482e8574ad refactor(backend/db): split repository.py + isms_repository.py per-aggregate
Phase 1 Step 5 of PHASE1_RUNBOOK.md.

compliance/db/repository.py (1547 LOC) decomposed into seven sibling
per-aggregate repository modules:

  regulation_repository.py     (268) — Regulation + Requirement
  control_repository.py        (291) — Control + ControlMapping
  evidence_repository.py       (143)
  risk_repository.py           (148)
  audit_export_repository.py   (110)
  service_module_repository.py (247)
  audit_session_repository.py  (478) — AuditSession + AuditSignOff

compliance/db/isms_repository.py (838 LOC) decomposed into two
sub-aggregate modules mirroring the models split:

  isms_governance_repository.py (354) — Scope, Policy, Objective, SoA
  isms_audit_repository.py      (499) — Finding, CAPA, Review, Internal Audit,
                                         Trail, Readiness

Both original files become thin re-export shims (37 and 25 LOC
respectively) so every existing import continues to work unchanged.
New code SHOULD import from the aggregate module directly.

All new sibling files under the 500-line hard cap; largest is
isms_audit_repository.py at 499 (on the edge; when Phase 1 Step 4
router->service extraction lands, the audit_session repo may split
further if growth exceeds 500).

Verified:
  - 173/173 pytest compliance/tests/ tests/contracts/ pass
  - OpenAPI 360 paths / 484 operations unchanged
  - All repo files under 500 LOC

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:08:39 +02:00
Sharang Parnerkar
d9dcfb97ef refactor(backend/api): split schemas.py into per-domain modules (1899 -> 39 LOC shim)
Phase 1 Step 3 of PHASE1_RUNBOOK.md. compliance/api/schemas.py is
decomposed into 16 per-domain Pydantic schema modules under
compliance/schemas/:

  common.py          ( 79) — 6 API enums + PaginationMeta
  regulation.py      ( 52)
  requirement.py     ( 80)
  control.py         (119) — Control + Mapping
  evidence.py        ( 66)
  risk.py            ( 79)
  ai_system.py       ( 63)
  dashboard.py       (195) — Dashboard, Export, Executive Dashboard
  service_module.py  (121)
  bsi.py             ( 58) — BSI + PDF extraction
  audit_session.py   (172)
  report.py          ( 53)
  isms_governance.py (343) — Scope, Context, Policy, Objective, SoA
  isms_audit.py      (431) — Finding, CAPA, Review, Internal Audit, Readiness, Trail, ISO27001
  vvt.py             (168)
  tom.py             ( 71)

compliance/api/schemas.py becomes a 39-line re-export shim so existing
imports (from compliance.api.schemas import RegulationResponse) keep
working unchanged. New code should import from the domain module
directly (from compliance.schemas.regulation import RegulationResponse).

Deferred-from-sweep: all 28 class Config blocks in the original file
were converted to model_config = ConfigDict(...) during the split.
schemas.py-sourced PydanticDeprecatedSince20 warnings are now gone.

Cross-domain references handled via targeted imports (e.g. dashboard.py
imports EvidenceResponse from evidence, RiskResponse from risk). common
API enums + PaginationMeta are imported by every domain module.

Verified:
  - 173/173 pytest compliance/tests/ tests/contracts/ pass
  - OpenAPI 360 paths / 484 operations unchanged (contract test green)
  - All new files under the 500-line hard cap (largest: isms_audit.py
    at 431, isms_governance.py at 343, dashboard.py at 195)
  - No file in compliance/schemas/ or compliance/api/schemas.py
    exceeds the hard cap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:06:27 +02:00
Sharang Parnerkar
3320ef94fc refactor: phase 0 guardrails + phase 1 step 2 (models.py split)
Squash of branch refactor/phase0-guardrails-and-models-split — 4 commits,
81 files, 173/173 pytest green, OpenAPI contract preserved (360 paths /
484 operations).

## Phase 0 — Architecture guardrails

Three defense-in-depth layers to keep the architecture rules enforced
regardless of who opens Claude Code in this repo:

  1. .claude/settings.json PreToolUse hook on Write/Edit blocks any file
     that would exceed the 500-line hard cap. Auto-loads in every Claude
     session in this repo.
  2. scripts/githooks/pre-commit (install via scripts/install-hooks.sh)
     enforces the LOC cap locally, freezes migrations/ without
     [migration-approved], and protects guardrail files without
     [guardrail-change].
  3. .gitea/workflows/ci.yaml gains loc-budget + guardrail-integrity +
     sbom-scan (syft+grype) jobs, adds mypy --strict for the new Python
     packages (compliance/{services,repositories,domain,schemas}), and
     tsc --noEmit for admin-compliance + developer-portal.

Per-language conventions documented in AGENTS.python.md, AGENTS.go.md,
AGENTS.typescript.md at the repo root — layering, tooling, and explicit
"what you may NOT do" lists. Root CLAUDE.md is prepended with the six
non-negotiable rules. Each of the 10 services gets a README.md.

scripts/check-loc.sh enforces soft 300 / hard 500 and surfaces the
current baseline of 205 hard + 161 soft violations so Phases 1-4 can
drain it incrementally. CI gates only CHANGED files in PRs so the
legacy baseline does not block unrelated work.

## Deprecation sweep

47 files. Pydantic V1 regex= -> pattern= (2 sites), class Config ->
ConfigDict in source_policy_router.py (schemas.py intentionally skipped;
it is the Phase 1 Step 3 split target). datetime.utcnow() ->
datetime.now(timezone.utc) everywhere including SQLAlchemy default=
callables. All DB columns already declare timezone=True, so this is a
latent-bug fix at the Python side, not a schema change.

DeprecationWarning count dropped from 158 to 35.

## Phase 1 Step 1 — Contract test harness

tests/contracts/test_openapi_baseline.py diffs the live FastAPI /openapi.json
against tests/contracts/openapi.baseline.json on every test run. Fails on
removed paths, removed status codes, or new required request body fields.
Regenerate only via tests/contracts/regenerate_baseline.py after a
consumer-updated contract change. This is the safety harness for all
subsequent refactor commits.

## Phase 1 Step 2 — models.py split (1466 -> 85 LOC shim)

compliance/db/models.py is decomposed into seven sibling aggregate modules
following the existing repo pattern (dsr_models.py, vvt_models.py, ...):

  regulation_models.py       (134) — Regulation, Requirement
  control_models.py          (279) — Control, Mapping, Evidence, Risk
  ai_system_models.py        (141) — AISystem, AuditExport
  service_module_models.py   (176) — ServiceModule, ModuleRegulation, ModuleRisk
  audit_session_models.py    (177) — AuditSession, AuditSignOff
  isms_governance_models.py  (323) — ISMSScope, Context, Policy, Objective, SoA
  isms_audit_models.py       (468) — Finding, CAPA, MgmtReview, InternalAudit,
                                     AuditTrail, Readiness

models.py becomes an 85-line re-export shim in dependency order so
existing imports continue to work unchanged. Schema is byte-identical:
__tablename__, column definitions, relationship strings, back_populates,
cascade directives all preserved.

All new sibling files are under the 500-line hard cap; largest is
isms_audit_models.py at 468. No file in compliance/db/ now exceeds
the hard cap.

## Phase 1 Step 3 — infrastructure only

backend-compliance/compliance/{schemas,domain,repositories}/ packages
are created as landing zones with docstrings. compliance/domain/
exports DomainError / NotFoundError / ConflictError / ValidationError /
PermissionError — the base classes services will use to raise
domain-level errors instead of HTTPException.

PHASE1_RUNBOOK.md at backend-compliance/PHASE1_RUNBOOK.md documents
the nine-step execution plan for Phase 1: snapshot baseline,
characterization tests, split models.py (this commit), split schemas.py
(next), extract services, extract repositories, mypy --strict, coverage.

## Verification

  backend-compliance/.venv-phase1: uv python install 3.12 + pip -r requirements.txt
  PYTHONPATH=. pytest compliance/tests/ tests/contracts/
  -> 173 passed, 0 failed, 35 warnings, OpenAPI 360/484 unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:18:29 +02:00
Sharang Parnerkar
1dfea51919 Remove standalone deploy-coolify.yml — deploy is handled in ci.yaml
Some checks failed
CI/CD / go-lint (pull_request) Failing after 2s
CI/CD / python-lint (pull_request) Failing after 10s
CI/CD / nodejs-lint (pull_request) Failing after 2s
CI/CD / test-go-ai-compliance (pull_request) Failing after 2s
CI/CD / test-python-backend-compliance (pull_request) Failing after 10s
CI/CD / test-python-document-crawler (pull_request) Failing after 12s
CI/CD / test-python-dsms-gateway (pull_request) Failing after 10s
CI/CD / validate-canonical-controls (pull_request) Failing after 10s
CI/CD / Deploy (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:26:31 +01:00
Sharang Parnerkar
559d7960a2 Replace deploy-hetzner with Coolify webhook deploy
Some checks failed
CI/CD / go-lint (pull_request) Failing after 15s
CI/CD / python-lint (pull_request) Failing after 12s
CI/CD / nodejs-lint (pull_request) Failing after 2s
CI/CD / test-go-ai-compliance (pull_request) Failing after 2s
CI/CD / test-python-backend-compliance (pull_request) Failing after 11s
CI/CD / test-python-document-crawler (pull_request) Failing after 11s
CI/CD / test-python-dsms-gateway (pull_request) Failing after 10s
CI/CD / validate-canonical-controls (pull_request) Failing after 9s
CI/CD / Deploy (pull_request) Has been skipped
Deploy to Coolify / deploy (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:39:12 +01:00
Sharang Parnerkar
a101426dba Add traefik.docker.network label to fix routing
Containers are on multiple networks (breakpilot-network, coolify,
gokocgws...). Without traefik.docker.network, Traefik randomly picks
a network and may choose breakpilot-network where it has no access.
This label forces Traefik to always use the coolify network.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:52 +01:00
Sharang Parnerkar
f6b22820ce Add coolify network to externally-routed services
Traefik routes traffic via the 'coolify' bridge network, so services
that need public domain access must be on both breakpilot-network
(for inter-service communication) and coolify (for Traefik routing).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:52 +01:00
Sharang Parnerkar
86588aff09 Fix SQLAlchemy 2.x compatibility: wrap raw SQL in text()
SQLAlchemy 2.x requires raw SQL strings to be explicitly wrapped
in text(). Fixed 16 instances across 5 route files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:52 +01:00
Sharang Parnerkar
033fa52e5b Add healthcheck to dsms-gateway
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:00 +01:00
Sharang Parnerkar
005fb9d219 Add healthchecks to admin-compliance, developer-portal, backend-compliance
Traefik may require healthchecks to route traffic to containers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:00 +01:00
Sharang Parnerkar
0c01f1c96c Remove Traefik labels from coolify compose — Coolify handles routing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:00 +01:00
Sharang Parnerkar
ffd256d420 Sync coolify compose with main: use COMPLIANCE_DATABASE_URL, QDRANT_URL
- Switch to ${COMPLIANCE_DATABASE_URL} for admin-compliance, backend, SDK, crawler
- Add DATABASE_URL to admin-compliance environment
- Switch ai-compliance-sdk from QDRANT_HOST/PORT to QDRANT_URL + QDRANT_API_KEY
- Add MINIO_SECURE to compliance-tts-service
- Update .env.coolify.example with new variable patterns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:00 +01:00
Sharang Parnerkar
d542dbbacd fix: ensure public dir exists in developer-portal build
Next.js standalone COPY fails when no public directory exists in source.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:16:00 +01:00
Sharang Parnerkar
a3d0024d39 fix: use Alpine-compatible addgroup/adduser flags in Dockerfiles
Replace --system/--gid/--uid (Debian syntax) with -S/-g/-u (BusyBox/Alpine).
Coolify ARG injection causes exit code 255 with Debian-style flags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:13:57 +01:00
Sharang Parnerkar
998d427c3c fix: update alpine base to 3.21 for ai-compliance-sdk
Alpine 3.19 apk mirrors failing during Coolify build.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:13:57 +01:00
Sharang Parnerkar
99f3180ffc refactor(coolify): externalize postgres, qdrant, S3
- Replace bp-core-postgres with POSTGRES_HOST env var
- Replace bp-core-qdrant with QDRANT_HOST env var
- Replace bp-core-minio with S3_ENDPOINT/S3_ACCESS_KEY/S3_SECRET_KEY

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:13:57 +01:00
Sharang Parnerkar
2ec340c64b feat: add Coolify deployment configuration
Add docker-compose.coolify.yml (8 services), .env.coolify.example,
and Gitea Action workflow for Coolify API deployment. Removes
core-health-check and docs. Adds Traefik labels for
*.breakpilot.ai domain routing with Let's Encrypt SSL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:13:57 +01:00
498 changed files with 61683 additions and 188034 deletions

View File

@@ -1,73 +1,66 @@
# BreakPilot Compliance - DSGVO/AI-Act SDK Platform
> **NON-NEGOTIABLE STRUCTURE RULES** (enforced by `.claude/settings.json` hook, git pre-commit, and CI):
> 1. **File-size budget:** soft target **300** lines, **hard cap 500** lines for any non-test, non-generated source file. Anything larger → split it. Exceptions are listed in `.claude/rules/loc-exceptions.txt` and require a written rationale.
> 2. **Clean architecture per service.** Routers/handlers stay thin (≤30 lines per handler) and delegate to services; services use repositories; repositories own DB I/O. See `AGENTS.python.md` / `AGENTS.go.md` / `AGENTS.typescript.md`.
> 3. **Do not touch the database schema.** No new Alembic migrations, no `ALTER TABLE`, no model field renames without an explicit migration plan reviewed by the DB owner. SQLAlchemy `__tablename__` and column names are frozen.
> 4. **Public endpoints are a contract.** Any change to a path, method, status code, request schema, or response schema in `backend-compliance/`, `ai-compliance-sdk/`, `dsms-gateway/`, `document-crawler/`, or `compliance-tts-service/` must be accompanied by a matching update in **every** consumer (`admin-compliance/`, `developer-portal/`, `breakpilot-compliance-sdk/`, `consent-sdk/`). Use the OpenAPI snapshot tests in `tests/contracts/` as the gate.
> 5. **Tests are not optional.** New code without tests fails CI. Refactors must preserve coverage and add a characterization test before splitting an oversized file.
> 6. **Do not bypass the guardrails.** Do not edit `.claude/settings.json`, `scripts/check-loc.sh`, or the loc-exceptions list to silence violations. If a rule is wrong, raise it in a PR description.
>
> These rules apply to **every** Claude Code session opened inside this repository, regardless of who launched it. They are loaded automatically via this `CLAUDE.md`.
## Entwicklungsumgebung (WICHTIG - IMMER ZUERST LESEN)
### Zwei-Rechner-Setup + Coolify
### Zwei-Rechner-Setup + Hetzner
| 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 |
| **Mac Mini** | Lokaler Server | Docker fuer lokale Dev/Tests (NICHT mehr fuer Production!) |
| **Hetzner** | Production | CI/CD Build + Deploy via Gitea Actions |
**WICHTIG:** Code wird auf dem MacBook bearbeitet. Production-Deployment laeuft automatisch ueber Coolify.
**WICHTIG:** Code wird auf dem MacBook bearbeitet. Production-Deployment laeuft automatisch auf Hetzner via CI/CD.
### Entwicklungsworkflow (CI/CD — Coolify)
### Entwicklungsworkflow (CI/CD — seit 2026-03-11)
```bash
# 1. Code auf MacBook bearbeiten (dieses Verzeichnis)
# 2. Committen und zu BEIDEN Remotes pushen:
git push origin main && git push gitea main
# 3. FERTIG! Push auf gitea triggert automatisch:
# - Gitea Actions: Lint → Tests → Validierung
# - Coolify: Build → Deploy
# 3. FERTIG! Gitea Actions auf Hetzner uebernimmt automatisch:
# Push auf main → Lint → Tests → Build → Deploy
# Pipeline: .gitea/workflows/ci.yaml
# 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.
**NICHT MEHR NOETIG:** Manuelles `ssh macmini "docker compose build"` — das macht jetzt die CI/CD Pipeline!
### Post-Push Deploy-Monitoring (PFLICHT nach jedem Push auf gitea)
**IMMER wenn Claude auf gitea pusht, MUSS danach automatisch das Deploy-Monitoring laufen:**
1. Dem User sofort mitteilen: "Deploy gestartet, ich ueberwache den Status..."
2. Im Hintergrund Health-Checks pollen (alle 20 Sekunden, max 5 Minuten):
```bash
# Compliance Health-Endpoints:
curl -sf https://api-dev.breakpilot.ai/health # Backend Compliance
curl -sf https://sdk-dev.breakpilot.ai/health # AI Compliance SDK
```
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.
**Ablauf im Terminal:**
```
> git push gitea 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 → Hetzner)
```
Push auf gitea main → go-lint/python-lint/nodejs-lint (nur PRs)
→ test-go-ai-compliance
→ test-python-backend-compliance
→ test-python-document-crawler
→ test-python-dsms-gateway
validate-canonical-controls
→ Coolify: Build + Deploy (automatisch bei Push)
Push auf main → go-lint/python-lint/nodejs-lint (nur PRs)
→ test-go-ai-compliance
→ test-python-backend-compliance
→ test-python-document-crawler
→ test-python-dsms-gateway
deploy-hetzner (nur wenn ALLE Tests gruen)
```
**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)
- `.gitea/workflows/ci.yaml` — Pipeline-Definition
- `docker-compose.hetzner.yml`Override: arm64→amd64 fuer Hetzner (x86_64)
- Deploy-Pfad auf Hetzner: `/opt/breakpilot-compliance/`
**Ablauf deploy-hetzner:**
1. `git pull` im Deploy-Dir
2. `docker compose -f docker-compose.yml -f docker-compose.hetzner.yml build --parallel`
3. `docker compose up -d --remove-orphans`
4. Health Checks
### Lokale Entwicklung (Mac Mini — optional)
@@ -95,18 +88,20 @@ rsync -avz --exclude node_modules --exclude .next --exclude .git \
- RAG-Service (Vektorsuche fuer Compliance-Dokumente)
- Nginx (Reverse Proxy)
**Externe Services (Production):**
- PostgreSQL 17 (sslmode=require) — Schemas: `compliance`, `public`
**Externe Services (Hetzner/meghshakka) — seit 2026-03-06:**
- PostgreSQL 17 @ `46.225.100.82:54321` (sslmode=require) — Schemas: `compliance` (51), `public` (compliance_* + training_* + ucca_* + academy_*)
- Qdrant @ `qdrant-dev.breakpilot.ai` (HTTPS, API-Key)
- Object Storage (S3-kompatibel, TLS)
- Object Storage @ `nbg1.your-objectstorage.com` (S3-kompatibel, TLS)
Config via `.env` (nicht im Repo): `COMPLIANCE_DATABASE_URL`, `QDRANT_URL`, `QDRANT_API_KEY`
Config via `.env` auf Mac Mini (nicht im Repo): `COMPLIANCE_DATABASE_URL`, `QDRANT_URL`, `QDRANT_API_KEY`
Pruefen: `curl -sf http://macmini:8099/health`
---
## Haupt-URLs
### Production (Coolify-deployed)
### Production (Hetzner — primaer)
| URL | Service | Beschreibung |
|-----|---------|--------------|
@@ -162,6 +157,18 @@ Config via `.env` (nicht im Repo): `COMPLIANCE_DATABASE_URL`, `QDRANT_URL`, `QDR
| docs | MkDocs/nginx | 8011 | bp-compliance-docs |
| core-wait | curl health-check | - | bp-compliance-core-wait |
### compliance-tts-service
- Piper TTS + FFmpeg fuer Schulungsvideos
- Speichert Audio/Video in Hetzner Object Storage (nbg1.your-objectstorage.com)
- TTS-Modell: `de_DE-thorsten-high.onnx`
- Dateien: `main.py`, `tts_engine.py`, `video_generator.py`, `storage.py`
### document-crawler
- Dokument-Analyse: PDF, DOCX, XLSX, PPTX
- Gap-Analyse zwischen bestehenden Dokumenten und Compliance-Anforderungen
- IPFS-Archivierung via dsms-gateway
- Kommuniziert mit ai-compliance-sdk (LLM Gateway)
### Docker-Netzwerk
Nutzt das externe Core-Netzwerk:
```yaml
@@ -207,8 +214,8 @@ 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
└── .gitea/workflows/ci.yaml # CI/CD Pipeline (Lint → Tests → Validierung)
├── docker-compose.hetzner.yml # Override: arm64→amd64 fuer Hetzner
└── .gitea/workflows/ci.yaml # CI/CD Pipeline (Lint → Tests → Deploy)
```
---
@@ -218,7 +225,7 @@ breakpilot-compliance/
### Deployment (CI/CD — Standardweg)
```bash
# Committen und pushen → Coolify deployt automatisch:
# Committen und pushen → CI/CD deployt automatisch auf Hetzner:
git push origin main && git push gitea main
# CI-Status pruefen (im Browser):
@@ -331,6 +338,10 @@ DELETE /api/v1/projects/{project_id} → Projekt archivieren (Soft Delete)
- `app/sdk/layout.tsx` — liest `?project=` aus searchParams
- `app/api/sdk/v1/projects/` — Next.js Proxy zum Backend
**Multi-Tab:** Tab A (Projekt X) und Tab B (Projekt Y) interferieren nicht — separate BroadcastChannel + localStorage Keys.
**Stammdaten-Kopie:** Neues Projekt mit `copy_from_project_id` → Backend kopiert `companyProfile` aus dem Quell-State. Danach unabhaengig editierbar.
### Backend-Compliance APIs
```
POST/GET /api/v1/compliance/risks
@@ -341,7 +352,7 @@ POST/GET /api/v1/dsr/requests
POST/GET /api/v1/gdpr/exports
POST/GET /api/v1/consent/admin
# Stammdaten, Versionierung & Change-Requests
# Stammdaten, Versionierung & Change-Requests (Phase 1-6, 2026-03-07)
GET/POST/DELETE /api/compliance/company-profile
GET /api/compliance/company-profile/template-context
GET /api/compliance/change-requests
@@ -359,6 +370,24 @@ GET /api/compliance/{doc}/{id}/versions
- UUID-Format, kein `"default"` mehr
- Header `X-Tenant-ID` > Query `tenant_id` > ENV-Fallback
### Migrations (035-038)
| Nr | Datei | Beschreibung |
|----|-------|--------------|
| 035 | `migrations/035_vvt_tenant_isolation.sql` | VVT tenant_id + DSFA/Vendor default→UUID |
| 036 | `migrations/036_company_profile_extend.sql` | Stammdaten JSONB + Regulierungs-Flags |
| 037 | `migrations/037_document_versions.sql` | 5 Versions-Tabellen + current_version |
| 038 | `migrations/038_change_requests.sql` | Change-Requests + Audit-Log |
### Neue Backend-Module
| Datei | Beschreibung |
|-------|--------------|
| `compliance/api/tenant_utils.py` | Shared Tenant-ID Dependency |
| `compliance/api/versioning_utils.py` | Shared Versioning Helper |
| `compliance/api/change_request_routes.py` | CR CRUD + Accept/Reject/Edit |
| `compliance/api/change_request_engine.py` | Regelbasierte CR-Generierung |
| `compliance/api/generation_routes.py` | Dokumentengenerierung aus Stammdaten |
| `compliance/api/document_templates/` | 5 Template-Generatoren (DSFA, VVT, TOM, etc.) |
---
## Wichtige Dateien (Referenz)
@@ -366,7 +395,9 @@ GET /api/compliance/{doc}/{id}/versions
| Datei | Beschreibung |
|-------|--------------|
| `admin-compliance/app/(sdk)/` | Alle 37+ SDK-Routes |
| `admin-compliance/components/sdk/Sidebar/SDKSidebar.tsx` | SDK Navigation |
| `admin-compliance/app/(sdk)/sdk/change-requests/page.tsx` | Change-Request Inbox |
| `admin-compliance/components/sdk/Sidebar/SDKSidebar.tsx` | SDK Navigation (mit CR-Badge) |
| `admin-compliance/components/sdk/VersionHistory.tsx` | Versions-Timeline-Komponente |
| `admin-compliance/components/sdk/CommandBar.tsx` | Command Palette |
| `admin-compliance/lib/sdk/context.tsx` | SDK State (Provider) |
| `backend-compliance/compliance/` | Haupt-Package (50+ Dateien) |

View File

@@ -0,0 +1,43 @@
# Architecture Rules (auto-loaded)
These rules apply to **every** Claude Code session in this repository, regardless of who launched it. They are non-negotiable.
## File-size budget
- **Soft target:** 300 lines per non-test, non-generated source file.
- **Hard cap:** 500 lines. The PreToolUse hook in `.claude/settings.json` blocks Write/Edit operations that would create or push a file past 500. The git pre-commit hook re-checks. CI is the final gate.
- Exceptions live in `.claude/rules/loc-exceptions.txt` and require a written rationale plus `[guardrail-change]` in the commit message. The exceptions list should shrink over time, not grow.
## Clean architecture
- Python (FastAPI): see `AGENTS.python.md`. Layering: `api → services → repositories → db.models`. Routers ≤30 LOC per handler. Schemas split per domain.
- Go (Gin): see `AGENTS.go.md`. Standard Go Project Layout + hexagonal. `cmd/` thin, wiring in `internal/app`.
- TypeScript (Next.js): see `AGENTS.typescript.md`. Server-by-default, push the client boundary deep, colocate `_components/` and `_hooks/` per route.
## Database is frozen
- No new Alembic migrations. No `ALTER TABLE`. No `__tablename__` or column renames.
- The pre-commit hook blocks any change under `migrations/` or `alembic/versions/` unless the commit message contains `[migration-approved]`.
## Public endpoints are a contract
- Any change to a path/method/status/request schema/response schema in a backend service must update every consumer in the same change set.
- Each backend service has an OpenAPI baseline at `tests/contracts/openapi.baseline.json`. Contract tests fail on drift.
## Tests
- New code without tests fails CI.
- Refactors must preserve coverage. Before splitting an oversized file, add a characterization test that pins current behavior.
- Layout: `tests/unit/`, `tests/integration/`, `tests/contracts/`, `tests/e2e/`.
## Guardrails are themselves protected
- Edits to `.claude/settings.json`, `scripts/check-loc.sh`, `scripts/githooks/pre-commit`, `.claude/rules/loc-exceptions.txt`, or any `AGENTS.*.md` require `[guardrail-change]` in the commit message. The pre-commit hook enforces this.
- If you (Claude) think a rule is wrong, surface it to the user. Do not silently weaken it.
## Tooling baseline
- Python: `ruff`, `mypy --strict` on new modules, `pytest --cov`.
- Go: `golangci-lint` strict config, `go vet`, table-driven tests.
- TS: `tsc --noEmit` strict, ESLint type-aware, Vitest, Playwright.
- All three: dependency caching in CI, license/SBOM scan via `syft`+`grype`.

View File

@@ -0,0 +1,8 @@
# loc-exceptions.txt — files allowed to exceed the 500-line hard cap.
#
# Format: one repo-relative path per line. Comments start with '#' and are ignored.
# Each exception MUST be preceded by a comment explaining why splitting is not viable.
#
# Phase 0 baseline: this list is initially empty. Phases 1-4 will add grandfathered
# entries as we encounter legitimate exceptions (e.g. large generated data tables).
# The goal is for this list to SHRINK over time, never grow.

28
.claude/settings.json Normal file
View File

@@ -0,0 +1,28 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "f=$(jq -r '.tool_input.file_path // empty'); [ -z \"$f\" ] && exit 0; lines=$(printf '%s' \"$(jq -r '.tool_input.content // empty')\" | awk 'END{print NR}'); if [ \"${lines:-0}\" -gt 500 ]; then echo '{\"decision\":\"block\",\"reason\":\"breakpilot guardrail: file exceeds the 500-line hard cap. Split it into smaller modules per the layering rules in AGENTS.<lang>.md. If this is generated/data code, add an entry to .claude/rules/loc-exceptions.txt with rationale and reference [guardrail-change].\"}'; exit 0; fi",
"shell": "bash",
"timeout": 5
}
]
},
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "f=$(jq -r '.tool_input.file_path // empty'); [ -z \"$f\" ] || [ ! -f \"$f\" ] && exit 0; case \"$f\" in *.md|*.json|*.yaml|*.yml|*test*|*tests/*|*node_modules/*|*.next/*|*migrations/*) exit 0 ;; esac; new_str=$(jq -r '.tool_input.new_string // empty'); old_str=$(jq -r '.tool_input.old_string // empty'); old_lines=$(printf '%s' \"$old_str\" | awk 'END{print NR}'); new_lines=$(printf '%s' \"$new_str\" | awk 'END{print NR}'); cur=$(wc -l < \"$f\" | tr -d ' '); proj=$((cur - old_lines + new_lines)); if [ \"$proj\" -gt 500 ]; then echo \"{\\\"decision\\\":\\\"block\\\",\\\"reason\\\":\\\"breakpilot guardrail: this edit would push $f to ~$proj lines (hard cap is 500). Split the file before continuing. See AGENTS.<lang>.md for the layering rules.\\\"}\"; fi; exit 0",
"shell": "bash",
"timeout": 5
}
]
}
]
}
}

View File

@@ -19,6 +19,55 @@ on:
branches: [main, develop]
jobs:
# ========================================
# Guardrails — LOC budget + architecture gates
# Runs on every push/PR. Fails fast and cheap.
# ========================================
loc-budget:
runs-on: docker
container: alpine:3.20
steps:
- name: Checkout
run: |
apk add --no-cache git bash
git clone --depth 50 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
- name: Enforce 500-line hard cap on changed files
run: |
chmod +x scripts/check-loc.sh
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
git fetch origin ${GITHUB_BASE_REF}:base
mapfile -t changed < <(git diff --name-only --diff-filter=ACM base...HEAD)
[ ${#changed[@]} -eq 0 ] && { echo "No changed files."; exit 0; }
scripts/check-loc.sh "${changed[@]}"
else
# Push to main: only warn on whole-repo state; blocking gate is on PRs.
scripts/check-loc.sh || true
fi
# Phase 0 intentionally gates only changed files so the 205-file legacy
# baseline doesn't block every PR. Phases 1-4 drain the baseline; Phase 5
# flips this to a whole-repo blocking gate.
guardrail-integrity:
runs-on: docker
container: alpine:3.20
if: github.event_name == 'pull_request'
steps:
- name: Checkout
run: |
apk add --no-cache git bash
git clone --depth 20 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
git fetch origin ${GITHUB_BASE_REF}:base
- name: Require [guardrail-change] label in PR commits touching guardrails
run: |
changed=$(git diff --name-only base...HEAD)
echo "$changed" | grep -E '^(\.claude/settings\.json|\.claude/rules/loc-exceptions\.txt|scripts/check-loc\.sh|scripts/githooks/pre-commit|AGENTS\.(python|go|typescript)\.md)$' || exit 0
if ! git log base..HEAD --format=%B | grep -q '\[guardrail-change\]'; then
echo "::error:: Guardrail files were modified but no commit in this PR carries [guardrail-change]."
echo "If intentional, amend one commit message with [guardrail-change] and explain why in the body."
exit 1
fi
# ========================================
# Lint (nur bei PRs)
# ========================================
@@ -47,13 +96,29 @@ jobs:
run: |
apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
- name: Lint Python services
- name: Lint Python services (ruff)
run: |
pip install --quiet ruff
for svc in backend-compliance document-crawler dsms-gateway; do
fail=0
for svc in backend-compliance document-crawler dsms-gateway compliance-tts-service; do
if [ -d "$svc" ]; then
echo "=== Linting $svc ==="
ruff check "$svc/" --output-format=github || true
echo "=== ruff: $svc ==="
ruff check "$svc/" --output-format=github || fail=1
fi
done
exit $fail
- name: Type-check new modules (mypy --strict)
# Scoped to the layered packages we own. Expand this list as Phase 1+ refactors land.
run: |
pip install --quiet mypy
for pkg in \
backend-compliance/compliance/services \
backend-compliance/compliance/repositories \
backend-compliance/compliance/domain \
backend-compliance/compliance/schemas; do
if [ -d "$pkg" ]; then
echo "=== mypy --strict: $pkg ==="
mypy --strict --ignore-missing-imports "$pkg" || exit 1
fi
done
@@ -66,17 +131,20 @@ jobs:
run: |
apk add --no-cache git
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
- name: Lint Node.js services
- name: Lint + type-check Node.js services
run: |
fail=0
for svc in admin-compliance developer-portal; do
if [ -d "$svc" ]; then
echo "=== Linting $svc ==="
cd "$svc"
npm ci --silent 2>/dev/null || npm install --silent
npx next lint || true
cd ..
echo "=== $svc: install ==="
(cd "$svc" && (npm ci --silent 2>/dev/null || npm install --silent))
echo "=== $svc: next lint ==="
(cd "$svc" && npx next lint) || fail=1
echo "=== $svc: tsc --noEmit ==="
(cd "$svc" && npx tsc --noEmit) || fail=1
fi
done
exit $fail
# ========================================
# Unit Tests
@@ -169,6 +237,32 @@ jobs:
pip install --quiet --no-cache-dir pytest pytest-asyncio
python -m pytest test_main.py -v --tb=short
# ========================================
# SBOM + license scan (compliance product → we eat our own dog food)
# ========================================
sbom-scan:
runs-on: docker
if: github.event_name == 'pull_request'
container: alpine:3.20
steps:
- name: Checkout
run: |
apk add --no-cache git curl bash
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
- name: Install syft + grype
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
- name: Generate SBOM
run: |
mkdir -p sbom-out
syft dir:. -o cyclonedx-json=sbom-out/sbom.cdx.json -q
- name: Vulnerability scan (fail on high+)
run: |
grype sbom:sbom-out/sbom.cdx.json --fail-on high -q || true
# Initially non-blocking ('|| true'). Flip to blocking after baseline is clean.
# ========================================
# Validate Canonical Controls
# ========================================
@@ -194,6 +288,7 @@ jobs:
runs-on: docker
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- loc-budget
- test-go-ai-compliance
- test-python-backend-compliance
- test-python-document-crawler

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 Hetzner laufen.
# Die BreakPilot-Services muessen deployed sein (ci.yaml deploy-hetzner).
name: RAG Ingestion

1
.gitignore vendored
View File

@@ -18,7 +18,6 @@ __pycache__/
venv/
.venv/
.coverage
coverage.out
test_*.db
# Docker

126
AGENTS.go.md Normal file
View File

@@ -0,0 +1,126 @@
# AGENTS.go.md — Go Service Conventions
Applies to: `ai-compliance-sdk/`.
## Layered architecture (Gin)
Follows [Standard Go Project Layout](https://github.com/golang-standards/project-layout) + hexagonal/clean-arch.
```
ai-compliance-sdk/
├── cmd/server/main.go # Thin: parse flags → app.New → app.Run. <50 LOC.
├── internal/
│ ├── app/ # Wiring: config + DI graph + lifecycle.
│ ├── domain/ # Pure types, interfaces, errors. No I/O imports.
│ │ └── <aggregate>/
│ ├── service/ # Business logic. Depends on domain interfaces only.
│ │ └── <aggregate>/
│ ├── repository/postgres/ # Concrete repo implementations.
│ │ └── <aggregate>/
│ ├── transport/http/ # Gin handlers. Thin. One handler per file group.
│ │ ├── handler/<aggregate>/
│ │ ├── middleware/
│ │ └── router.go
│ └── platform/ # DB pool, logger, config, tracing.
└── pkg/ # Importable by other repos. Empty unless needed.
```
**Dependency direction:** `transport → service → domain ← repository`. `domain` imports nothing from siblings.
## Handlers
- One handler = one Gin function. ≤40 LOC.
- Bind → call service → map domain error to HTTP via `httperr.Write(c, err)` → respond.
- Return early on errors. No business logic, no SQL.
```go
func (h *IACEHandler) Create(c *gin.Context) {
var req CreateIACERequest
if err := c.ShouldBindJSON(&req); err != nil {
httperr.Write(c, httperr.BadRequest(err))
return
}
out, err := h.svc.Create(c.Request.Context(), req.ToInput())
if err != nil {
httperr.Write(c, err)
return
}
c.JSON(http.StatusCreated, out)
}
```
## Services
- Struct + constructor + interface methods. No package-level state.
- Take `context.Context` as first arg always. Propagate to repos.
- Return `(value, error)`. Wrap with `fmt.Errorf("create iace: %w", err)`.
- Domain errors implemented as sentinel vars or typed errors; matched with `errors.Is` / `errors.As`.
## Repositories
- Interface lives in `domain/<aggregate>/repository.go`. Implementation in `repository/postgres/<aggregate>/`.
- One file per query group; no file >500 LOC.
- Use `pgx`/`sqlc` over hand-rolled string SQL when feasible. No ORM globals.
- All queries take `ctx`. No background goroutines without explicit lifecycle.
## Errors
Single `internal/platform/httperr` package maps `error` → HTTP status:
```go
switch {
case errors.Is(err, domain.ErrNotFound): return 404
case errors.Is(err, domain.ErrConflict): return 409
case errors.As(err, &validationErr): return 422
default: return 500
}
```
Never `panic` in request handling. `recover` middleware logs and returns 500.
## Tests
- Co-located `*_test.go`.
- **Table-driven** tests for service logic; use `t.Run(tt.name, ...)`.
- Handlers tested with `httptest.NewRecorder`.
- Repos tested with `testcontainers-go` (or the existing compose Postgres) — never mocks at the SQL boundary.
- Coverage target: 80% on `service/`. CI fails on regression.
```go
func TestIACEService_Create(t *testing.T) {
tests := []struct {
name string
input service.CreateInput
setup func(*mockRepo)
wantErr error
}{
{"happy path", validInput(), func(r *mockRepo) { r.createReturns(nil) }, nil},
{"conflict", validInput(), func(r *mockRepo) { r.createReturns(domain.ErrConflict) }, domain.ErrConflict},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { /* ... */ })
}
}
```
## Tooling
- `golangci-lint` with: `errcheck, govet, staticcheck, revive, gosec, gocyclo (max 15), gocognit (max 20), unused, ineffassign, errorlint, nilerr, nolintlint, contextcheck`.
- `gofumpt` formatting.
- `go vet ./...` clean.
- `go mod tidy` clean — no unused deps.
## Concurrency
- Goroutines must have a clear lifecycle owner (struct method that started them must stop them).
- Pass `ctx` everywhere. Cancellation respected.
- No global mutexes for request data. Use per-request context.
## What you may NOT do
- Touch DB schema/migrations.
- Add a new top-level package directly under `internal/` without architectural review.
- `import "C"`, unsafe, reflection-heavy code.
- Use `init()` for non-trivial setup. Wire it in `internal/app`.
- Create a file >500 lines.
- Change a public route's contract without updating consumers.

94
AGENTS.python.md Normal file
View File

@@ -0,0 +1,94 @@
# AGENTS.python.md — Python Service Conventions
Applies to: `backend-compliance/`, `document-crawler/`, `dsms-gateway/`, `compliance-tts-service/`.
## Layered architecture (FastAPI)
```
compliance/
├── api/ # HTTP layer — routers only. Thin (≤30 LOC per handler).
│ └── <domain>_routes.py
├── services/ # Business logic. Pure-ish; no FastAPI imports.
│ └── <domain>_service.py
├── repositories/ # DB access. Owns SQLAlchemy session usage.
│ └── <domain>_repository.py
├── domain/ # Value objects, enums, domain exceptions.
├── schemas/ # Pydantic models, split per domain. NEVER one giant schemas.py.
│ └── <domain>.py
└── db/
└── models/ # SQLAlchemy ORM, one module per aggregate. __tablename__ frozen.
```
**Dependency direction:** `api → services → repositories → db.models`. Lower layers must not import upper layers.
## Routers
- One `APIRouter` per domain file.
- Handlers do exactly: parse request → call service → map domain errors to HTTPException → return response model.
- Inject services via `Depends`. No globals.
- Tag routes; document with summary + response_model.
```python
@router.post("/dsr/requests", response_model=DSRRequestRead, status_code=201)
async def create_dsr_request(
payload: DSRRequestCreate,
service: DSRService = Depends(get_dsr_service),
tenant_id: UUID = Depends(get_tenant_id),
) -> DSRRequestRead:
try:
return await service.create(tenant_id, payload)
except DSRConflict as exc:
raise HTTPException(409, str(exc)) from exc
```
## Services
- Constructor takes the repository (interface, not concrete).
- No `Request`, `Response`, or HTTP knowledge.
- Raise domain exceptions (e.g. `DSRConflict`, `DSRNotFound`), never `HTTPException`.
- Return domain objects or Pydantic schemas — pick one and stay consistent inside a service.
## Repositories
- Methods are intent-named (`get_pending_for_tenant`), not CRUD-named (`select_where`).
- Sessions injected, not constructed inside.
- No business logic. No cross-aggregate joins for unrelated workflows — that belongs in a service.
- Return ORM models or domain VOs; never `Row`.
## Schemas (Pydantic v2)
- One module per domain. Module ≤300 lines.
- Use `model_config = ConfigDict(from_attributes=True, frozen=True)` for read models.
- Separate `*Create`, `*Update`, `*Read`. No giant union schemas.
## Tests (`pytest`)
- Layout: `tests/unit/`, `tests/integration/`, `tests/contracts/`.
- Unit tests mock the repository. Use `pytest.fixture` + `unittest.mock.AsyncMock`.
- Integration tests run against the real Postgres from `docker-compose.yml` via a transactional fixture (rollback after each test).
- Contract tests diff `/openapi.json` against `tests/contracts/openapi.baseline.json`.
- Naming: `test_<unit>_<scenario>_<expected>.py::TestClass::test_method`.
- `pytest-asyncio` mode = `auto`. Mark slow tests with `@pytest.mark.slow`.
- Coverage target: 80% for new code; never decrease the service baseline.
## Tooling
- `ruff check` + `ruff format` (line length 100).
- `mypy --strict` on `services/`, `repositories/`, `domain/`. Expand outward.
- `pip-audit` in CI.
- Async-first: prefer `httpx.AsyncClient`, `asyncpg`/`SQLAlchemy 2.x async`.
## Errors & logging
- Domain errors inherit from a single `DomainError` base per service.
- Log via `structlog` with bound context (`tenant_id`, `request_id`). Never log secrets, PII, or full request bodies.
- Audit-relevant actions go through the audit logger, not the application logger.
## What you may NOT do
- Add a new Alembic migration.
- Rename a `__tablename__`, column, or enum value.
- Change a public route's path/method/status/schema without simultaneous dashboard fix.
- Catch `Exception` broadly — catch the specific domain or library error.
- Put business logic in a router or in a Pydantic validator.
- Create a new file >500 lines. Period.

85
AGENTS.typescript.md Normal file
View File

@@ -0,0 +1,85 @@
# AGENTS.typescript.md — TypeScript / Next.js Conventions
Applies to: `admin-compliance/`, `developer-portal/`, `breakpilot-compliance-sdk/`, `consent-sdk/`, `dsms-node/` (where applicable).
## Layered architecture (Next.js 15 App Router)
```
app/
├── <route>/
│ ├── page.tsx # Server Component by default. ≤200 LOC.
│ ├── layout.tsx
│ ├── _components/ # Private folder; not routable. Colocated UI.
│ │ └── <Component>.tsx # Each file ≤300 LOC.
│ ├── _hooks/ # Client hooks for this route.
│ ├── _server/ # Server actions, data loaders for this route.
│ └── loading.tsx / error.tsx
├── api/
│ └── <domain>/route.ts # Thin handler. Delegates to lib/server/<domain>/.
lib/
├── <domain>/ # Pure helpers, types, schemas (zod). Reusable.
└── server/<domain>/ # Server-only logic; uses "server-only" import.
components/ # Truly shared, app-wide components.
```
**Server vs Client:** Default is Server Component. Add `"use client"` only when you need state, effects, or browser APIs. Push the boundary as deep as possible.
## API routes (route.ts)
- One handler per HTTP method, ≤40 LOC.
- Validate input with `zod`. Reject invalid → 400.
- Delegate to `lib/server/<domain>/`. No business logic in `route.ts`.
- Always return `NextResponse.json(..., { status })`. Never throw to the framework.
```ts
export async function POST(req: Request) {
const parsed = CreateDSRSchema.safeParse(await req.json());
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
const result = await dsrService.create(parsed.data);
return NextResponse.json(result, { status: 201 });
}
```
## Page components
- Pages >300 lines must be split into colocated `_components/`.
- Server Components fetch data; pass plain objects to Client Components.
- No data fetching in `useEffect` for server-renderable data.
- State management: prefer URL state (`searchParams`) and Server Components over global stores.
## Types
- `lib/sdk/types.ts` is being split into `lib/sdk/types/<domain>.ts`. Mirror backend domain boundaries.
- All API DTOs are zod schemas; infer types via `z.infer`.
- No `any`. No `as unknown as`. If you reach for it, the type is wrong.
## Tests
- Unit: **Vitest** (`*.test.ts`/`*.test.tsx`), colocated.
- Hooks: `@testing-library/react` `renderHook`.
- E2E: **Playwright** (`tests/e2e/`), one spec per top-level page, smoke happy path minimum.
- Snapshot tests sparingly — only for stable output (CSV, JSON-LD).
- Coverage target: 70% on `lib/`, smoke coverage on `app/`.
## Tooling
- `tsc --noEmit` clean (strict mode, `noUncheckedIndexedAccess: true`).
- ESLint with `@typescript-eslint`, `eslint-config-next`, type-aware rules on.
- `prettier`.
- `next build` clean. No `// @ts-ignore`. `// @ts-expect-error` only with a comment explaining why.
## Performance
- Use `next/dynamic` for heavy client-only components.
- Image: `next/image` with explicit width/height.
- Avoid waterfalls — `Promise.all` for parallel data fetches in Server Components.
## What you may NOT do
- Put business logic in a `page.tsx` or `route.ts`.
- Reach across module boundaries (e.g. `admin-compliance` importing from `developer-portal`).
- Use `dangerouslySetInnerHTML` without explicit sanitization.
- Call backend APIs directly from Client Components when a Server Component or Server Action would do.
- Change a public API route's path/method/schema without updating SDK consumers in the same change.
- Create a file >500 lines.
- Disable a lint or type rule globally to silence a finding — fix the root cause.

View File

@@ -0,0 +1,51 @@
# admin-compliance
Next.js 15 dashboard for BreakPilot Compliance — SDK module UI, company profile, DSR, DSFA, VVT, TOM, consent, AI Act, training, audit, change requests, etc. Also hosts 96+ API routes that proxy/orchestrate backend services.
**Port:** `3007` (container: `bp-compliance-admin`)
**Stack:** Next.js 15 App Router, React 18, TailwindCSS, TypeScript strict.
## Architecture (target — Phase 3)
```
app/
├── <route>/
│ ├── page.tsx # Server Component (≤200 LOC)
│ ├── _components/ # Colocated UI, each ≤300 LOC
│ ├── _hooks/ # Client hooks
│ └── _server/ # Server actions
├── api/<domain>/route.ts # Thin handlers → lib/server/<domain>/
lib/
├── <domain>/ # Pure helpers, zod schemas
└── server/<domain>/ # "server-only" logic
components/ # App-wide shared UI
```
See `../AGENTS.typescript.md`.
## Run locally
```bash
cd admin-compliance
npm install
npm run dev # http://localhost:3007
```
## Tests
```bash
npm test # Vitest unit + component tests
npx playwright test # E2E
npx tsc --noEmit # Type-check
npx next lint
```
## Known debt (Phase 3 targets)
- `app/sdk/company-profile/page.tsx` (3017 LOC), `tom-generator/controls/loader.ts` (2521), `lib/sdk/types.ts` (2511), `app/sdk/loeschfristen/page.tsx` (2322), `app/sdk/dsb-portal/page.tsx` (2068) — all must be split.
- 0 test files for 182 monolithic pages. Phase 3 adds Playwright smoke + Vitest unit coverage.
## Don't touch
- Backend API paths without updating `backend-compliance/` in the same change.
- `lib/sdk/types.ts` in large contiguous chunks — it's being domain-split.

View File

@@ -48,12 +48,12 @@ describe('Ingestion Script: ingest-industry-compliance.sh', () => {
expect(scriptContent).toContain('chunk_strategy=recursive')
})
it('should use chunk_size=1024', () => {
expect(scriptContent).toContain('chunk_size=1024')
it('should use chunk_size=512', () => {
expect(scriptContent).toContain('chunk_size=512')
})
it('should use chunk_overlap=128', () => {
expect(scriptContent).toContain('chunk_overlap=128')
it('should use chunk_overlap=50', () => {
expect(scriptContent).toContain('chunk_overlap=50')
})
it('should validate minimum file size', () => {

View File

@@ -591,43 +591,12 @@ async function handleV2Draft(body: Record<string, unknown>): Promise<NextRespons
cacheStats: proseCache.getStats(),
}
// Anti-Fake-Evidence: Truth label for all LLM-generated content
const truthLabel = {
generation_mode: 'draft_assistance',
truth_status: 'generated',
may_be_used_as_evidence: false,
generated_by: 'system',
}
// Fire-and-forget: persist LLM audit trail to backend
try {
const BACKEND_URL = process.env.BACKEND_COMPLIANCE_URL || 'http://backend-compliance:8002'
fetch(`${BACKEND_URL}/api/compliance/llm-audit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entity_type: 'document',
entity_id: null,
generation_mode: 'draft_assistance',
truth_status: 'generated',
may_be_used_as_evidence: false,
llm_model: LLM_MODEL,
llm_provider: 'ollama',
input_summary: `${documentType} draft generation`,
output_summary: draft?.sections?.length ? `${draft.sections.length} sections generated` : 'draft generated',
}),
}).catch(() => {/* fire-and-forget */})
} catch {
// LLM audit persistence failure should not block the response
}
return NextResponse.json({
draft,
constraintCheck,
tokensUsed: Math.round(totalTokens),
pipelineVersion: 'v2',
auditTrail,
truthLabel,
})
}

View File

@@ -14,76 +14,6 @@ import { buildCrossCheckPrompt } from '@/lib/sdk/drafting-engine/prompts/validat
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
/**
* Anti-Fake-Evidence: Verbotene Formulierungen
*
* Flags formulations that falsely claim compliance without evidence.
* Only allowed when: control_status=pass AND confidence >= E2 AND
* truth_status in (validated_internal, accepted_by_auditor).
*/
interface EvidenceContext {
controlStatus?: string
confidenceLevel?: string
truthStatus?: string
}
const FORBIDDEN_PATTERNS: Array<{
pattern: RegExp
label: string
safeAlternative: string
}> = [
{ pattern: /ist\s+compliant/gi, label: 'ist compliant', safeAlternative: 'soll compliant sein' },
{ pattern: /erfüllt\s+vollständig/gi, label: 'erfüllt vollständig', safeAlternative: 'soll vollständig erfüllt werden' },
{ pattern: /wurde\s+geprüft/gi, label: 'wurde geprüft', safeAlternative: 'soll geprüft werden' },
{ pattern: /wurde\s+umgesetzt/gi, label: 'wurde umgesetzt', safeAlternative: 'ist zur Umsetzung vorgesehen' },
{ pattern: /ist\s+auditiert/gi, label: 'ist auditiert', safeAlternative: 'soll auditiert werden' },
{ pattern: /vollständig\s+implementiert/gi, label: 'vollständig implementiert', safeAlternative: 'Implementierung ist vorgesehen' },
{ pattern: /nachweislich\s+konform/gi, label: 'nachweislich konform', safeAlternative: 'Konformität ist nachzuweisen' },
]
const CONFIDENCE_ORDER: Record<string, number> = { E0: 0, E1: 1, E2: 2, E3: 3, E4: 4 }
const VALID_TRUTH_STATUSES = new Set(['validated_internal', 'accepted_by_auditor'])
function checkForbiddenFormulations(
content: string,
evidenceContext?: EvidenceContext,
): ValidationFinding[] {
const findings: ValidationFinding[] = []
if (!content) return findings
// If evidence context shows sufficient proof, allow the formulations
if (evidenceContext) {
const { controlStatus, confidenceLevel, truthStatus } = evidenceContext
const confLevel = CONFIDENCE_ORDER[confidenceLevel ?? 'E0'] ?? 0
if (
controlStatus === 'pass' &&
confLevel >= CONFIDENCE_ORDER.E2 &&
VALID_TRUTH_STATUSES.has(truthStatus ?? '')
) {
return findings // Formulations are backed by real evidence
}
}
for (const { pattern, label, safeAlternative } of FORBIDDEN_PATTERNS) {
// Reset regex state for global patterns
pattern.lastIndex = 0
if (pattern.test(content)) {
findings.push({
id: `AFE-FORBIDDEN-${label.replace(/\s+/g, '_').toUpperCase()}`,
severity: 'error',
category: 'forbidden_formulation' as ValidationFinding['category'],
title: `Verbotene Formulierung: "${label}"`,
description: `Die Formulierung "${label}" impliziert eine nachgewiesene Compliance, die ohne ausreichenden Nachweis (Evidence >= E2, validiert) nicht verwendet werden darf.`,
documentType: 'vvt' as ScopeDocumentType,
suggestion: `Verwende stattdessen: "${safeAlternative}"`,
})
}
}
return findings
}
/**
* Stufe 1: Deterministische Pruefung
*/
@@ -291,18 +221,10 @@ export async function POST(request: NextRequest) {
// LLM unavailable, continue with deterministic results only
}
// ---------------------------------------------------------------
// Stufe 1b: Verbotene Formulierungen (Anti-Fake-Evidence)
// ---------------------------------------------------------------
const forbiddenFindings = checkForbiddenFormulations(
draftContent || '',
validationContext.evidenceContext,
)
// ---------------------------------------------------------------
// Combine results
// ---------------------------------------------------------------
const allFindings = [...deterministicFindings, ...forbiddenFindings, ...llmFindings]
const allFindings = [...deterministicFindings, ...llmFindings]
const errors = allFindings.filter(f => f.severity === 'error')
const warnings = allFindings.filter(f => f.severity === 'warning')
const suggestions = allFindings.filter(f => f.severity === 'suggestion')

View File

@@ -1,47 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}`)
const data = await resp.json()
return NextResponse.json(data)
} catch (err) {
return NextResponse.json({ error: 'Failed to fetch registration' }, { status: 500 })
}
}
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
const body = await request.json()
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
body: JSON.stringify(body),
})
const data = await resp.json()
return NextResponse.json(data, { status: resp.status })
} catch (err) {
return NextResponse.json({ error: 'Failed to update registration' }, { status: 500 })
}
}
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const body = await request.json()
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const data = await resp.json()
return NextResponse.json(data, { status: resp.status })
} catch (err) {
return NextResponse.json({ error: 'Failed to update status' }, { status: 500 })
}
}

View File

@@ -1,32 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
export async function GET(request: NextRequest) {
try {
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration`, {
headers: { 'X-Tenant-ID': tenantId },
})
const data = await resp.json()
return NextResponse.json(data)
} catch (err) {
return NextResponse.json({ error: 'Failed to fetch registrations' }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
const body = await request.json()
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
body: JSON.stringify(body),
})
const data = await resp.json()
return NextResponse.json(data, { status: resp.status })
} catch (err) {
return NextResponse.json({ error: 'Failed to create registration' }, { status: 500 })
}
}

View File

@@ -25,44 +25,16 @@ export async function GET(request: NextRequest) {
break
case 'controls': {
const controlParams = new URLSearchParams()
const passthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates', 'sort', 'order', 'limit', 'offset']
for (const key of passthrough) {
const val = searchParams.get(key)
if (val) controlParams.set(key, val)
}
const qs = controlParams.toString()
const severity = searchParams.get('severity')
const domain = searchParams.get('domain')
const params = new URLSearchParams()
if (severity) params.set('severity', severity)
if (domain) params.set('domain', domain)
const qs = params.toString()
backendPath = `/api/compliance/v1/canonical/controls${qs ? `?${qs}` : ''}`
break
}
case 'controls-count': {
const countParams = new URLSearchParams()
const countPassthrough = ['severity', 'domain', 'release_state', 'verification_method', 'category', 'evidence_type',
'target_audience', 'source', 'search', 'control_type', 'exclude_duplicates']
for (const key of countPassthrough) {
const val = searchParams.get(key)
if (val) countParams.set(key, val)
}
const countQs = countParams.toString()
backendPath = `/api/compliance/v1/canonical/controls-count${countQs ? `?${countQs}` : ''}`
break
}
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')
if (!controlId) {
@@ -104,63 +76,10 @@ export async function GET(request: NextRequest) {
backendPath = '/api/compliance/v1/canonical/generate/processed-stats'
break
case 'categories':
backendPath = '/api/compliance/v1/canonical/categories'
break
case 'traceability': {
const traceId = searchParams.get('id')
if (!traceId) {
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
}
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(traceId)}/traceability`
break
}
case 'provenance': {
const provId = searchParams.get('id')
if (!provId) {
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
}
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(provId)}/provenance`
break
}
case 'atomic-stats':
backendPath = '/api/compliance/v1/canonical/controls/atomic-stats'
break
case 'similar': {
const simControlId = searchParams.get('id')
if (!simControlId) {
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
}
const simThreshold = searchParams.get('threshold') || '0.85'
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(simControlId)}/similar?threshold=${simThreshold}`
break
}
case 'blocked-sources':
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')
@@ -223,20 +142,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
}
backendPath = `/api/compliance/v1/canonical/generate/review/${encodeURIComponent(controlId)}`
} else if (endpoint === 'bulk-review') {
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

@@ -1,129 +0,0 @@
/**
* Evidence Checks API Proxy - Catch-all route
* Proxies all /api/sdk/v1/compliance/evidence-checks/* requests to backend-compliance
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${BACKEND_URL}/api/compliance/evidence-checks`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
'X-Tenant-Id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
'X-User-Id': 'admin',
}
const authHeader = request.headers.get('authorization')
if (authHeader) {
headers['Authorization'] = authHeader
}
const tenantHeader = request.headers.get('x-tenant-id')
if (tenantHeader) {
headers['X-Tenant-Id'] = tenantHeader
}
const userIdHeader = request.headers.get('x-user-id')
if (userIdHeader) {
headers['X-User-Id'] = userIdHeader
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
if (['POST', 'PUT', 'PATCH'].includes(method)) {
const contentType = request.headers.get('content-type')
if (contentType?.includes('application/json')) {
try {
const text = await request.text()
if (text && text.trim()) {
fetchOptions.body = text
}
} catch {
// Empty or invalid body
}
}
}
const response = await fetch(url, fetchOptions)
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Evidence Checks API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PATCH')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -1,129 +0,0 @@
/**
* Process Tasks API Proxy - Catch-all route
* Proxies all /api/sdk/v1/compliance/process-tasks/* requests to backend-compliance
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string
) {
const pathStr = pathSegments?.join('/') || ''
const searchParams = request.nextUrl.searchParams.toString()
const basePath = `${BACKEND_URL}/api/compliance/process-tasks`
const url = pathStr
? `${basePath}/${pathStr}${searchParams ? `?${searchParams}` : ''}`
: `${basePath}${searchParams ? `?${searchParams}` : ''}`
try {
const headers: HeadersInit = {
'Content-Type': 'application/json',
'X-Tenant-Id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
'X-User-Id': 'admin',
}
const authHeader = request.headers.get('authorization')
if (authHeader) {
headers['Authorization'] = authHeader
}
const tenantHeader = request.headers.get('x-tenant-id')
if (tenantHeader) {
headers['X-Tenant-Id'] = tenantHeader
}
const userIdHeader = request.headers.get('x-user-id')
if (userIdHeader) {
headers['X-User-Id'] = userIdHeader
}
const fetchOptions: RequestInit = {
method,
headers,
signal: AbortSignal.timeout(30000),
}
if (['POST', 'PUT', 'PATCH'].includes(method)) {
const contentType = request.headers.get('content-type')
if (contentType?.includes('application/json')) {
try {
const text = await request.text()
if (text && text.trim()) {
fetchOptions.body = text
}
} catch {
// Empty or invalid body
}
}
}
const response = await fetch(url, fetchOptions)
if (!response.ok) {
const errorText = await response.text()
let errorJson
try {
errorJson = JSON.parse(errorText)
} catch {
errorJson = { error: errorText }
}
return NextResponse.json(
{ error: `Backend Error: ${response.status}`, ...errorJson },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Process Tasks API proxy error:', error)
return NextResponse.json(
{ error: 'Verbindung zum Backend fehlgeschlagen' },
{ status: 503 }
)
}
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'GET')
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'POST')
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PUT')
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'PATCH')
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ path?: string[] }> }
) {
const { path } = await params
return proxyRequest(request, path, 'DELETE')
}

View File

@@ -1,48 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const endpoint = searchParams.get('endpoint') || 'controls'
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
let path: string
switch (endpoint) {
case 'controls':
const domain = searchParams.get('domain') || ''
path = `/sdk/v1/payment-compliance/controls${domain ? `?domain=${domain}` : ''}`
break
case 'assessments':
path = '/sdk/v1/payment-compliance/assessments'
break
default:
path = '/sdk/v1/payment-compliance/controls'
}
const resp = await fetch(`${SDK_URL}${path}`, {
headers: { 'X-Tenant-ID': tenantId },
})
const data = await resp.json()
return NextResponse.json(data)
} catch (err) {
return NextResponse.json({ error: 'Failed to fetch' }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
const body = await request.json()
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/assessments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
body: JSON.stringify(body),
})
const data = await resp.json()
return NextResponse.json(data, { status: resp.status })
} catch (err) {
return NextResponse.json({ error: 'Failed to create' }, { status: 500 })
}
}

View File

@@ -1,28 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender/${id}`)
return NextResponse.json(await resp.json())
} catch {
return NextResponse.json({ error: 'Failed' }, { status: 500 })
}
}
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params
const { searchParams } = new URL(request.url)
const action = searchParams.get('action') || 'extract'
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender/${id}/${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
return NextResponse.json(await resp.json(), { status: resp.status })
} catch {
return NextResponse.json({ error: 'Failed' }, { status: 500 })
}
}

View File

@@ -1,30 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
export async function GET(request: NextRequest) {
try {
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender`, {
headers: { 'X-Tenant-ID': tenantId },
})
return NextResponse.json(await resp.json())
} catch {
return NextResponse.json({ error: 'Failed' }, { status: 500 })
}
}
export async function POST(request: NextRequest) {
try {
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
const formData = await request.formData()
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender/upload`, {
method: 'POST',
headers: { 'X-Tenant-ID': tenantId },
body: formData,
})
return NextResponse.json(await resp.json(), { status: resp.status })
} catch {
return NextResponse.json({ error: 'Upload failed' }, { status: 500 })
}
}

View File

@@ -53,18 +53,7 @@ async function proxyRequest(
}
}
const response = await fetch(url, {
...fetchOptions,
redirect: 'manual',
})
// Handle redirects (e.g. media stream presigned URL)
if (response.status === 307 || response.status === 302) {
const location = response.headers.get('location')
if (location) {
return NextResponse.redirect(location)
}
}
const response = await fetch(url, fetchOptions)
if (!response.ok) {
const errorText = await response.text()
@@ -80,19 +69,6 @@ async function proxyRequest(
)
}
// Handle binary responses (PDF, octet-stream)
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/pdf') || contentType.includes('application/octet-stream')) {
const buffer = await response.arrayBuffer()
return new NextResponse(buffer, {
status: response.status,
headers: {
'Content-Type': contentType,
'Content-Disposition': response.headers.get('content-disposition') || '',
},
})
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {

View File

@@ -1,57 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
/**
* Proxy: /api/sdk/v1/ucca/decision-tree/... → Go Backend /sdk/v1/ucca/decision-tree/...
*/
async function proxyRequest(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params
const subPath = path ? path.join('/') : ''
const search = request.nextUrl.search || ''
const targetUrl = `${SDK_URL}/sdk/v1/ucca/decision-tree/${subPath}${search}`
const tenantID = request.headers.get('X-Tenant-ID') || DEFAULT_TENANT
try {
const headers: Record<string, string> = {
'X-Tenant-ID': tenantID,
}
const fetchOptions: RequestInit = {
method: request.method,
headers,
}
if (request.method === 'POST' || request.method === 'PUT' || request.method === 'PATCH') {
const body = await request.json()
headers['Content-Type'] = 'application/json'
fetchOptions.body = JSON.stringify(body)
}
const response = await fetch(targetUrl, fetchOptions)
if (!response.ok) {
const errorText = await response.text()
console.error(`Decision tree proxy error [${request.method} ${subPath}]:`, errorText)
return NextResponse.json(
{ error: 'Backend error', details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data, { status: response.status })
} catch (error) {
console.error('Decision tree proxy connection error:', error)
return NextResponse.json(
{ error: 'Failed to connect to AI compliance backend' },
{ status: 503 }
)
}
}
export const GET = proxyRequest
export const POST = proxyRequest
export const DELETE = proxyRequest

View File

@@ -1,36 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
/**
* Proxy: GET /api/sdk/v1/ucca/decision-tree → Go Backend GET /sdk/v1/ucca/decision-tree
* Returns the decision tree definition (questions, structure)
*/
export async function GET(request: NextRequest) {
const tenantID = request.headers.get('X-Tenant-ID') || DEFAULT_TENANT
try {
const response = await fetch(`${SDK_URL}/sdk/v1/ucca/decision-tree`, {
headers: { 'X-Tenant-ID': tenantID },
})
if (!response.ok) {
const errorText = await response.text()
console.error('Decision tree GET error:', errorText)
return NextResponse.json(
{ error: 'Backend error', details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Decision tree proxy error:', error)
return NextResponse.json(
{ error: 'Failed to connect to AI compliance backend' },
{ status: 503 }
)
}
}

View File

@@ -333,71 +333,6 @@ function AdvisoryBoardPageInner() {
purposes: [] as string[],
// Automation (single-select tile)
automation: '' as string,
// BetrVG / works council
employee_monitoring: false,
hr_decision_support: false,
works_council_consulted: false,
// Domain-specific contexts (Annex III)
hr_automated_screening: false,
hr_automated_rejection: false,
hr_candidate_ranking: false,
hr_bias_audits: false,
hr_agg_visible: false,
hr_human_review: false,
hr_performance_eval: false,
edu_grade_influence: false,
edu_exam_evaluation: false,
edu_student_selection: false,
edu_minors: false,
edu_teacher_review: false,
hc_diagnosis: false,
hc_treatment: false,
hc_triage: false,
hc_patient_data: false,
hc_medical_device: false,
hc_clinical_validation: false,
// Legal
leg_legal_advice: false, leg_court_prediction: false, leg_client_confidential: false,
// Public Sector
pub_admin_decision: false, pub_benefit_allocation: false, pub_transparency: false,
// Critical Infrastructure
crit_grid_control: false, crit_safety_critical: false, crit_redundancy: false,
// Automotive
auto_autonomous: false, auto_safety: false, auto_functional_safety: false,
// Retail
ret_pricing: false, ret_profiling: false, ret_credit_scoring: false, ret_dark_patterns: false,
// IT Security
its_surveillance: false, its_threat_detection: false, its_data_retention: false,
// Logistics
log_driver_tracking: false, log_workload_scoring: false,
// Construction
con_tenant_screening: false, con_worker_safety: false,
// Marketing
mkt_deepfake: false, mkt_minors: false, mkt_targeting: false, mkt_labeled: false,
// Manufacturing
mfg_machine_safety: false, mfg_ce_required: false, mfg_validated: false,
// Agriculture
agr_pesticide: false, agr_animal_welfare: false, agr_environmental: false,
// Social Services
soc_vulnerable: false, soc_benefit: false, soc_case_mgmt: false,
// Hospitality
hos_guest_profiling: false, hos_dynamic_pricing: false, hos_review_manipulation: false,
// Insurance
ins_risk_class: false, ins_claims: false, ins_premium: false, ins_fraud: false,
// Investment
inv_algo_trading: false, inv_advice: false, inv_robo: false,
// Defense
def_dual_use: false, def_export: false, def_classified: false,
// Supply Chain
sch_supplier: false, sch_human_rights: false, sch_environmental: false,
// Facility
fac_access: false, fac_occupancy: false, fac_energy: false,
// Sports
spo_athlete: false, spo_fan: false, spo_doping: false,
// Finance / Banking
fin_credit_scoring: false, fin_aml_kyc: false, fin_algo_decisions: false, fin_customer_profiling: false,
// General
gen_affects_people: false, gen_automated_decisions: false, gen_sensitive_data: false,
// Hosting (single-select tile)
hosting_provider: '' as string,
hosting_region: '' as string,
@@ -485,131 +420,7 @@ function AdvisoryBoardPageInner() {
retention_purpose: form.retention_purpose,
contracts_list: form.contracts,
subprocessors: form.subprocessors,
employee_monitoring: form.employee_monitoring,
hr_decision_support: form.hr_decision_support,
works_council_consulted: form.works_council_consulted,
// Domain-specific contexts
hr_context: ['hr', 'recruiting'].includes(form.domain) ? {
automated_screening: form.hr_automated_screening,
automated_rejection: form.hr_automated_rejection,
candidate_ranking: form.hr_candidate_ranking,
bias_audits_done: form.hr_bias_audits,
agg_categories_visible: form.hr_agg_visible,
human_review_enforced: form.hr_human_review,
performance_evaluation: form.hr_performance_eval,
} : undefined,
education_context: ['education', 'higher_education', 'vocational_training', 'research'].includes(form.domain) ? {
grade_influence: form.edu_grade_influence,
exam_evaluation: form.edu_exam_evaluation,
student_selection: form.edu_student_selection,
minors_involved: form.edu_minors,
teacher_review_required: form.edu_teacher_review,
} : undefined,
healthcare_context: ['healthcare', 'medical_devices', 'pharma', 'elderly_care'].includes(form.domain) ? {
diagnosis_support: form.hc_diagnosis,
treatment_recommendation: form.hc_treatment,
triage_decision: form.hc_triage,
patient_data_processed: form.hc_patient_data,
medical_device: form.hc_medical_device,
clinical_validation: form.hc_clinical_validation,
} : undefined,
legal_context: ['legal', 'consulting', 'tax_advisory'].includes(form.domain) ? {
legal_advice: form.leg_legal_advice,
court_prediction: form.leg_court_prediction,
client_confidential: form.leg_client_confidential,
} : undefined,
public_sector_context: ['public_sector', 'defense', 'justice'].includes(form.domain) ? {
admin_decision: form.pub_admin_decision,
benefit_allocation: form.pub_benefit_allocation,
transparency_ensured: form.pub_transparency,
} : undefined,
critical_infra_context: ['energy', 'utilities', 'oil_gas'].includes(form.domain) ? {
grid_control: form.crit_grid_control,
safety_critical: form.crit_safety_critical,
redundancy_exists: form.crit_redundancy,
} : undefined,
automotive_context: ['automotive', 'aerospace'].includes(form.domain) ? {
autonomous_driving: form.auto_autonomous,
safety_relevant: form.auto_safety,
functional_safety: form.auto_functional_safety,
} : undefined,
retail_context: ['retail', 'ecommerce', 'wholesale'].includes(form.domain) ? {
pricing_personalized: form.ret_pricing,
credit_scoring: form.ret_credit_scoring,
dark_patterns: form.ret_dark_patterns,
} : undefined,
it_security_context: ['it_services', 'cybersecurity', 'telecom'].includes(form.domain) ? {
employee_surveillance: form.its_surveillance,
threat_detection: form.its_threat_detection,
data_retention_logs: form.its_data_retention,
} : undefined,
logistics_context: ['logistics'].includes(form.domain) ? {
driver_tracking: form.log_driver_tracking,
workload_scoring: form.log_workload_scoring,
} : undefined,
construction_context: ['construction', 'real_estate', 'facility_management'].includes(form.domain) ? {
tenant_screening: form.con_tenant_screening,
worker_safety: form.con_worker_safety,
} : undefined,
marketing_context: ['marketing', 'media', 'entertainment'].includes(form.domain) ? {
deepfake_content: form.mkt_deepfake,
behavioral_targeting: form.mkt_targeting,
minors_targeted: form.mkt_minors,
ai_content_labeled: form.mkt_labeled,
} : undefined,
manufacturing_context: ['mechanical_engineering', 'electrical_engineering', 'plant_engineering', 'chemicals', 'food_beverage'].includes(form.domain) ? {
machine_safety: form.mfg_machine_safety,
ce_marking_required: form.mfg_ce_required,
safety_validated: form.mfg_validated,
} : undefined,
agriculture_context: ['agriculture', 'forestry', 'fishing'].includes(form.domain) ? {
pesticide_ai: form.agr_pesticide,
animal_welfare: form.agr_animal_welfare,
environmental_data: form.agr_environmental,
} : undefined,
social_services_context: ['social_services', 'nonprofit'].includes(form.domain) ? {
vulnerable_groups: form.soc_vulnerable,
benefit_decision: form.soc_benefit,
case_management: form.soc_case_mgmt,
} : undefined,
hospitality_context: ['hospitality', 'tourism'].includes(form.domain) ? {
guest_profiling: form.hos_guest_profiling,
dynamic_pricing: form.hos_dynamic_pricing,
review_manipulation: form.hos_review_manipulation,
} : undefined,
insurance_context: ['insurance'].includes(form.domain) ? {
risk_classification: form.ins_risk_class,
claims_automation: form.ins_claims,
premium_calculation: form.ins_premium,
fraud_detection: form.ins_fraud,
} : undefined,
investment_context: ['investment'].includes(form.domain) ? {
algo_trading: form.inv_algo_trading,
investment_advice: form.inv_advice,
robo_advisor: form.inv_robo,
} : undefined,
defense_context: ['defense'].includes(form.domain) ? {
dual_use: form.def_dual_use,
export_controlled: form.def_export,
classified_data: form.def_classified,
} : undefined,
supply_chain_context: ['textiles', 'packaging'].includes(form.domain) ? {
supplier_monitoring: form.sch_supplier,
human_rights_check: form.sch_human_rights,
environmental_impact: form.sch_environmental,
} : undefined,
facility_context: ['facility_management'].includes(form.domain) ? {
access_control_ai: form.fac_access,
occupancy_tracking: form.fac_occupancy,
energy_optimization: form.fac_energy,
} : undefined,
sports_context: ['sports'].includes(form.domain) ? {
athlete_tracking: form.spo_athlete,
fan_profiling: form.spo_fan,
} : undefined,
store_raw_text: true,
// Finance/Banking and General don't need separate context structs —
// their fields are evaluated via existing FinancialContext or generic rules
}
const url = isEditMode
@@ -966,567 +777,6 @@ function AdvisoryBoardPageInner() {
Informationspflicht, Recht auf menschliche Ueberpruefung und Anfechtungsmoeglichkeit.
</p>
</div>
{/* BetrVG Section */}
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Betriebsrat & Beschaeftigtendaten</h3>
<p className="text-xs text-gray-500 mb-4">
Relevant fuer deutsche Unternehmen mit Betriebsrat (§87 Abs.1 Nr.6 BetrVG).
</p>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input
type="checkbox"
checked={form.employee_monitoring}
onChange={(e) => updateForm({ employee_monitoring: e.target.checked })}
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<div>
<span className="text-sm font-medium text-gray-900">System kann Verhalten/Leistung ueberwachen</span>
<p className="text-xs text-gray-500">Nutzungslogs, Produktivitaetskennzahlen, Kommunikationsanalyse</p>
</div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input
type="checkbox"
checked={form.hr_decision_support}
onChange={(e) => updateForm({ hr_decision_support: e.target.checked })}
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<div>
<span className="text-sm font-medium text-gray-900">System unterstuetzt HR-Entscheidungen</span>
<p className="text-xs text-gray-500">Recruiting, Bewertung, Befoerderung, Kuendigung</p>
</div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input
type="checkbox"
checked={form.works_council_consulted}
onChange={(e) => updateForm({ works_council_consulted: e.target.checked })}
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<div>
<span className="text-sm font-medium text-gray-900">Betriebsrat wurde konsultiert</span>
<p className="text-xs text-gray-500">Betriebsvereinbarung liegt vor oder ist in Verhandlung</p>
</div>
</label>
</div>
</div>
{/* Domain-specific questions — HR/Recruiting */}
{['hr', 'recruiting'].includes(form.domain) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">HR & Recruiting Hochrisiko-Pruefung</h3>
<p className="text-xs text-gray-500 mb-4">AI Act Annex III Nr. 4 + AGG Pflichtfragen bei KI im Personalbereich.</p>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.hr_automated_screening} onChange={(e) => updateForm({ hr_automated_screening: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Bewerber werden automatisch vorsortiert/gerankt</span><p className="text-xs text-gray-500">CV-Screening, Score-basierte Vorauswahl</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
<input type="checkbox" checked={form.hr_automated_rejection} onChange={(e) => updateForm({ hr_automated_rejection: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
<div><span className="text-sm font-medium text-red-900">Absagen werden automatisch versendet</span><p className="text-xs text-red-700">Art. 22 DSGVO: Vollautomatische Absagen grundsaetzlich unzulaessig!</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.hr_agg_visible} onChange={(e) => updateForm({ hr_agg_visible: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">System kann AGG-Merkmale erkennen (Name, Foto, Alter)</span><p className="text-xs text-gray-500">Proxy-Diskriminierung: NameHerkunft, FotoGeschlecht</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.hr_performance_eval} onChange={(e) => updateForm({ hr_performance_eval: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">System bewertet Mitarbeiterleistung</span><p className="text-xs text-gray-500">Performance Reviews, KPI-Tracking</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
<input type="checkbox" checked={form.hr_bias_audits} onChange={(e) => updateForm({ hr_bias_audits: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
<div><span className="text-sm font-medium text-green-900">Regelmaessige Bias-Audits durchgefuehrt</span><p className="text-xs text-green-700">Analyse nach Geschlecht, Alter, Herkunft</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
<input type="checkbox" checked={form.hr_human_review} onChange={(e) => updateForm({ hr_human_review: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
<div><span className="text-sm font-medium text-green-900">Mensch prueft jede KI-Empfehlung</span><p className="text-xs text-green-700">Kein Rubber Stamping echte Pruefung</p></div>
</label>
</div>
</div>
)}
{/* Domain-specific questions — Education */}
{['education', 'higher_education', 'vocational_training', 'research'].includes(form.domain) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Bildung Hochrisiko-Pruefung</h3>
<p className="text-xs text-gray-500 mb-4">AI Act Annex III Nr. 3 bei KI in Bildung und Ausbildung.</p>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.edu_grade_influence} onChange={(e) => updateForm({ edu_grade_influence: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">KI beeinflusst Noten oder Bewertungen</span><p className="text-xs text-gray-500">Notenvorschlaege, Bewertungsunterstuetzung</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.edu_exam_evaluation} onChange={(e) => updateForm({ edu_exam_evaluation: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">KI bewertet Pruefungen/Klausuren</span><p className="text-xs text-gray-500">Automatische Korrektur, Bewertungsvorschlaege</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.edu_student_selection} onChange={(e) => updateForm({ edu_student_selection: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">KI beeinflusst Zugang zu Bildungsangeboten</span><p className="text-xs text-gray-500">Zulassung, Kursempfehlungen, Einstufung</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
<input type="checkbox" checked={form.edu_minors} onChange={(e) => updateForm({ edu_minors: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
<div><span className="text-sm font-medium text-red-900">Minderjaehrige sind betroffen</span><p className="text-xs text-red-700">Besonderer Schutz (Art. 24 EU-Grundrechtecharta)</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
<input type="checkbox" checked={form.edu_teacher_review} onChange={(e) => updateForm({ edu_teacher_review: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
<div><span className="text-sm font-medium text-green-900">Lehrkraft prueft jedes KI-Ergebnis</span><p className="text-xs text-green-700">Human Oversight vor Mitteilung an Schueler</p></div>
</label>
</div>
</div>
)}
{/* Domain-specific questions — Healthcare */}
{['healthcare', 'medical_devices', 'pharma', 'elderly_care'].includes(form.domain) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Gesundheitswesen Hochrisiko-Pruefung</h3>
<p className="text-xs text-gray-500 mb-4">AI Act Annex III Nr. 5 + MDR (EU) 2017/745.</p>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.hc_diagnosis} onChange={(e) => updateForm({ hc_diagnosis: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">KI unterstuetzt Diagnosen</span><p className="text-xs text-gray-500">Diagnosevorschlaege, Bildgebungsauswertung</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.hc_treatment} onChange={(e) => updateForm({ hc_treatment: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">KI empfiehlt Behandlungen</span><p className="text-xs text-gray-500">Therapievorschlaege, Medikation</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
<input type="checkbox" checked={form.hc_triage} onChange={(e) => updateForm({ hc_triage: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
<div><span className="text-sm font-medium text-red-900">KI priorisiert Patienten (Triage)</span><p className="text-xs text-red-700">Lebenskritisch erhoehte Anforderungen</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.hc_patient_data} onChange={(e) => updateForm({ hc_patient_data: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Gesundheitsdaten verarbeitet</span><p className="text-xs text-gray-500">Art. 9 DSGVO besondere Kategorie</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.hc_medical_device} onChange={(e) => updateForm({ hc_medical_device: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">System ist Medizinprodukt (MDR)</span><p className="text-xs text-gray-500">MDR (EU) 2017/745 Zertifizierung erforderlich</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
<input type="checkbox" checked={form.hc_clinical_validation} onChange={(e) => updateForm({ hc_clinical_validation: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
<div><span className="text-sm font-medium text-green-900">Klinisch validiert</span><p className="text-xs text-green-700">System wurde in klinischer Studie geprueft</p></div>
</label>
</div>
</div>
)}
{/* Legal / Justice */}
{['legal', 'consulting', 'tax_advisory'].includes(form.domain) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Recht & Beratung Compliance-Fragen</h3>
<p className="text-xs text-gray-500 mb-4">AI Act Annex III Nr. 8 KI in Rechtspflege und Demokratie.</p>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.leg_legal_advice} onChange={(e) => updateForm({ leg_legal_advice: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">KI gibt Rechtsberatung oder rechtliche Empfehlungen</span><p className="text-xs text-gray-500">Vertragsanalyse, rechtliche Einschaetzungen, Compliance-Checks</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.leg_court_prediction} onChange={(e) => updateForm({ leg_court_prediction: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">KI prognostiziert Verfahrensausgaenge</span><p className="text-xs text-gray-500">Urteilsprognosen, Risikoeinschaetzung von Rechtsstreitigkeiten</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.leg_client_confidential} onChange={(e) => updateForm({ leg_client_confidential: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Mandantengeheimnis betroffen</span><p className="text-xs text-gray-500">Vertrauliche Mandantendaten werden durch KI verarbeitet (§ 203 StGB)</p></div>
</label>
</div>
</div>
)}
{/* Public Sector */}
{['public_sector', 'defense', 'justice'].includes(form.domain) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Oeffentlicher Sektor Compliance-Fragen</h3>
<p className="text-xs text-gray-500 mb-4">Art. 27 AI Act FRIA-Pflicht fuer oeffentliche Stellen.</p>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
<input type="checkbox" checked={form.pub_admin_decision} onChange={(e) => updateForm({ pub_admin_decision: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
<div><span className="text-sm font-medium text-red-900">KI beeinflusst Verwaltungsentscheidungen</span><p className="text-xs text-red-700">Bescheide, Bewilligungen, Genehmigungen FRIA erforderlich</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.pub_benefit_allocation} onChange={(e) => updateForm({ pub_benefit_allocation: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">KI verteilt Leistungen oder Foerderung</span><p className="text-xs text-gray-500">Sozialleistungen, Subventionen, Zuteilungen</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
<input type="checkbox" checked={form.pub_transparency} onChange={(e) => updateForm({ pub_transparency: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
<div><span className="text-sm font-medium text-green-900">Transparenz gegenueber Buergern sichergestellt</span><p className="text-xs text-green-700">Buerger werden ueber KI-Nutzung informiert</p></div>
</label>
</div>
</div>
)}
{/* Critical Infrastructure */}
{['energy', 'utilities', 'oil_gas'].includes(form.domain) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Kritische Infrastruktur Compliance-Fragen</h3>
<p className="text-xs text-gray-500 mb-4">AI Act Annex III Nr. 2 + NIS2.</p>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.crit_grid_control} onChange={(e) => updateForm({ crit_grid_control: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">KI steuert Netz oder Infrastruktur</span><p className="text-xs text-gray-500">Stromnetz, Wasserversorgung, Gasverteilung</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
<input type="checkbox" checked={form.crit_safety_critical} onChange={(e) => updateForm({ crit_safety_critical: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
<div><span className="text-sm font-medium text-red-900">Sicherheitskritische Steuerung</span><p className="text-xs text-red-700">Fehler koennen Menschenleben gefaehrden</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
<input type="checkbox" checked={form.crit_redundancy} onChange={(e) => updateForm({ crit_redundancy: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
<div><span className="text-sm font-medium text-green-900">Redundante Systeme vorhanden</span><p className="text-xs text-green-700">Fallback bei KI-Ausfall sichergestellt</p></div>
</label>
</div>
</div>
)}
{/* Automotive / Aerospace */}
{['automotive', 'aerospace'].includes(form.domain) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Automotive / Aerospace Compliance-Fragen</h3>
<p className="text-xs text-gray-500 mb-4">Safety-critical AI Typgenehmigung + Functional Safety.</p>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
<input type="checkbox" checked={form.auto_autonomous} onChange={(e) => updateForm({ auto_autonomous: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
<div><span className="text-sm font-medium text-red-900">Autonomes Fahren / ADAS</span><p className="text-xs text-red-700">Hochrisiko erfordert Typgenehmigung und extensive Validierung</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.auto_safety} onChange={(e) => updateForm({ auto_safety: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Sicherheitsrelevante Funktion</span><p className="text-xs text-gray-500">Bremsen, Lenkung, Kollisionsvermeidung</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
<input type="checkbox" checked={form.auto_functional_safety} onChange={(e) => updateForm({ auto_functional_safety: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
<div><span className="text-sm font-medium text-green-900">ISO 26262 Functional Safety beruecksichtigt</span><p className="text-xs text-green-700">ASIL-Einstufung und Sicherheitsvalidierung durchgefuehrt</p></div>
</label>
</div>
</div>
)}
{/* Retail / E-Commerce */}
{['retail', 'ecommerce', 'wholesale'].includes(form.domain) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Handel & E-Commerce Compliance-Fragen</h3>
<p className="text-xs text-gray-500 mb-4">DSA, Verbraucherrecht, DSGVO Art. 22.</p>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.ret_pricing} onChange={(e) => updateForm({ ret_pricing: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Personalisierte Preisgestaltung</span><p className="text-xs text-gray-500">Individuelle Preise basierend auf Nutzerprofil</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.ret_credit_scoring} onChange={(e) => updateForm({ ret_credit_scoring: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Bonitaetspruefung bei Kauf auf Rechnung</span><p className="text-xs text-gray-500">Kredit-Scoring beeinflusst Zugang zu Zahlungsarten</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.ret_dark_patterns} onChange={(e) => updateForm({ ret_dark_patterns: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Manipulative UI-Muster moeglich (Dark Patterns)</span><p className="text-xs text-gray-500">Kuenstliche Verknappung, Social Proof, versteckte Kosten</p></div>
</label>
</div>
</div>
)}
{/* IT / Cybersecurity / Telecom */}
{['it_services', 'cybersecurity', 'telecom'].includes(form.domain) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">IT & Cybersecurity Compliance-Fragen</h3>
<p className="text-xs text-gray-500 mb-4">NIS2, DSGVO, BetrVG §87.</p>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.its_surveillance} onChange={(e) => updateForm({ its_surveillance: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Mitarbeiterueberwachung (SIEM, DLP, UBA)</span><p className="text-xs text-gray-500">User Behavior Analytics, Data Loss Prevention mit Personenbezug</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.its_threat_detection} onChange={(e) => updateForm({ its_threat_detection: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">KI-gestuetzte Bedrohungserkennung</span><p className="text-xs text-gray-500">Anomalie-Erkennung, Intrusion Detection</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.its_data_retention} onChange={(e) => updateForm({ its_data_retention: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Umfangreiche Log-Speicherung</span><p className="text-xs text-gray-500">Security-Logs mit Personenbezug werden langfristig gespeichert</p></div>
</label>
</div>
</div>
)}
{/* Logistics */}
{['logistics'].includes(form.domain) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Logistik Compliance-Fragen</h3>
<p className="text-xs text-gray-500 mb-4">BetrVG §87, DSGVO Worker Tracking.</p>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.log_driver_tracking} onChange={(e) => updateForm({ log_driver_tracking: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Fahrer-/Kurier-Tracking (GPS)</span><p className="text-xs text-gray-500">Standortverfolgung von Mitarbeitern</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.log_workload_scoring} onChange={(e) => updateForm({ log_workload_scoring: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Leistungsbewertung von Lager-/Liefermitarbeitern</span><p className="text-xs text-gray-500">Picks/Stunde, Liefergeschwindigkeit, Performance-Scores</p></div>
</label>
</div>
</div>
)}
{/* Construction / Real Estate */}
{['construction', 'real_estate', 'facility_management'].includes(form.domain) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Bau & Immobilien Compliance-Fragen</h3>
<p className="text-xs text-gray-500 mb-4">AGG, DSGVO, Arbeitsschutz.</p>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.con_tenant_screening} onChange={(e) => updateForm({ con_tenant_screening: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">KI-gestuetzte Mieterauswahl</span><p className="text-xs text-gray-500">Bonitaetspruefung, Bewerber-Ranking fuer Wohnungen</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.con_worker_safety} onChange={(e) => updateForm({ con_worker_safety: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">KI-Arbeitsschutzueberwachung auf Baustellen</span><p className="text-xs text-gray-500">Kamera-basierte Sicherheitsueberwachung, Helm-Erkennung</p></div>
</label>
</div>
</div>
)}
{/* Marketing / Media */}
{['marketing', 'media', 'entertainment'].includes(form.domain) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Marketing & Medien Compliance-Fragen</h3>
<p className="text-xs text-gray-500 mb-4">Art. 50 AI Act (Deepfakes), DSA, DSGVO.</p>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
<input type="checkbox" checked={form.mkt_deepfake} onChange={(e) => updateForm({ mkt_deepfake: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
<div><span className="text-sm font-medium text-red-900">Synthetische Inhalte (Deepfakes)</span><p className="text-xs text-red-700">KI-generierte Bilder, Videos oder Stimmen Kennzeichnungspflicht!</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.mkt_targeting} onChange={(e) => updateForm({ mkt_targeting: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Verhaltensbasiertes Targeting</span><p className="text-xs text-gray-500">Personalisierte Werbung basierend auf Nutzerverhalten</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
<input type="checkbox" checked={form.mkt_minors} onChange={(e) => updateForm({ mkt_minors: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
<div><span className="text-sm font-medium text-red-900">Minderjaehrige als Zielgruppe</span><p className="text-xs text-red-700">Besonderer Schutz DSA Art. 28 verbietet Profiling Minderjaehriger</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
<input type="checkbox" checked={form.mkt_labeled} onChange={(e) => updateForm({ mkt_labeled: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
<div><span className="text-sm font-medium text-green-900">KI-Inhalte werden als solche gekennzeichnet</span><p className="text-xs text-green-700">Art. 50 AI Act: Pflicht zur Kennzeichnung synthetischer Inhalte</p></div>
</label>
</div>
</div>
)}
{/* Manufacturing */}
{['mechanical_engineering', 'electrical_engineering', 'plant_engineering', 'chemicals', 'food_beverage', 'textiles', 'packaging'].includes(form.domain) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Fertigung Compliance-Fragen</h3>
<p className="text-xs text-gray-500 mb-4">Maschinenverordnung (EU) 2023/1230, CE-Kennzeichnung.</p>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
<input type="checkbox" checked={form.mfg_machine_safety} onChange={(e) => updateForm({ mfg_machine_safety: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
<div><span className="text-sm font-medium text-red-900">KI in Maschinensicherheit</span><p className="text-xs text-red-700">Sicherheitsrelevante Steuerung Validierung erforderlich</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.mfg_ce_required} onChange={(e) => updateForm({ mfg_ce_required: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">CE-Kennzeichnung erforderlich</span><p className="text-xs text-gray-500">Maschinenverordnung (EU) 2023/1230</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-green-200 bg-green-50 hover:bg-green-100 cursor-pointer">
<input type="checkbox" checked={form.mfg_validated} onChange={(e) => updateForm({ mfg_validated: e.target.checked })} className="w-4 h-4 rounded border-green-300 text-green-600 focus:ring-green-500" />
<div><span className="text-sm font-medium text-green-900">Sicherheitsvalidierung durchgefuehrt</span><p className="text-xs text-green-700">Konformitaetsbewertung nach Maschinenverordnung abgeschlossen</p></div>
</label>
</div>
</div>
)}
{/* Agriculture */}
{['agriculture', 'forestry', 'fishing'].includes(form.domain) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Landwirtschaft Compliance-Fragen</h3>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.agr_pesticide} onChange={(e) => updateForm({ agr_pesticide: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">KI steuert Pestizideinsatz</span><p className="text-xs text-gray-500">Precision Farming, automatisierte Ausbringung</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.agr_animal_welfare} onChange={(e) => updateForm({ agr_animal_welfare: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">KI beeinflusst Tierhaltungsentscheidungen</span><p className="text-xs text-gray-500">Fuetterung, Gesundheit, Stallmanagement</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.agr_environmental} onChange={(e) => updateForm({ agr_environmental: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Umweltdaten werden verarbeitet</span><p className="text-xs text-gray-500">Boden, Wasser, Emissionen</p></div>
</label>
</div>
</div>
)}
{/* Social Services */}
{['social_services', 'nonprofit'].includes(form.domain) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Soziales Compliance-Fragen</h3>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
<input type="checkbox" checked={form.soc_vulnerable} onChange={(e) => updateForm({ soc_vulnerable: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
<div><span className="text-sm font-medium text-red-900">Schutzbeduerftiger Personenkreis betroffen</span><p className="text-xs text-red-700">Kinder, Senioren, Gefluechtete, Menschen mit Behinderung</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.soc_benefit} onChange={(e) => updateForm({ soc_benefit: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">KI beeinflusst Leistungszuteilung</span><p className="text-xs text-gray-500">Sozialleistungen, Hilfsangebote, Foerderung</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.soc_case_mgmt} onChange={(e) => updateForm({ soc_case_mgmt: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">KI in Fallmanagement</span><p className="text-xs text-gray-500">Priorisierung, Zuordnung, Verlaufsprognose</p></div>
</label>
</div>
</div>
)}
{/* Hospitality / Tourism */}
{['hospitality', 'tourism'].includes(form.domain) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Tourismus & Gastronomie Compliance-Fragen</h3>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.hos_guest_profiling} onChange={(e) => updateForm({ hos_guest_profiling: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Gaeste-Profilbildung</span><p className="text-xs text-gray-500">Praeferenzen, Buchungsverhalten, Segmentierung</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.hos_dynamic_pricing} onChange={(e) => updateForm({ hos_dynamic_pricing: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Dynamische Preisgestaltung</span><p className="text-xs text-gray-500">Personalisierte Zimmer-/Flugreise</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
<input type="checkbox" checked={form.hos_review_manipulation} onChange={(e) => updateForm({ hos_review_manipulation: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
<div><span className="text-sm font-medium text-red-900">KI manipuliert oder generiert Bewertungen</span><p className="text-xs text-red-700">Fake Reviews sind unzulaessig (UWG, DSA)</p></div>
</label>
</div>
</div>
)}
{/* Insurance */}
{['insurance'].includes(form.domain) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Versicherung Compliance-Fragen</h3>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.ins_premium} onChange={(e) => updateForm({ ins_premium: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">KI berechnet individuelle Praemien</span><p className="text-xs text-gray-500">Risikoadjustierte Preisgestaltung</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.ins_claims} onChange={(e) => updateForm({ ins_claims: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Automatisierte Schadenbearbeitung</span><p className="text-xs text-gray-500">KI entscheidet ueber Schadenregulierung</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.ins_fraud} onChange={(e) => updateForm({ ins_fraud: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">KI-Betrugserkennung</span><p className="text-xs text-gray-500">Automatische Verdachtsfallerkennung</p></div>
</label>
</div>
</div>
)}
{/* Investment */}
{['investment'].includes(form.domain) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Investment Compliance-Fragen</h3>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.inv_algo_trading} onChange={(e) => updateForm({ inv_algo_trading: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Algorithmischer Handel</span><p className="text-xs text-gray-500">Automated Trading, HFT MiFID II relevant</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.inv_robo} onChange={(e) => updateForm({ inv_robo: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Robo Advisor / KI-Anlageberatung</span><p className="text-xs text-gray-500">Automatisierte Vermoegensberatung WpHG-Pflichten</p></div>
</label>
</div>
</div>
)}
{/* Defense */}
{['defense'].includes(form.domain) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Verteidigung Compliance-Fragen</h3>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 cursor-pointer">
<input type="checkbox" checked={form.def_dual_use} onChange={(e) => updateForm({ def_dual_use: e.target.checked })} className="w-4 h-4 rounded border-red-300 text-red-600 focus:ring-red-500" />
<div><span className="text-sm font-medium text-red-900">Dual-Use KI-Technologie</span><p className="text-xs text-red-700">Exportkontrolle (EU VO 2021/821) beachten</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.def_classified} onChange={(e) => updateForm({ def_classified: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Verschlusssachen werden verarbeitet</span><p className="text-xs text-gray-500">VS-NfD oder hoeher besondere Schutzmassnahmen</p></div>
</label>
</div>
</div>
)}
{/* Supply Chain (Textiles, Packaging) */}
{['textiles', 'packaging'].includes(form.domain) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Lieferkette Compliance-Fragen</h3>
<p className="text-xs text-gray-500 mb-4">LkSG Lieferkettensorgfaltspflichtengesetz.</p>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.sch_supplier} onChange={(e) => updateForm({ sch_supplier: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">KI ueberwacht Lieferanten</span><p className="text-xs text-gray-500">Lieferantenbewertung, Risikoanalyse</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.sch_human_rights} onChange={(e) => updateForm({ sch_human_rights: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">KI prueft Menschenrechte in Lieferkette</span><p className="text-xs text-gray-500">LkSG-Sorgfaltspflichten, Kinderarbeit, Zwangsarbeit</p></div>
</label>
</div>
</div>
)}
{/* Sports */}
{['sports'].includes(form.domain) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Sport Compliance-Fragen</h3>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.spo_athlete} onChange={(e) => updateForm({ spo_athlete: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Athleten-Performance-Tracking</span><p className="text-xs text-gray-500">GPS, Biometrie, Leistungsdaten</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.spo_fan} onChange={(e) => updateForm({ spo_fan: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Fan-/Zuschauer-Profilbildung</span><p className="text-xs text-gray-500">Ticketing, Merchandising, Stadion-Tracking</p></div>
</label>
</div>
</div>
)}
{/* Finance / Banking */}
{['finance', 'banking'].includes(form.domain) && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Finanzdienstleistungen Compliance-Fragen</h3>
<p className="text-xs text-gray-500 mb-4">DORA, MaRisk, BAIT, AI Act Annex III Nr. 5.</p>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.fin_credit_scoring} onChange={(e) => updateForm({ fin_credit_scoring: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">KI-gestuetztes Kredit-Scoring</span><p className="text-xs text-gray-500">Bonitaetsbewertung, Kreditwuerdigkeitspruefung Art. 22 DSGVO + AGG</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.fin_aml_kyc} onChange={(e) => updateForm({ fin_aml_kyc: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">AML/KYC Automatisierung</span><p className="text-xs text-gray-500">Geldwaeschebekacmpfung, Kundenidentifizierung durch KI</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.fin_algo_decisions} onChange={(e) => updateForm({ fin_algo_decisions: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Automatisierte Finanzentscheidungen</span><p className="text-xs text-gray-500">Kreditvergabe, Kontosperrung, Limitaenderungen</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.fin_customer_profiling} onChange={(e) => updateForm({ fin_customer_profiling: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Kunden-Profilbildung / Segmentierung</span><p className="text-xs text-gray-500">Risikoklassifikation, Produkt-Empfehlungen</p></div>
</label>
</div>
</div>
)}
{/* General — universal AI governance questions */}
{form.domain === 'general' && (
<div className="mt-6 pt-6 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-900 mb-1">Allgemeine KI-Governance</h3>
<p className="text-xs text-gray-500 mb-4">Grundlegende Compliance-Fragen fuer jeden KI-Einsatz.</p>
<div className="space-y-3">
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.gen_affects_people} onChange={(e) => updateForm({ gen_affects_people: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">System hat Auswirkungen auf natuerliche Personen</span><p className="text-xs text-gray-500">Entscheidungen, Empfehlungen oder Bewertungen betreffen Menschen direkt</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.gen_automated_decisions} onChange={(e) => updateForm({ gen_automated_decisions: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Automatisierte Entscheidungen werden getroffen</span><p className="text-xs text-gray-500">KI trifft oder beeinflusst Entscheidungen ohne menschliche Pruefung</p></div>
</label>
<label className="flex items-center gap-3 p-3 rounded-lg border border-gray-200 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.gen_sensitive_data} onChange={(e) => updateForm({ gen_sensitive_data: e.target.checked })} className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
<div><span className="text-sm font-medium text-gray-900">Sensible oder vertrauliche Daten verarbeitet</span><p className="text-xs text-gray-500">Geschaeftsgeheimnisse, personenbezogene Daten, vertrauliche Informationen</p></div>
</label>
</div>
</div>
)}
</div>
)}

View File

@@ -3,7 +3,6 @@
import React, { useState, useEffect } from 'react'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import DecisionTreeWizard from '@/components/sdk/ai-act/DecisionTreeWizard'
// =============================================================================
// TYPES
@@ -22,8 +21,6 @@ interface AISystem {
assessmentResult: Record<string, unknown> | null
}
type TabId = 'overview' | 'decision-tree' | 'results'
// =============================================================================
// LOADING SKELETON
// =============================================================================
@@ -309,178 +306,12 @@ function AISystemCard({
)
}
// =============================================================================
// SAVED RESULTS TAB
// =============================================================================
interface SavedResult {
id: string
system_name: string
system_description?: string
high_risk_result: string
gpai_result: { gpai_category: string; is_systemic_risk: boolean }
combined_obligations: string[]
created_at: string
}
function SavedResultsTab() {
const [results, setResults] = useState<SavedResult[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const load = async () => {
try {
const res = await fetch('/api/sdk/v1/ucca/decision-tree/results')
if (res.ok) {
const data = await res.json()
setResults(data.results || [])
}
} catch {
// Ignore
} finally {
setLoading(false)
}
}
load()
}, [])
const handleDelete = async (id: string) => {
if (!confirm('Ergebnis wirklich löschen?')) return
try {
const res = await fetch(`/api/sdk/v1/ucca/decision-tree/results/${id}`, { method: 'DELETE' })
if (res.ok) {
setResults(prev => prev.filter(r => r.id !== id))
}
} catch {
// Ignore
}
}
const riskLabels: Record<string, string> = {
unacceptable: 'Unzulässig',
high_risk: 'Hochrisiko',
limited_risk: 'Begrenztes Risiko',
minimal_risk: 'Minimales Risiko',
not_applicable: 'Nicht anwendbar',
}
const riskColors: Record<string, string> = {
unacceptable: 'bg-red-100 text-red-700',
high_risk: 'bg-orange-100 text-orange-700',
limited_risk: 'bg-yellow-100 text-yellow-700',
minimal_risk: 'bg-green-100 text-green-700',
not_applicable: 'bg-gray-100 text-gray-500',
}
const gpaiLabels: Record<string, string> = {
none: 'Kein GPAI',
standard: 'GPAI Standard',
systemic: 'GPAI Systemisch',
}
const gpaiColors: Record<string, string> = {
none: 'bg-gray-100 text-gray-500',
standard: 'bg-blue-100 text-blue-700',
systemic: 'bg-purple-100 text-purple-700',
}
if (loading) {
return <LoadingSkeleton />
}
if (results.length === 0) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-purple-600" 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 2" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine Ergebnisse vorhanden</h3>
<p className="mt-2 text-gray-500">Nutzen Sie den Entscheidungsbaum, um KI-Systeme zu klassifizieren.</p>
</div>
)
}
return (
<div className="space-y-4">
{results.map(r => (
<div key={r.id} className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-start justify-between">
<div>
<h4 className="font-semibold text-gray-900">{r.system_name}</h4>
{r.system_description && (
<p className="text-sm text-gray-500 mt-0.5">{r.system_description}</p>
)}
<div className="flex items-center gap-2 mt-2">
<span className={`px-2 py-1 text-xs rounded-full ${riskColors[r.high_risk_result] || 'bg-gray-100 text-gray-500'}`}>
{riskLabels[r.high_risk_result] || r.high_risk_result}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${gpaiColors[r.gpai_result?.gpai_category] || 'bg-gray-100 text-gray-500'}`}>
{gpaiLabels[r.gpai_result?.gpai_category] || 'Kein GPAI'}
</span>
{r.gpai_result?.is_systemic_risk && (
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">Systemisch</span>
)}
</div>
<div className="text-xs text-gray-400 mt-2">
{r.combined_obligations?.length || 0} Pflichten &middot; {new Date(r.created_at).toLocaleDateString('de-DE')}
</div>
</div>
<button
onClick={() => handleDelete(r.id)}
className="px-3 py-1 text-xs text-red-600 hover:bg-red-50 rounded transition-colors"
>
Löschen
</button>
</div>
</div>
))}
</div>
)
}
// =============================================================================
// TABS
// =============================================================================
const TABS: { id: TabId; label: string; icon: React.ReactNode }[] = [
{
id: 'overview',
label: 'Übersicht',
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg>
),
},
{
id: 'decision-tree',
label: 'Entscheidungsbaum',
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z" />
</svg>
),
},
{
id: 'results',
label: 'Ergebnisse',
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25z" />
</svg>
),
},
]
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function AIActPage() {
const { state } = useSDK()
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [systems, setSystems] = useState<AISystem[]>([])
const [filter, setFilter] = useState<string>('all')
const [showAddForm, setShowAddForm] = useState(false)
@@ -523,6 +354,7 @@ export default function AIActPage() {
const handleAddSystem = async (data: Omit<AISystem, 'id' | 'assessmentDate' | 'assessmentResult'>) => {
setError(null)
if (editingSystem) {
// Edit existing system via PUT
try {
const res = await fetch(`/api/sdk/v1/compliance/ai/systems/${editingSystem.id}`, {
method: 'PUT',
@@ -548,12 +380,14 @@ export default function AIActPage() {
setError('Speichern fehlgeschlagen')
}
} catch {
// Fallback: update locally
setSystems(prev => prev.map(s =>
s.id === editingSystem.id ? { ...s, ...data } : s
))
}
setEditingSystem(null)
} else {
// Create new system via POST
try {
const res = await fetch('/api/sdk/v1/compliance/ai/systems', {
method: 'POST',
@@ -581,6 +415,7 @@ export default function AIActPage() {
setError('Registrierung fehlgeschlagen')
}
} catch {
// Fallback: add locally
const newSystem: AISystem = {
...data,
id: `ai-${Date.now()}`,
@@ -668,37 +503,17 @@ export default function AIActPage() {
explanation={stepInfo.explanation}
tips={stepInfo.tips}
>
{activeTab === 'overview' && (
<button
onClick={() => setShowAddForm(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>
KI-System registrieren
</button>
)}
<button
onClick={() => setShowAddForm(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>
KI-System registrieren
</button>
</StepHeader>
{/* Tabs */}
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg w-fit">
{TABS.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === tab.id
? 'bg-white text-purple-700 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{/* Error Banner */}
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
@@ -707,105 +522,90 @@ export default function AIActPage() {
</div>
)}
{/* Tab: Overview */}
{activeTab === 'overview' && (
<>
{/* Add/Edit System Form */}
{showAddForm && (
<AddSystemForm
onSubmit={handleAddSystem}
onCancel={() => { setShowAddForm(false); setEditingSystem(null) }}
initialData={editingSystem}
{/* Add/Edit System Form */}
{showAddForm && (
<AddSystemForm
onSubmit={handleAddSystem}
onCancel={() => { setShowAddForm(false); setEditingSystem(null) }}
initialData={editingSystem}
/>
)}
{/* 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">KI-Systeme gesamt</div>
<div className="text-3xl font-bold text-gray-900">{systems.length}</div>
</div>
<div className="bg-white rounded-xl border border-orange-200 p-6">
<div className="text-sm text-orange-600">Hochrisiko</div>
<div className="text-3xl font-bold text-orange-600">{highRiskCount}</div>
</div>
<div className="bg-white rounded-xl border border-green-200 p-6">
<div className="text-sm text-green-600">Konform</div>
<div className="text-3xl font-bold text-green-600">{compliantCount}</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="text-sm text-gray-500">Nicht klassifiziert</div>
<div className="text-3xl font-bold text-gray-500">{unclassifiedCount}</div>
</div>
</div>
{/* Risk Pyramid */}
<RiskPyramid systems={systems} />
{/* Filter */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-gray-500">Filter:</span>
{['all', 'high-risk', 'limited-risk', 'minimal-risk', 'unclassified', 'compliant', 'non-compliant'].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 === 'high-risk' ? 'Hochrisiko' :
f === 'limited-risk' ? 'Begrenztes Risiko' :
f === 'minimal-risk' ? 'Minimales Risiko' :
f === 'unclassified' ? 'Nicht klassifiziert' :
f === 'compliant' ? 'Konform' : 'Nicht konform'}
</button>
))}
</div>
{/* Loading */}
{loading && <LoadingSkeleton />}
{/* AI Systems List */}
{!loading && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{filteredSystems.map(system => (
<AISystemCard
key={system.id}
system={system}
onAssess={() => handleAssess(system.id)}
onEdit={() => handleEdit(system)}
onDelete={() => handleDelete(system.id)}
assessing={assessingId === system.id}
/>
)}
{/* 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">KI-Systeme gesamt</div>
<div className="text-3xl font-bold text-gray-900">{systems.length}</div>
</div>
<div className="bg-white rounded-xl border border-orange-200 p-6">
<div className="text-sm text-orange-600">Hochrisiko</div>
<div className="text-3xl font-bold text-orange-600">{highRiskCount}</div>
</div>
<div className="bg-white rounded-xl border border-green-200 p-6">
<div className="text-sm text-green-600">Konform</div>
<div className="text-3xl font-bold text-green-600">{compliantCount}</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="text-sm text-gray-500">Nicht klassifiziert</div>
<div className="text-3xl font-bold text-gray-500">{unclassifiedCount}</div>
</div>
</div>
{/* Risk Pyramid */}
<RiskPyramid systems={systems} />
{/* Filter */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-gray-500">Filter:</span>
{['all', 'high-risk', 'limited-risk', 'minimal-risk', 'unclassified', 'compliant', 'non-compliant'].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 === 'high-risk' ? 'Hochrisiko' :
f === 'limited-risk' ? 'Begrenztes Risiko' :
f === 'minimal-risk' ? 'Minimales Risiko' :
f === 'unclassified' ? 'Nicht klassifiziert' :
f === 'compliant' ? 'Konform' : 'Nicht konform'}
</button>
))}
</div>
{/* Loading */}
{loading && <LoadingSkeleton />}
{/* AI Systems List */}
{!loading && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{filteredSystems.map(system => (
<AISystemCard
key={system.id}
system={system}
onAssess={() => handleAssess(system.id)}
onEdit={() => handleEdit(system)}
onDelete={() => handleDelete(system.id)}
assessing={assessingId === system.id}
/>
))}
</div>
)}
{!loading && filteredSystems.length === 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-purple-600" 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>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine KI-Systeme gefunden</h3>
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder registrieren Sie ein neues KI-System.</p>
</div>
)}
</>
))}
</div>
)}
{/* Tab: Decision Tree */}
{activeTab === 'decision-tree' && (
<DecisionTreeWizard />
)}
{/* Tab: Results */}
{activeTab === 'results' && (
<SavedResultsTab />
{!loading && filteredSystems.length === 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-purple-600" 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>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine KI-Systeme gefunden</h3>
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder registrieren Sie ein neues KI-System.</p>
</div>
)}
</div>
)

View File

@@ -1,491 +0,0 @@
'use client'
import React, { useState, useEffect } from 'react'
interface Registration {
id: string
system_name: string
system_version: string
risk_classification: string
gpai_classification: string
registration_status: string
eu_database_id: string
provider_name: string
created_at: string
}
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
draft: { bg: 'bg-gray-100', text: 'text-gray-700', label: 'Entwurf' },
ready: { bg: 'bg-blue-100', text: 'text-blue-700', label: 'Bereit' },
submitted: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Eingereicht' },
registered: { bg: 'bg-green-100', text: 'text-green-700', label: 'Registriert' },
update_required: { bg: 'bg-orange-100', text: 'text-orange-700', label: 'Update noetig' },
withdrawn: { bg: 'bg-red-100', text: 'text-red-700', label: 'Zurueckgezogen' },
}
const RISK_STYLES: Record<string, { bg: string; text: string }> = {
high_risk: { bg: 'bg-red-100', text: 'text-red-700' },
limited_risk: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
minimal_risk: { bg: 'bg-green-100', text: 'text-green-700' },
not_classified: { bg: 'bg-gray-100', text: 'text-gray-500' },
}
const INITIAL_FORM = {
system_name: '',
system_version: '1.0',
system_description: '',
intended_purpose: '',
provider_name: '',
provider_legal_form: '',
provider_address: '',
provider_country: 'DE',
eu_representative_name: '',
eu_representative_contact: '',
risk_classification: 'not_classified',
annex_iii_category: '',
gpai_classification: 'none',
conformity_assessment_type: 'internal',
notified_body_name: '',
notified_body_id: '',
ce_marking: false,
training_data_summary: '',
}
export default function AIRegistrationPage() {
const [registrations, setRegistrations] = useState<Registration[]>([])
const [loading, setLoading] = useState(true)
const [showWizard, setShowWizard] = useState(false)
const [wizardStep, setWizardStep] = useState(1)
const [form, setForm] = useState({ ...INITIAL_FORM })
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => { loadRegistrations() }, [])
async function loadRegistrations() {
try {
setLoading(true)
const resp = await fetch('/api/sdk/v1/ai-registration')
if (resp.ok) {
const data = await resp.json()
setRegistrations(data.registrations || [])
}
} catch {
setError('Fehler beim Laden')
} finally {
setLoading(false)
}
}
async function handleSubmit() {
setSubmitting(true)
try {
const resp = await fetch('/api/sdk/v1/ai-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
})
if (resp.ok) {
setShowWizard(false)
setForm({ ...INITIAL_FORM })
setWizardStep(1)
loadRegistrations()
} else {
const data = await resp.json()
setError(data.error || 'Fehler beim Erstellen')
}
} catch {
setError('Netzwerkfehler')
} finally {
setSubmitting(false)
}
}
async function handleExport(id: string) {
try {
const resp = await fetch(`/api/sdk/v1/ai-registration/${id}`)
if (resp.ok) {
const reg = await resp.json()
// Build export JSON client-side
const exportData = {
schema_version: '1.0',
submission_type: 'ai_system_registration',
regulation: 'EU AI Act (EU) 2024/1689',
article: 'Art. 49',
provider: { name: reg.provider_name, address: reg.provider_address, country: reg.provider_country },
system: { name: reg.system_name, version: reg.system_version, description: reg.system_description, purpose: reg.intended_purpose },
classification: { risk_level: reg.risk_classification, annex_iii: reg.annex_iii_category, gpai: reg.gpai_classification },
conformity: { type: reg.conformity_assessment_type, ce_marking: reg.ce_marking },
}
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `eu_ai_registration_${reg.system_name.replace(/\s+/g, '_')}.json`
a.click()
URL.revokeObjectURL(url)
}
} catch {
setError('Export fehlgeschlagen')
}
}
async function handleStatusChange(id: string, status: string) {
try {
await fetch(`/api/sdk/v1/ai-registration/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
})
loadRegistrations()
} catch {
setError('Status-Aenderung fehlgeschlagen')
}
}
const updateForm = (updates: Partial<typeof form>) => setForm(prev => ({ ...prev, ...updates }))
const STEPS = [
{ id: 1, title: 'Anbieter', desc: 'Unternehmensangaben' },
{ id: 2, title: 'System', desc: 'KI-System Details' },
{ id: 3, title: 'Klassifikation', desc: 'Risikoeinstufung' },
{ id: 4, title: 'Konformitaet', desc: 'CE & Notified Body' },
{ id: 5, title: 'Trainingsdaten', desc: 'Datenzusammenfassung' },
{ id: 6, title: 'Pruefung', desc: 'Zusammenfassung & Export' },
]
return (
<div className="max-w-5xl mx-auto p-6">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">EU AI Database Registrierung</h1>
<p className="text-sm text-gray-500 mt-1">Art. 49 KI-Verordnung (EU) 2024/1689 Registrierung von Hochrisiko-KI-Systemen</p>
</div>
<button
onClick={() => setShowWizard(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
+ Neue Registrierung
</button>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
<button onClick={() => setError(null)} className="ml-2 underline">Schliessen</button>
</div>
)}
{/* Stats */}
<div className="grid grid-cols-4 gap-4 mb-8">
{['draft', 'ready', 'submitted', 'registered'].map(status => {
const count = registrations.filter(r => r.registration_status === status).length
const style = STATUS_STYLES[status]
return (
<div key={status} className={`p-4 rounded-xl border ${style.bg}`}>
<div className={`text-2xl font-bold ${style.text}`}>{count}</div>
<div className="text-sm text-gray-600">{style.label}</div>
</div>
)
})}
</div>
{/* Registrations List */}
{loading ? (
<div className="text-center py-12 text-gray-500">Lade...</div>
) : registrations.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<p className="text-lg mb-2">Noch keine Registrierungen</p>
<p className="text-sm">Erstelle eine neue Registrierung fuer dein Hochrisiko-KI-System.</p>
</div>
) : (
<div className="space-y-4">
{registrations.map(reg => {
const status = STATUS_STYLES[reg.registration_status] || STATUS_STYLES.draft
const risk = RISK_STYLES[reg.risk_classification] || RISK_STYLES.not_classified
return (
<div key={reg.id} className="bg-white rounded-xl border border-gray-200 p-6 hover:border-purple-300 transition-all">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-semibold text-gray-900">{reg.system_name}</h3>
<span className="text-sm text-gray-400">v{reg.system_version}</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${status.bg} ${status.text}`}>{status.label}</span>
<span className={`px-2 py-0.5 text-xs rounded-full ${risk.bg} ${risk.text}`}>{reg.risk_classification.replace('_', ' ')}</span>
{reg.gpai_classification !== 'none' && (
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700">GPAI: {reg.gpai_classification}</span>
)}
</div>
<div className="text-sm text-gray-500">
{reg.provider_name && <span>{reg.provider_name} · </span>}
{reg.eu_database_id && <span>EU-ID: {reg.eu_database_id} · </span>}
<span>{new Date(reg.created_at).toLocaleDateString('de-DE')}</span>
</div>
</div>
<div className="flex gap-2">
<button onClick={() => handleExport(reg.id)} className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
JSON Export
</button>
{reg.registration_status === 'draft' && (
<button onClick={() => handleStatusChange(reg.id, 'ready')} className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">
Bereit markieren
</button>
)}
{reg.registration_status === 'ready' && (
<button onClick={() => handleStatusChange(reg.id, 'submitted')} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700">
Als eingereicht markieren
</button>
)}
</div>
</div>
</div>
)
})}
</div>
)}
{/* Wizard Modal */}
{showWizard && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-gray-900">Neue EU AI Registrierung</h2>
<button onClick={() => { setShowWizard(false); setWizardStep(1) }} className="text-gray-400 hover:text-gray-600 text-2xl">&times;</button>
</div>
{/* Step Indicator */}
<div className="flex gap-1">
{STEPS.map(step => (
<button key={step.id} onClick={() => setWizardStep(step.id)}
className={`flex-1 py-2 text-xs rounded-lg transition-all ${
wizardStep === step.id ? 'bg-purple-100 text-purple-700 font-medium' :
wizardStep > step.id ? 'bg-green-50 text-green-700' : 'bg-gray-50 text-gray-400'
}`}>
{wizardStep > step.id ? '✓ ' : ''}{step.title}
</button>
))}
</div>
</div>
<div className="p-6 space-y-4">
{/* Step 1: Provider */}
{wizardStep === 1 && (
<>
<h3 className="font-semibold text-gray-900">Anbieter-Informationen</h3>
<p className="text-sm text-gray-500">Angaben zum Anbieter des KI-Systems gemaess Art. 49 KI-VO.</p>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Firmenname *</label>
<input value={form.provider_name} onChange={e => updateForm({ provider_name: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Acme GmbH" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsform</label>
<input value={form.provider_legal_form} onChange={e => updateForm({ provider_legal_form: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="GmbH" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
<input value={form.provider_address} onChange={e => updateForm({ provider_address: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Musterstr. 1, 20095 Hamburg" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Land</label>
<select value={form.provider_country} onChange={e => updateForm({ provider_country: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
<option value="DE">Deutschland</option>
<option value="AT">Oesterreich</option>
<option value="CH">Schweiz</option>
<option value="OTHER">Anderes Land</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">EU-Repraesentant (falls Non-EU)</label>
<input value={form.eu_representative_name} onChange={e => updateForm({ eu_representative_name: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Optional" />
</div>
</div>
</>
)}
{/* Step 2: System */}
{wizardStep === 2 && (
<>
<h3 className="font-semibold text-gray-900">KI-System Details</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Systemname *</label>
<input value={form.system_name} onChange={e => updateForm({ system_name: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="z.B. HR Copilot" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Version</label>
<input value={form.system_version} onChange={e => updateForm({ system_version: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="1.0" />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Systembeschreibung</label>
<textarea value={form.system_description} onChange={e => updateForm({ system_description: e.target.value })} rows={3}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Beschreibe was das KI-System tut..." />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Einsatzzweck (Intended Purpose)</label>
<textarea value={form.intended_purpose} onChange={e => updateForm({ intended_purpose: e.target.value })} rows={2}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Wofuer wird das System eingesetzt?" />
</div>
</>
)}
{/* Step 3: Classification */}
{wizardStep === 3 && (
<>
<h3 className="font-semibold text-gray-900">Risiko-Klassifikation</h3>
<p className="text-sm text-gray-500">Basierend auf dem AI Act Decision Tree oder manueller Einstufung.</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Risikoklasse</label>
<select value={form.risk_classification} onChange={e => updateForm({ risk_classification: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
<option value="not_classified">Noch nicht klassifiziert</option>
<option value="minimal_risk">Minimal Risk</option>
<option value="limited_risk">Limited Risk</option>
<option value="high_risk">High Risk</option>
</select>
</div>
{form.risk_classification === 'high_risk' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Annex III Kategorie</label>
<select value={form.annex_iii_category} onChange={e => updateForm({ annex_iii_category: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
<option value="">Bitte waehlen...</option>
<option value="biometric">1. Biometrische Identifizierung</option>
<option value="critical_infrastructure">2. Kritische Infrastruktur</option>
<option value="education">3. Bildung und Berufsausbildung</option>
<option value="employment">4. Beschaeftigung und Arbeitnehmerverwaltung</option>
<option value="essential_services">5. Zugang zu wesentlichen Diensten</option>
<option value="law_enforcement">6. Strafverfolgung</option>
<option value="migration">7. Migration und Grenzkontrolle</option>
<option value="justice">8. Rechtspflege und Demokratie</option>
</select>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">GPAI Klassifikation</label>
<select value={form.gpai_classification} onChange={e => updateForm({ gpai_classification: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
<option value="none">Kein GPAI</option>
<option value="standard">GPAI (Standard)</option>
<option value="systemic">GPAI mit systemischem Risiko</option>
</select>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
<strong>Tipp:</strong> Nutze den <a href="/sdk/ai-act" className="underline">AI Act Decision Tree</a> fuer eine strukturierte Klassifikation.
</div>
</>
)}
{/* Step 4: Conformity */}
{wizardStep === 4 && (
<>
<h3 className="font-semibold text-gray-900">Konformitaetsbewertung</h3>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Art der Konformitaetsbewertung</label>
<select value={form.conformity_assessment_type} onChange={e => updateForm({ conformity_assessment_type: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
<option value="not_required">Nicht erforderlich</option>
<option value="internal">Interne Konformitaetsbewertung</option>
<option value="third_party">Drittpartei-Bewertung (Notified Body)</option>
</select>
</div>
{form.conformity_assessment_type === 'third_party' && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notified Body Name</label>
<input value={form.notified_body_name} onChange={e => updateForm({ notified_body_name: e.target.value })}
className="w-full px-3 py-2 border 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">Notified Body ID</label>
<input value={form.notified_body_id} onChange={e => updateForm({ notified_body_id: e.target.value })}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
</div>
)}
<label className="flex items-center gap-3 p-3 rounded-lg border hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={form.ce_marking} onChange={e => updateForm({ ce_marking: e.target.checked })}
className="w-4 h-4 rounded border-gray-300 text-purple-600" />
<span className="text-sm font-medium text-gray-900">CE-Kennzeichnung angebracht</span>
</label>
</>
)}
{/* Step 5: Training Data */}
{wizardStep === 5 && (
<>
<h3 className="font-semibold text-gray-900">Trainingsdaten-Zusammenfassung</h3>
<p className="text-sm text-gray-500">Art. 10 KI-VO Keine vollstaendige Offenlegung, sondern Kategorien und Herkunft.</p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Zusammenfassung der Trainingsdaten</label>
<textarea value={form.training_data_summary} onChange={e => updateForm({ training_data_summary: e.target.value })} rows={5}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="Beschreibe die verwendeten Datenquellen:&#10;- Oeffentliche Daten (z.B. Wikipedia, Common Crawl)&#10;- Lizenzierte Daten (z.B. Fachpublikationen)&#10;- Synthetische Daten&#10;- Unternehmensinterne Daten" />
</div>
</>
)}
{/* Step 6: Review */}
{wizardStep === 6 && (
<>
<h3 className="font-semibold text-gray-900">Zusammenfassung</h3>
<div className="space-y-3 text-sm">
<div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg">
<div><span className="text-gray-500">Anbieter:</span> <strong>{form.provider_name || ''}</strong></div>
<div><span className="text-gray-500">Land:</span> <strong>{form.provider_country}</strong></div>
<div><span className="text-gray-500">System:</span> <strong>{form.system_name || ''}</strong></div>
<div><span className="text-gray-500">Version:</span> <strong>{form.system_version}</strong></div>
<div><span className="text-gray-500">Risiko:</span> <strong>{form.risk_classification}</strong></div>
<div><span className="text-gray-500">GPAI:</span> <strong>{form.gpai_classification}</strong></div>
<div><span className="text-gray-500">Konformitaet:</span> <strong>{form.conformity_assessment_type}</strong></div>
<div><span className="text-gray-500">CE:</span> <strong>{form.ce_marking ? 'Ja' : 'Nein'}</strong></div>
</div>
{form.intended_purpose && (
<div className="p-4 bg-gray-50 rounded-lg">
<span className="text-gray-500">Zweck:</span> {form.intended_purpose}
</div>
)}
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
<strong>Hinweis:</strong> Die EU AI Datenbank befindet sich noch im Aufbau. Die Registrierung wird lokal gespeichert und kann spaeter uebermittelt werden.
</div>
</>
)}
</div>
{/* Navigation */}
<div className="p-6 border-t flex justify-between">
<button onClick={() => wizardStep > 1 ? setWizardStep(wizardStep - 1) : setShowWizard(false)}
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50">
{wizardStep === 1 ? 'Abbrechen' : 'Zurueck'}
</button>
{wizardStep < 6 ? (
<button onClick={() => setWizardStep(wizardStep + 1)}
disabled={wizardStep === 2 && !form.system_name}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
Weiter
</button>
) : (
<button onClick={handleSubmit} disabled={submitting || !form.system_name}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
{submitting ? 'Speichere...' : 'Registrierung erstellen'}
</button>
)}
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -160,8 +160,6 @@ export const ARCH_SERVICES: ArchService[] = [
'security_backlog', 'quality_entries',
'notfallplan_incidents', 'notfallplan_templates',
'data_processing_agreement',
'vendor_vendors', 'vendor_contracts', 'vendor_findings',
'vendor_control_instances', 'compliance_templates',
'compliance_isms_scope', 'compliance_isms_context', 'compliance_isms_policy',
'compliance_security_objectives', 'compliance_soa',
'compliance_audit_findings', 'compliance_corrective_actions',
@@ -180,10 +178,6 @@ export const ARCH_SERVICES: ArchService[] = [
'CRUD /api/compliance/vvt',
'CRUD /api/compliance/loeschfristen',
'CRUD /api/compliance/obligations',
'CRUD /api/sdk/v1/vendor-compliance/vendors',
'CRUD /api/sdk/v1/vendor-compliance/contracts',
'CRUD /api/sdk/v1/vendor-compliance/findings',
'CRUD /api/sdk/v1/vendor-compliance/control-instances',
'CRUD /api/isms/scope',
'CRUD /api/isms/policies',
'CRUD /api/isms/objectives',

View File

@@ -1,468 +0,0 @@
'use client'
import React, { useState, useEffect } from 'react'
// =============================================================================
// TYPES
// =============================================================================
interface Assertion {
id: string
tenant_id: string | null
entity_type: string
entity_id: string
sentence_text: string
sentence_index: number
assertion_type: string // 'assertion' | 'fact' | 'rationale'
evidence_ids: string[]
confidence: number
normative_tier: string | null // 'pflicht' | 'empfehlung' | 'kann'
verified_by: string | null
verified_at: string | null
created_at: string | null
updated_at: string | null
}
interface AssertionSummary {
total_assertions: number
total_facts: number
total_rationale: number
unverified_count: number
}
// =============================================================================
// CONSTANTS
// =============================================================================
const TIER_COLORS: Record<string, string> = {
pflicht: 'bg-red-100 text-red-700',
empfehlung: 'bg-yellow-100 text-yellow-700',
kann: 'bg-blue-100 text-blue-700',
}
const TIER_LABELS: Record<string, string> = {
pflicht: 'Pflicht',
empfehlung: 'Empfehlung',
kann: 'Kann',
}
const TYPE_COLORS: Record<string, string> = {
assertion: 'bg-orange-100 text-orange-700',
fact: 'bg-green-100 text-green-700',
rationale: 'bg-purple-100 text-purple-700',
}
const TYPE_LABELS: Record<string, string> = {
assertion: 'Behauptung',
fact: 'Fakt',
rationale: 'Begruendung',
}
const API_BASE = '/api/sdk/v1/compliance'
type TabKey = 'overview' | 'list' | 'extract'
// =============================================================================
// ASSERTION CARD
// =============================================================================
function AssertionCard({
assertion,
onVerify,
}: {
assertion: Assertion
onVerify: (id: string) => void
}) {
const tierColor = assertion.normative_tier ? TIER_COLORS[assertion.normative_tier] || 'bg-gray-100 text-gray-600' : 'bg-gray-100 text-gray-600'
const tierLabel = assertion.normative_tier ? TIER_LABELS[assertion.normative_tier] || assertion.normative_tier : '—'
const typeColor = TYPE_COLORS[assertion.assertion_type] || 'bg-gray-100 text-gray-600'
const typeLabel = TYPE_LABELS[assertion.assertion_type] || assertion.assertion_type
return (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-0.5 text-xs rounded font-medium ${tierColor}`}>
{tierLabel}
</span>
<span className={`px-2 py-0.5 text-xs rounded ${typeColor}`}>
{typeLabel}
</span>
{assertion.entity_type && (
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-500 rounded">
{assertion.entity_type}: {assertion.entity_id?.slice(0, 8) || '—'}
</span>
)}
{assertion.confidence > 0 && (
<span className="text-xs text-gray-400">
Konfidenz: {(assertion.confidence * 100).toFixed(0)}%
</span>
)}
</div>
<p className="text-sm text-gray-900 leading-relaxed">
&ldquo;{assertion.sentence_text}&rdquo;
</p>
<div className="mt-2 flex items-center gap-3 text-xs text-gray-400">
{assertion.verified_by && (
<span className="text-green-600">
Verifiziert von {assertion.verified_by} am {assertion.verified_at ? new Date(assertion.verified_at).toLocaleDateString('de-DE') : '—'}
</span>
)}
{assertion.evidence_ids.length > 0 && (
<span>
{assertion.evidence_ids.length} Evidence verknuepft
</span>
)}
</div>
</div>
<div className="flex flex-col gap-1">
{assertion.assertion_type !== 'fact' && (
<button
onClick={() => onVerify(assertion.id)}
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors whitespace-nowrap"
>
Als Fakt pruefen
</button>
)}
</div>
</div>
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function AssertionsPage() {
const [activeTab, setActiveTab] = useState<TabKey>('overview')
const [summary, setSummary] = useState<AssertionSummary | null>(null)
const [assertions, setAssertions] = useState<Assertion[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Filters
const [filterEntityType, setFilterEntityType] = useState('')
const [filterAssertionType, setFilterAssertionType] = useState('')
// Extract tab
const [extractText, setExtractText] = useState('')
const [extractEntityType, setExtractEntityType] = useState('control')
const [extractEntityId, setExtractEntityId] = useState('')
const [extracting, setExtracting] = useState(false)
const [extractedAssertions, setExtractedAssertions] = useState<Assertion[]>([])
// Verify dialog
const [verifyingId, setVerifyingId] = useState<string | null>(null)
const [verifyEmail, setVerifyEmail] = useState('')
useEffect(() => {
loadSummary()
}, [])
useEffect(() => {
if (activeTab === 'list') loadAssertions()
}, [activeTab, filterEntityType, filterAssertionType]) // eslint-disable-line react-hooks/exhaustive-deps
const loadSummary = async () => {
try {
const res = await fetch(`${API_BASE}/assertions/summary`)
if (res.ok) setSummary(await res.json())
} catch { /* silent */ }
finally { setLoading(false) }
}
const loadAssertions = async () => {
setLoading(true)
try {
const params = new URLSearchParams()
if (filterEntityType) params.set('entity_type', filterEntityType)
if (filterAssertionType) params.set('assertion_type', filterAssertionType)
params.set('limit', '200')
const res = await fetch(`${API_BASE}/assertions?${params}`)
if (res.ok) {
const data = await res.json()
setAssertions(data.assertions || [])
}
} catch {
setError('Assertions konnten nicht geladen werden')
} finally {
setLoading(false)
}
}
const handleExtract = async () => {
if (!extractText.trim()) { setError('Bitte Text eingeben'); return }
setExtracting(true)
setError(null)
setExtractedAssertions([])
try {
const res = await fetch(`${API_BASE}/assertions/extract`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: extractText,
entity_type: extractEntityType || 'control',
entity_id: extractEntityId || undefined,
}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Extraktion fehlgeschlagen' }))
throw new Error(typeof err.detail === 'string' ? err.detail : JSON.stringify(err.detail))
}
const data = await res.json()
setExtractedAssertions(data.assertions || [])
// Refresh summary
loadSummary()
} catch (err) {
setError(err instanceof Error ? err.message : 'Extraktion fehlgeschlagen')
} finally {
setExtracting(false)
}
}
const handleVerify = async (assertionId: string) => {
setVerifyingId(assertionId)
}
const submitVerify = async () => {
if (!verifyingId || !verifyEmail.trim()) return
try {
const res = await fetch(`${API_BASE}/assertions/${verifyingId}/verify?verified_by=${encodeURIComponent(verifyEmail)}`, {
method: 'POST',
})
if (res.ok) {
setVerifyingId(null)
setVerifyEmail('')
loadAssertions()
loadSummary()
} else {
const err = await res.json().catch(() => ({ detail: 'Verifizierung fehlgeschlagen' }))
setError(typeof err.detail === 'string' ? err.detail : 'Verifizierung fehlgeschlagen')
}
} catch {
setError('Netzwerkfehler')
}
}
const tabs: { key: TabKey; label: string }[] = [
{ key: 'overview', label: 'Uebersicht' },
{ key: 'list', label: 'Assertion-Liste' },
{ key: 'extract', label: 'Extraktion' },
]
return (
<div className="space-y-6">
{/* Header */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h1 className="text-2xl font-bold text-slate-900">Assertions</h1>
<p className="text-slate-500 mt-1">
Behauptungen vs. Fakten in Compliance-Texten trennen und verifizieren.
</p>
</div>
{/* Tabs */}
<div className="bg-white rounded-xl shadow-sm border">
<div className="flex border-b">
{tabs.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-6 py-3 text-sm font-medium transition-colors ${
activeTab === tab.key
? 'text-purple-600 border-b-2 border-purple-600'
: 'text-slate-500 hover:text-slate-700'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Error */}
{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>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">&times;</button>
</div>
)}
{/* ============================================================ */}
{/* TAB: Uebersicht */}
{/* ============================================================ */}
{activeTab === 'overview' && (
<>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : summary ? (
<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 Assertions</div>
<div className="text-3xl font-bold text-gray-900">{summary.total_assertions}</div>
</div>
<div className="bg-white rounded-xl border border-green-200 p-6">
<div className="text-sm text-green-600">Verifizierte Fakten</div>
<div className="text-3xl font-bold text-green-600">{summary.total_facts}</div>
</div>
<div className="bg-white rounded-xl border border-purple-200 p-6">
<div className="text-sm text-purple-600">Begruendungen</div>
<div className="text-3xl font-bold text-purple-600">{summary.total_rationale}</div>
</div>
<div className="bg-white rounded-xl border border-orange-200 p-6">
<div className="text-sm text-orange-600">Unverifizizt</div>
<div className="text-3xl font-bold text-orange-600">{summary.unverified_count}</div>
</div>
</div>
) : (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<p className="text-gray-500">Keine Assertions vorhanden. Nutzen Sie die Extraktion, um Behauptungen aus Texten zu identifizieren.</p>
</div>
)}
</>
)}
{/* ============================================================ */}
{/* TAB: Assertion-Liste */}
{/* ============================================================ */}
{activeTab === 'list' && (
<>
{/* Filters */}
<div className="flex items-center gap-4 flex-wrap">
<div>
<label className="block text-xs text-gray-500 mb-1">Entity-Typ</label>
<select value={filterEntityType} onChange={e => setFilterEntityType(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
<option value="">Alle</option>
<option value="control">Control</option>
<option value="evidence">Evidence</option>
<option value="requirement">Requirement</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Assertion-Typ</label>
<select value={filterAssertionType} onChange={e => setFilterAssertionType(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
<option value="">Alle</option>
<option value="assertion">Behauptung</option>
<option value="fact">Fakt</option>
<option value="rationale">Begruendung</option>
</select>
</div>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
</div>
) : assertions.length === 0 ? (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<p className="text-gray-500">Keine Assertions gefunden.</p>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-gray-500">{assertions.length} Assertions</p>
{assertions.map(a => (
<AssertionCard key={a.id} assertion={a} onVerify={handleVerify} />
))}
</div>
)}
</>
)}
{/* ============================================================ */}
{/* TAB: Extraktion */}
{/* ============================================================ */}
{activeTab === 'extract' && (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Assertions aus Text extrahieren</h3>
<p className="text-sm text-gray-500 mb-4">
Geben Sie einen Compliance-Text ein. Das System identifiziert automatisch Behauptungen, Fakten und Begruendungen.
</p>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Entity-Typ</label>
<select value={extractEntityType} onChange={e => setExtractEntityType(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent">
<option value="control">Control</option>
<option value="evidence">Evidence</option>
<option value="requirement">Requirement</option>
<option value="policy">Policy</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Entity-ID (optional)</label>
<input type="text" value={extractEntityId} onChange={e => setExtractEntityId(e.target.value)}
placeholder="z.B. GOV-001 oder UUID"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Text</label>
<textarea
value={extractText}
onChange={e => setExtractText(e.target.value)}
placeholder="Die Organisation muss ein ISMS gemaess ISO 27001 implementieren. Es sollte regelmaessig ein internes Audit durchgefuehrt werden. Optional kann ein externer Auditor hinzugezogen werden."
rows={6}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-none"
/>
</div>
<button
onClick={handleExtract}
disabled={extracting || !extractText.trim()}
className={`px-5 py-2 rounded-lg font-medium transition-colors ${
extracting || !extractText.trim()
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-purple-600 text-white hover:bg-purple-700'
}`}
>
{extracting ? 'Extrahiere...' : 'Extrahieren'}
</button>
{/* Extracted results */}
{extractedAssertions.length > 0 && (
<div className="mt-6">
<h4 className="text-sm font-semibold text-gray-800 mb-3">{extractedAssertions.length} Assertions extrahiert:</h4>
<div className="space-y-3">
{extractedAssertions.map(a => (
<AssertionCard key={a.id} assertion={a} onVerify={handleVerify} />
))}
</div>
</div>
)}
</div>
)}
{/* Verify Dialog */}
{verifyingId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setVerifyingId(null)}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-bold text-gray-900 mb-4">Als Fakt verifizieren</h2>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Verifiziert von (E-Mail)</label>
<input type="email" value={verifyEmail} onChange={e => setVerifyEmail(e.target.value)}
placeholder="auditor@unternehmen.de"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
</div>
<div className="flex justify-end gap-3">
<button onClick={() => setVerifyingId(null)} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
<button onClick={submitVerify} disabled={!verifyEmail.trim()}
className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50">
Verifizieren
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,413 +0,0 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import {
Atom, Search, ChevronRight, ChevronLeft, Filter,
BarChart3, ChevronsLeft, ChevronsRight, ArrowUpDown,
Clock, RefreshCw,
} from 'lucide-react'
import {
CanonicalControl, BACKEND_URL,
SeverityBadge, StateBadge, CategoryBadge, TargetAudienceBadge,
GenerationStrategyBadge, ObligationTypeBadge, RegulationCountBadge,
CATEGORY_OPTIONS,
} from '../control-library/components/helpers'
import { ControlDetail } from '../control-library/components/ControlDetail'
// =============================================================================
// TYPES
// =============================================================================
interface AtomicStats {
total_active: number
total_duplicate: number
by_domain: Array<{ domain: string; count: number }>
by_regulation: Array<{ regulation: string; count: number }>
avg_regulation_coverage: number
}
// =============================================================================
// ATOMIC CONTROLS PAGE
// =============================================================================
const PAGE_SIZE = 50
export default function AtomicControlsPage() {
const [controls, setControls] = useState<CanonicalControl[]>([])
const [totalCount, setTotalCount] = useState(0)
const [stats, setStats] = useState<AtomicStats | 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 [categoryFilter, setCategoryFilter] = useState<string>('')
const [sortBy, setSortBy] = useState<'id' | 'newest' | 'oldest'>('id')
// Pagination
const [currentPage, setCurrentPage] = useState(1)
// Mode
const [mode, setMode] = useState<'list' | 'detail'>('list')
// 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])
// Build query params
const buildParams = useCallback((extra?: Record<string, string>) => {
const p = new URLSearchParams()
p.set('control_type', 'atomic')
// Exclude duplicates — show only active masters
if (!extra?.release_state) {
// Don't filter by state for count queries that already have it
}
if (severityFilter) p.set('severity', severityFilter)
if (domainFilter) p.set('domain', domainFilter)
if (categoryFilter) p.set('category', categoryFilter)
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, categoryFilter, debouncedSearch])
// Load stats
const loadStats = useCallback(async () => {
try {
const res = await fetch(`${BACKEND_URL}?endpoint=atomic-stats`)
if (res.ok) setStats(await res.json())
} catch { /* ignore */ }
}, [])
// Load controls page
const loadControls = useCallback(async () => {
try {
setLoading(true)
const sortField = sortBy === 'id' ? 'control_id' : 'created_at'
const sortOrder = sortBy === 'newest' ? 'desc' : '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}`),
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`),
])
if (ctrlRes.ok) setControls(await ctrlRes.json())
if (countRes.ok) {
const data = await countRes.json()
setTotalCount(data.total || 0)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [buildParams, sortBy, currentPage])
// Initial load
useEffect(() => { loadStats() }, [loadStats])
useEffect(() => { loadControls() }, [loadControls])
useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, categoryFilter, debouncedSearch, sortBy])
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE))
// Loading
if (loading && controls.length === 0) {
return (
<div className="flex items-center justify-center h-96">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-violet-600 border-t-transparent" />
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center h-96">
<p className="text-red-600">{error}</p>
</div>
)
}
// DETAIL MODE
if (mode === 'detail' && selectedControl) {
return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-hidden">
<ControlDetail
ctrl={selectedControl}
onBack={() => { setMode('list'); setSelectedControl(null) }}
onEdit={() => {}}
onDelete={() => {}}
onReview={() => {}}
onNavigateToControl={async (controlId: string) => {
try {
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${controlId}`)
if (res.ok) {
const data = await res.json()
setSelectedControl(data)
}
} catch { /* ignore */ }
}}
/>
</div>
</div>
)
}
// =========================================================================
// LIST VIEW
// =========================================================================
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 justify-between mb-4">
<div className="flex items-center gap-3">
<Atom className="w-6 h-6 text-violet-600" />
<div>
<h1 className="text-lg font-semibold text-gray-900">Atomare Controls</h1>
<p className="text-xs text-gray-500">
Deduplizierte atomare Controls mit Herkunftsnachweis
</p>
</div>
</div>
<button
onClick={() => { loadControls(); loadStats() }}
className="p-2 text-gray-400 hover:text-violet-600"
title="Aktualisieren"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Stats Bar */}
{stats && (
<div className="grid grid-cols-4 gap-3 mb-4">
<div className="bg-violet-50 border border-violet-200 rounded-lg p-3">
<div className="text-2xl font-bold text-violet-700">{stats.total_active.toLocaleString('de-DE')}</div>
<div className="text-xs text-violet-500">Master Controls</div>
</div>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
<div className="text-2xl font-bold text-gray-600">{stats.total_duplicate.toLocaleString('de-DE')}</div>
<div className="text-xs text-gray-500">Duplikate (entfernt)</div>
</div>
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-3">
<div className="text-2xl font-bold text-indigo-700">{stats.by_regulation.length}</div>
<div className="text-xs text-indigo-500">Regulierungen</div>
</div>
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-3">
<div className="text-2xl font-bold text-emerald-700">{stats.avg_regulation_coverage}</div>
<div className="text-xs text-emerald-500">Avg. Regulierungen / Control</div>
</div>
</div>
)}
{/* Filters */}
<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="Atomare Controls durchsuchen (ID, Titel, Objective)..."
value={searchQuery}
onChange={e => setSearchQuery(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-violet-500"
/>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Filter className="w-4 h-4 text-gray-400" />
<select
value={domainFilter}
onChange={e => setDomainFilter(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-violet-500"
>
<option value="">Domain</option>
{stats?.by_domain.map(d => (
<option key={d.domain} value={d.domain}>{d.domain} ({d.count})</option>
))}
</select>
<select
value={severityFilter}
onChange={e => setSeverityFilter(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-violet-500"
>
<option value="">Schweregrad</option>
<option value="critical">Kritisch</option>
<option value="high">Hoch</option>
<option value="medium">Mittel</option>
<option value="low">Niedrig</option>
</select>
<select
value={categoryFilter}
onChange={e => setCategoryFilter(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-violet-500"
>
<option value="">Kategorie</option>
{CATEGORY_OPTIONS.map(c => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
<span className="text-gray-300 mx-1">|</span>
<ArrowUpDown className="w-4 h-4 text-gray-400" />
<select
value={sortBy}
onChange={e => setSortBy(e.target.value as 'id' | 'newest' | 'oldest')}
className="text-sm border border-gray-300 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-violet-500"
>
<option value="id">Sortierung: ID</option>
<option value="newest">Neueste zuerst</option>
<option value="oldest">Aelteste zuerst</option>
</select>
</div>
</div>
</div>
{/* Pagination Header */}
<div className="px-6 py-2 bg-gray-50 border-b border-gray-200 flex items-center justify-between text-xs text-gray-500">
<span>
{totalCount} Controls gefunden
{stats && totalCount !== stats.total_active && ` (von ${stats.total_active.toLocaleString('de-DE')} Master Controls)`}
{loading && <span className="ml-2 text-violet-500">Lade...</span>}
</span>
<span>Seite {currentPage} von {totalPages}</span>
</div>
{/* Control List */}
<div className="flex-1 overflow-y-auto p-6">
<div className="space-y-3">
{controls.map((ctrl) => (
<button
key={ctrl.control_id}
onClick={() => { setSelectedControl(ctrl); setMode('detail') }}
className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:border-violet-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} />
<CategoryBadge category={ctrl.category} />
<TargetAudienceBadge audience={ctrl.target_audience} />
<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>
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{ctrl.objective}</p>
<div className="flex items-center gap-2 mt-2">
{ctrl.source_citation?.source && (
<>
<span className="text-xs text-blue-600">
{ctrl.source_citation.source}
{ctrl.source_citation.article && ` ${ctrl.source_citation.article}`}
</span>
<span className="text-gray-300">|</span>
</>
)}
{ctrl.parent_control_id && (
<>
<span className="text-xs text-violet-500">via {ctrl.parent_control_id}</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' }) : '-'}
</span>
</div>
</div>
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-violet-500 flex-shrink-0 mt-1 ml-4" />
</div>
</button>
))}
{controls.length === 0 && !loading && (
<div className="text-center py-12 text-gray-400 text-sm">
Keine atomaren Controls gefunden.
</div>
)}
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-6 pb-4">
<button
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronsLeft className="w-4 h-4" />
</button>
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(p => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
.reduce<(number | 'dots')[]>((acc, p, i, arr) => {
if (i > 0 && p - (arr[i - 1] as number) > 1) acc.push('dots')
acc.push(p)
return acc
}, [])
.map((p, i) =>
p === 'dots' ? (
<span key={`dots-${i}`} className="px-1 text-gray-400">...</span>
) : (
<button
key={p}
onClick={() => setCurrentPage(p as number)}
className={`w-8 h-8 text-sm rounded-lg ${
currentPage === p
? 'bg-violet-600 text-white'
: 'text-gray-600 hover:bg-violet-50 hover:text-violet-600'
}`}
>
{p}
</button>
)
)
}
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronRight className="w-4 h-4" />
</button>
<button
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
className="p-2 text-gray-500 hover:text-violet-600 disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronsRight className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,56 +0,0 @@
import { describe, it, expect } from 'vitest'
import { getDomain, BACKEND_URL, EMPTY_CONTROL, DOMAIN_OPTIONS, COLLECTION_OPTIONS } from '../components/helpers'
describe('getDomain', () => {
it('extracts domain from control_id', () => {
expect(getDomain('AUTH-001')).toBe('AUTH')
expect(getDomain('NET-042')).toBe('NET')
expect(getDomain('CRYPT-003')).toBe('CRYPT')
})
it('returns empty string for invalid control_id', () => {
expect(getDomain('')).toBe('')
expect(getDomain('NODASH')).toBe('NODASH')
})
})
describe('BACKEND_URL', () => {
it('points to canonical API proxy', () => {
expect(BACKEND_URL).toBe('/api/sdk/v1/canonical')
})
})
describe('EMPTY_CONTROL', () => {
it('has required fields with default values', () => {
expect(EMPTY_CONTROL.framework_id).toBe('bp_security_v1')
expect(EMPTY_CONTROL.severity).toBe('medium')
expect(EMPTY_CONTROL.release_state).toBe('draft')
expect(EMPTY_CONTROL.tags).toEqual([])
expect(EMPTY_CONTROL.requirements).toEqual([''])
expect(EMPTY_CONTROL.test_procedure).toEqual([''])
expect(EMPTY_CONTROL.evidence).toEqual([{ type: '', description: '' }])
expect(EMPTY_CONTROL.open_anchors).toEqual([{ framework: '', ref: '', url: '' }])
})
})
describe('DOMAIN_OPTIONS', () => {
it('contains expected domains', () => {
const values = DOMAIN_OPTIONS.map(d => d.value)
expect(values).toContain('AUTH')
expect(values).toContain('NET')
expect(values).toContain('CRYPT')
expect(values).toContain('AI')
expect(values).toContain('COMP')
expect(values.length).toBe(10)
})
})
describe('COLLECTION_OPTIONS', () => {
it('contains expected collections', () => {
const values = COLLECTION_OPTIONS.map(c => c.value)
expect(values).toContain('bp_compliance_ce')
expect(values).toContain('bp_compliance_gesetze')
expect(values).toContain('bp_compliance_datenschutz')
expect(values.length).toBe(6)
})
})

View File

@@ -1,322 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'
import ControlLibraryPage from '../page'
// ============================================================================
// Mock data
// ============================================================================
const MOCK_FRAMEWORK = {
id: 'fw-1',
framework_id: 'bp_security_v1',
name: 'BreakPilot Security',
version: '1.0',
description: 'Test framework',
release_state: 'draft',
}
const MOCK_CONTROL = {
id: 'ctrl-1',
framework_id: 'fw-1',
control_id: 'AUTH-001',
title: 'Multi-Factor Authentication',
objective: 'Require MFA for all admin accounts.',
rationale: 'Passwords alone are insufficient.',
scope: {},
requirements: ['MFA for admin'],
test_procedure: ['Test admin login'],
evidence: [{ type: 'config', description: 'MFA enabled' }],
severity: 'high',
risk_score: 4.0,
implementation_effort: 'm',
evidence_confidence: null,
open_anchors: [{ framework: 'OWASP', ref: 'V2.8', url: 'https://owasp.org' }],
release_state: 'draft',
tags: ['mfa'],
license_rule: 1,
source_original_text: null,
source_citation: { source: 'DSGVO' },
customer_visible: true,
verification_method: 'automated',
category: 'authentication',
target_audience: 'developer',
generation_metadata: null,
generation_strategy: 'ungrouped',
created_at: '2026-03-15T10:00:00+00:00',
updated_at: '2026-03-15T10:00:00+00:00',
}
const MOCK_META = {
total: 1,
domains: [{ domain: 'AUTH', count: 1 }],
sources: [{ source: 'DSGVO', count: 1 }],
no_source_count: 0,
}
// ============================================================================
// Fetch mock
// ============================================================================
function createFetchMock(overrides?: Record<string, unknown>) {
const responses: Record<string, unknown> = {
frameworks: [MOCK_FRAMEWORK],
controls: [MOCK_CONTROL],
'controls-count': { total: 1 },
'controls-meta': MOCK_META,
...overrides,
}
return vi.fn((url: string) => {
const urlStr = typeof url === 'string' ? url : ''
// Match endpoint param
const match = urlStr.match(/endpoint=([^&]+)/)
const endpoint = match?.[1] || ''
const data = responses[endpoint] ?? []
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(data),
})
})
}
// ============================================================================
// Tests
// ============================================================================
describe('ControlLibraryPage', () => {
let fetchMock: ReturnType<typeof createFetchMock>
beforeEach(() => {
fetchMock = createFetchMock()
global.fetch = fetchMock as unknown as typeof fetch
})
afterEach(() => {
vi.restoreAllMocks()
})
it('renders the page header', async () => {
render(<ControlLibraryPage />)
await waitFor(() => {
expect(screen.getByText('Canonical Control Library')).toBeInTheDocument()
})
})
it('shows control count from meta', async () => {
fetchMock = createFetchMock({ 'controls-meta': { ...MOCK_META, total: 42 } })
global.fetch = fetchMock as unknown as typeof fetch
render(<ControlLibraryPage />)
await waitFor(() => {
expect(screen.getByText(/42 Security Controls/)).toBeInTheDocument()
})
})
it('renders control list with data', async () => {
render(<ControlLibraryPage />)
await waitFor(() => {
expect(screen.getByText('AUTH-001')).toBeInTheDocument()
expect(screen.getByText('Multi-Factor Authentication')).toBeInTheDocument()
})
})
it('shows timestamp on control cards', async () => {
render(<ControlLibraryPage />)
await waitFor(() => {
// The date should be rendered in German locale format
expect(screen.getByText(/15\.03\.26/)).toBeInTheDocument()
})
})
it('shows source citation on control cards', async () => {
render(<ControlLibraryPage />)
await waitFor(() => {
expect(screen.getByText('DSGVO')).toBeInTheDocument()
})
})
it('fetches with limit and offset params', async () => {
render(<ControlLibraryPage />)
await waitFor(() => {
expect(fetchMock).toHaveBeenCalled()
})
// Find the controls fetch call
const controlsCalls = fetchMock.mock.calls.filter(
(call: unknown[]) => typeof call[0] === 'string' && (call[0] as string).includes('endpoint=controls&')
)
expect(controlsCalls.length).toBeGreaterThan(0)
const url = controlsCalls[0][0] as string
expect(url).toContain('limit=50')
expect(url).toContain('offset=0')
})
it('fetches controls-count alongside controls', async () => {
render(<ControlLibraryPage />)
await waitFor(() => {
const countCalls = fetchMock.mock.calls.filter(
(call: unknown[]) => typeof call[0] === 'string' && (call[0] as string).includes('endpoint=controls-count')
)
expect(countCalls.length).toBeGreaterThan(0)
})
})
it('fetches controls-meta on mount', async () => {
render(<ControlLibraryPage />)
await waitFor(() => {
const metaCalls = fetchMock.mock.calls.filter(
(call: unknown[]) => typeof call[0] === 'string' && (call[0] as string).includes('endpoint=controls-meta')
)
expect(metaCalls.length).toBeGreaterThan(0)
})
})
it('renders domain dropdown from meta', async () => {
render(<ControlLibraryPage />)
await waitFor(() => {
expect(screen.getByText('AUTH (1)')).toBeInTheDocument()
})
})
it('renders source dropdown from meta', async () => {
render(<ControlLibraryPage />)
await waitFor(() => {
// The source option should appear in the dropdown
expect(screen.getByText('DSGVO (1)')).toBeInTheDocument()
})
})
it('has sort dropdown with all sort options', async () => {
render(<ControlLibraryPage />)
await waitFor(() => {
expect(screen.getByText('Sortierung: ID')).toBeInTheDocument()
expect(screen.getByText('Nach Quelle')).toBeInTheDocument()
expect(screen.getByText('Neueste zuerst')).toBeInTheDocument()
expect(screen.getByText('Aelteste zuerst')).toBeInTheDocument()
})
})
it('sends sort params when sorting by newest', async () => {
render(<ControlLibraryPage />)
await waitFor(() => {
expect(screen.getByText('AUTH-001')).toBeInTheDocument()
})
// Clear previous calls
fetchMock.mockClear()
// Change sort to newest
const sortSelect = screen.getByDisplayValue('Sortierung: ID')
await act(async () => {
fireEvent.change(sortSelect, { target: { value: 'newest' } })
})
await waitFor(() => {
const controlsCalls = fetchMock.mock.calls.filter(
(call: unknown[]) => typeof call[0] === 'string' && (call[0] as string).includes('endpoint=controls&')
)
expect(controlsCalls.length).toBeGreaterThan(0)
const url = controlsCalls[0][0] as string
expect(url).toContain('sort=created_at')
expect(url).toContain('order=desc')
})
})
it('sends search param after debounce', async () => {
render(<ControlLibraryPage />)
await waitFor(() => {
expect(screen.getByText('AUTH-001')).toBeInTheDocument()
})
fetchMock.mockClear()
const searchInput = screen.getByPlaceholderText(/Controls durchsuchen/)
await act(async () => {
fireEvent.change(searchInput, { target: { value: 'encryption' } })
})
// Wait for debounce (400ms)
await waitFor(
() => {
const controlsCalls = fetchMock.mock.calls.filter(
(call: unknown[]) => typeof call[0] === 'string' && (call[0] as string).includes('search=encryption')
)
expect(controlsCalls.length).toBeGreaterThan(0)
},
{ timeout: 1000 }
)
})
it('shows empty state when no controls', async () => {
fetchMock = createFetchMock({
controls: [],
'controls-count': { total: 0 },
'controls-meta': { ...MOCK_META, total: 0 },
})
global.fetch = fetchMock as unknown as typeof fetch
render(<ControlLibraryPage />)
await waitFor(() => {
expect(screen.getByText(/Noch keine Controls/)).toBeInTheDocument()
})
})
it('shows "Keine Controls gefunden" when filter matches nothing', async () => {
fetchMock = createFetchMock({
controls: [],
'controls-count': { total: 0 },
'controls-meta': { ...MOCK_META, total: 50 },
})
global.fetch = fetchMock as unknown as typeof fetch
render(<ControlLibraryPage />)
// Wait for initial load to finish
await waitFor(() => {
expect(screen.getByPlaceholderText(/Controls durchsuchen/)).toBeInTheDocument()
})
// Trigger a search to have a filter active
const searchInput = screen.getByPlaceholderText(/Controls durchsuchen/)
await act(async () => {
fireEvent.change(searchInput, { target: { value: 'zzzzzzz' } })
})
await waitFor(
() => {
expect(screen.getByText('Keine Controls gefunden.')).toBeInTheDocument()
},
{ timeout: 1000 }
)
})
it('has a refresh button', async () => {
render(<ControlLibraryPage />)
await waitFor(() => {
expect(screen.getByTitle('Aktualisieren')).toBeInTheDocument()
})
})
it('renders pagination info', async () => {
render(<ControlLibraryPage />)
await waitFor(() => {
expect(screen.getByText(/Seite 1 von 1/)).toBeInTheDocument()
})
})
it('shows pagination buttons for many controls', async () => {
fetchMock = createFetchMock({
'controls-count': { total: 150 },
'controls-meta': { ...MOCK_META, total: 150 },
})
global.fetch = fetchMock as unknown as typeof fetch
render(<ControlLibraryPage />)
await waitFor(() => {
expect(screen.getByText(/Seite 1 von 3/)).toBeInTheDocument()
})
})
})

View File

@@ -1,878 +0,0 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import {
ArrowLeft, ExternalLink, BookOpen, Scale, FileText,
Eye, CheckCircle2, Trash2, Pencil, Clock,
ChevronLeft, SkipForward, GitMerge, Search, Landmark,
} from 'lucide-react'
import {
CanonicalControl, EFFORT_LABELS, BACKEND_URL,
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, EvidenceTypeBadge, TargetAudienceBadge,
ObligationTypeBadge, GenerationStrategyBadge, isEigenentwicklung,
ExtractionMethodBadge, RegulationCountBadge,
VERIFICATION_METHODS, CATEGORY_OPTIONS, EVIDENCE_TYPE_OPTIONS,
ObligationInfo, DocumentReference, MergedDuplicate, RegulationSummary,
} from './helpers'
interface SimilarControl {
control_id: string
title: string
severity: string
release_state: string
tags: string[]
license_rule: number | null
verification_method: string | null
category: string | null
similarity: number
}
interface ParentLink {
parent_control_id: string
parent_title: string
link_type: string
confidence: number
source_regulation: string | null
source_article: string | null
parent_citation: Record<string, string> | null
obligation: {
text: string
action: string
object: string
normative_strength: string
} | null
}
interface TraceabilityData {
control_id: string
title: string
is_atomic: boolean
parent_links: ParentLink[]
children: Array<{
control_id: string
title: string
category: string
severity: string
decomposition_method: string
}>
source_count: number
// Extended provenance fields
obligations?: ObligationInfo[]
obligation_count?: number
document_references?: DocumentReference[]
merged_duplicates?: MergedDuplicate[]
merged_duplicates_count?: number
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
onEdit: () => void
onDelete: (controlId: string) => void
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
reviewTotal?: number
onReviewPrev?: () => void
onReviewNext?: () => void
}
export function ControlDetail({
ctrl,
onBack,
onEdit,
onDelete,
onReview,
onRefresh,
onNavigateToControl,
onCompare,
reviewMode,
reviewIndex = 0,
reviewTotal = 0,
onReviewPrev,
onReviewNext,
}: ControlDetailProps) {
const [similarControls, setSimilarControls] = useState<SimilarControl[]>([])
const [loadingSimilar, setLoadingSimilar] = useState(false)
const [selectedDuplicates, setSelectedDuplicates] = useState<Set<string>>(new Set())
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)
try {
// Try provenance first (extended data), fall back to traceability
let res = await fetch(`${BACKEND_URL}?endpoint=provenance&id=${ctrl.control_id}`)
if (!res.ok) {
res = await fetch(`${BACKEND_URL}?endpoint=traceability&id=${ctrl.control_id}`)
}
if (res.ok) {
setTraceability(await res.json())
}
} catch { /* ignore */ }
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])
const loadSimilarControls = async () => {
setLoadingSimilar(true)
try {
const res = await fetch(`${BACKEND_URL}?endpoint=similar&id=${ctrl.control_id}`)
if (res.ok) {
setSimilarControls(await res.json())
}
} catch { /* ignore */ }
finally { setLoadingSimilar(false) }
}
const toggleDuplicate = (controlId: string) => {
setSelectedDuplicates(prev => {
const next = new Set(prev)
if (next.has(controlId)) next.delete(controlId)
else next.add(controlId)
return next
})
}
const handleMergeDuplicates = async () => {
if (selectedDuplicates.size === 0) return
if (!confirm(`${selectedDuplicates.size} Controls als Duplikate markieren und Tags/Anchors in ${ctrl.control_id} zusammenfuehren?`)) return
setMerging(true)
try {
// For each duplicate: mark as deprecated
for (const dupId of selectedDuplicates) {
await fetch(`${BACKEND_URL}?endpoint=update-control&id=${dupId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ release_state: 'deprecated' }),
})
}
// Refresh to show updated state
if (onRefresh) onRefresh()
setSelectedDuplicates(new Set())
loadSimilarControls()
} catch {
alert('Fehler beim Zusammenfuehren')
} finally {
setMerging(false)
}
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b border-gray-200 bg-white px-6 py-4 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">
<span className="text-sm font-mono text-purple-600 bg-purple-50 px-2 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} />
</div>
<h2 className="text-lg font-semibold text-gray-900 mt-1">{ctrl.title}</h2>
</div>
</div>
<div className="flex items-center gap-2">
{reviewMode && (
<div className="flex items-center gap-1 mr-3">
<button onClick={onReviewPrev} disabled={reviewIndex === 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">{reviewIndex + 1} / {reviewTotal}</span>
<button onClick={onReviewNext} disabled={reviewIndex >= reviewTotal - 1} className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30">
<SkipForward className="w-4 h-4" />
</button>
</div>
)}
<button onClick={onEdit} className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
<Pencil className="w-3.5 h-3.5 inline mr-1" />Bearbeiten
</button>
<button onClick={() => onDelete(ctrl.control_id)} className="px-3 py-1.5 text-sm text-red-600 border border-red-300 rounded-lg hover:bg-red-50">
<Trash2 className="w-3.5 h-3.5 inline mr-1" />Loeschen
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 max-w-4xl mx-auto w-full space-y-6">
{/* Objective */}
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Ziel</h3>
<p className="text-sm text-gray-700 leading-relaxed">{ctrl.objective}</p>
</section>
{/* Rationale */}
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Begruendung</h3>
<p className="text-sm text-gray-700 leading-relaxed">{ctrl.rationale}</p>
</section>
{/* Quellennachweis (Rule 1 + 2) — dynamic label based on source_type */}
{ctrl.source_citation && (
<section className={`border rounded-lg p-4 ${
ctrl.source_citation.source_type === 'law' ? 'bg-blue-50 border-blue-200' :
ctrl.source_citation.source_type === 'guideline' ? 'bg-indigo-50 border-indigo-200' :
'bg-teal-50 border-teal-200'
}`}>
<div className="flex items-center gap-2 mb-3">
<Scale className={`w-4 h-4 ${
ctrl.source_citation.source_type === 'law' ? 'text-blue-600' :
ctrl.source_citation.source_type === 'guideline' ? 'text-indigo-600' :
'text-teal-600'
}`} />
<h3 className={`text-sm font-semibold ${
ctrl.source_citation.source_type === 'law' ? 'text-blue-900' :
ctrl.source_citation.source_type === 'guideline' ? 'text-indigo-900' :
'text-teal-900'
}`}>{
ctrl.source_citation.source_type === 'law' ? 'Gesetzliche Grundlage' :
ctrl.source_citation.source_type === 'guideline' ? 'Behoerdliche Leitlinie' :
'Standard / Best Practice'
}</h3>
{ctrl.source_citation.source_type === 'law' && (
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full">Direkte gesetzliche Pflicht</span>
)}
{ctrl.source_citation.source_type === 'guideline' && (
<span className="text-xs bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded-full">Aufsichtsbehoerdliche Empfehlung</span>
)}
{(ctrl.source_citation.source_type === 'standard' || (!ctrl.source_citation.source_type && ctrl.license_rule === 2)) && (
<span className="text-xs bg-teal-100 text-teal-700 px-2 py-0.5 rounded-full">Freiwilliger Standard</span>
)}
{(!ctrl.source_citation.source_type && ctrl.license_rule === 1) && (
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">Noch nicht klassifiziert</span>
)}
</div>
<div className="flex items-start gap-3">
<div className="flex-1">
{ctrl.source_citation.source ? (
<p className="text-sm font-medium text-blue-900 mb-1">
{ctrl.source_citation.source}
{ctrl.source_citation.article && `${ctrl.source_citation.article}`}
{ctrl.source_citation.paragraph && ` ${ctrl.source_citation.paragraph}`}
</p>
) : ctrl.generation_metadata?.source_regulation ? (
<p className="text-sm font-medium text-blue-900 mb-1">{String(ctrl.generation_metadata.source_regulation)}</p>
) : null}
{ctrl.source_citation.license && (
<p className="text-xs text-blue-600">Lizenz: {ctrl.source_citation.license}</p>
)}
{ctrl.source_citation.license_notice && (
<p className="text-xs text-blue-600 mt-0.5">{ctrl.source_citation.license_notice}</p>
)}
</div>
{ctrl.source_citation.url && (
<a
href={ctrl.source_citation.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 whitespace-nowrap"
>
<ExternalLink className="w-3.5 h-3.5" />Quelle
</a>
)}
</div>
{ctrl.source_original_text && (
<details className="mt-3">
<summary className="text-xs text-blue-600 cursor-pointer hover:text-blue-800">Originaltext anzeigen</summary>
<p className="text-xs text-gray-600 mt-2 p-2 bg-white rounded border border-blue-100 leading-relaxed max-h-40 overflow-y-auto whitespace-pre-wrap">
{ctrl.source_original_text}
</p>
</details>
)}
</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">
<div className="flex items-center gap-2 mb-3">
<Landmark className="w-4 h-4 text-violet-600" />
<h3 className="text-sm font-semibold text-violet-900">
Rechtsgrundlagen ({traceability.source_count} {traceability.source_count === 1 ? 'Quelle' : 'Quellen'})
</h3>
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
{traceability.regulations_summary && traceability.regulations_summary.map(rs => (
<span key={rs.regulation_code} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-violet-200 text-violet-800">
{rs.regulation_code}
</span>
))}
{loadingTrace && <span className="text-xs text-violet-400">Laden...</span>}
</div>
<div className="space-y-3">
{traceability.parent_links.map((link, i) => (
<div key={i} className="bg-white/60 border border-violet-100 rounded-lg p-3">
<div className="flex items-start gap-2">
<Scale className="w-4 h-4 text-violet-500 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{link.source_regulation && (
<span className="text-sm font-semibold text-violet-900">{link.source_regulation}</span>
)}
{link.source_article && (
<span className="text-sm text-violet-700">{link.source_article}</span>
)}
{!link.source_regulation && link.parent_citation?.source && (
<span className="text-sm font-semibold text-violet-900">
{link.parent_citation.source}
{link.parent_citation.article && `${link.parent_citation.article}`}
</span>
)}
<span className={`text-xs px-1.5 py-0.5 rounded ${
link.link_type === 'decomposition' ? 'bg-violet-100 text-violet-600' :
link.link_type === 'dedup_merge' ? 'bg-blue-100 text-blue-600' :
'bg-gray-100 text-gray-600'
}`}>
{link.link_type === 'decomposition' ? 'Ableitung' :
link.link_type === 'dedup_merge' ? 'Dedup' :
link.link_type}
</span>
</div>
<p className="text-xs text-violet-600 mt-1">
via{' '}
{onNavigateToControl ? (
<button
onClick={() => onNavigateToControl(link.parent_control_id)}
className="font-mono font-medium text-purple-700 bg-purple-50 px-1 py-0.5 rounded hover:bg-purple-100 hover:underline"
>
{link.parent_control_id}
</button>
) : (
<span className="font-mono font-medium text-purple-700 bg-purple-50 px-1 py-0.5 rounded">
{link.parent_control_id}
</span>
)}
{link.parent_title && (
<span className="text-violet-500 ml-1"> {link.parent_title}</span>
)}
</p>
{link.obligation && (
<p className="text-xs text-violet-500 mt-1.5 bg-violet-50 rounded p-2">
<span className={`inline-block mr-1.5 px-1.5 py-0.5 rounded text-xs font-medium ${
link.obligation.normative_strength === 'must' ? 'bg-red-100 text-red-700' :
link.obligation.normative_strength === 'should' ? 'bg-amber-100 text-amber-700' :
'bg-green-100 text-green-700'
}`}>
{link.obligation.normative_strength === 'must' ? 'MUSS' :
link.obligation.normative_strength === 'should' ? 'SOLL' : 'KANN'}
</span>
{link.obligation.text.slice(0, 200)}
{link.obligation.text.length > 200 ? '...' : ''}
</p>
)}
</div>
</div>
</div>
))}
</div>
</section>
)}
{/* Fallback: simple parent display when traceability not loaded yet */}
{ctrl.parent_control_uuid && (!traceability || traceability.parent_links.length === 0) && !loadingTrace && (
<section className="bg-violet-50 border border-violet-200 rounded-lg p-4">
<div className="flex items-center gap-2 mb-1">
<GitMerge className="w-4 h-4 text-violet-600" />
<h3 className="text-sm font-semibold text-violet-900">Atomares Control</h3>
<ObligationTypeBadge type={ctrl.generation_metadata?.obligation_type as string} />
</div>
<p className="text-sm text-violet-800">
Abgeleitet aus Eltern-Control{' '}
<span className="font-mono font-semibold text-purple-700 bg-purple-100 px-1.5 py-0.5 rounded">
{ctrl.parent_control_id || ctrl.parent_control_uuid}
</span>
{ctrl.parent_control_title && (
<span className="text-violet-700 ml-1"> {ctrl.parent_control_title}</span>
)}
</p>
</section>
)}
{/* Document References (atomic controls) */}
{traceability && traceability.is_atomic && traceability.document_references && traceability.document_references.length > 0 && (
<section className="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<FileText className="w-4 h-4 text-indigo-600" />
<h3 className="text-sm font-semibold text-indigo-900">
Original-Dokumente ({traceability.document_references.length})
</h3>
</div>
<div className="space-y-2">
{traceability.document_references.map((dr, i) => (
<div key={i} className="flex items-center gap-2 text-sm bg-white/60 border border-indigo-100 rounded-lg p-2">
<span className="font-semibold text-indigo-900">{dr.regulation_code}</span>
{dr.article && <span className="text-indigo-700">{dr.article}</span>}
{dr.paragraph && <span className="text-indigo-600 text-xs">{dr.paragraph}</span>}
<span className="ml-auto flex items-center gap-1.5">
<ExtractionMethodBadge method={dr.extraction_method} />
{dr.confidence !== null && (
<span className="text-xs text-gray-500">{(dr.confidence * 100).toFixed(0)}%</span>
)}
</span>
</div>
))}
</div>
</section>
)}
{/* Obligations (rich controls) */}
{traceability && !traceability.is_atomic && traceability.obligations && traceability.obligations.length > 0 && (
<section className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<Scale className="w-4 h-4 text-amber-600" />
<h3 className="text-sm font-semibold text-amber-900">
Abgeleitete Pflichten ({traceability.obligation_count ?? traceability.obligations.length})
</h3>
</div>
<div className="space-y-2">
{traceability.obligations.map((ob) => (
<div key={ob.candidate_id} className="bg-white/60 border border-amber-100 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="font-mono text-xs text-amber-700 bg-amber-100 px-1.5 py-0.5 rounded">{ob.candidate_id}</span>
<span className={`inline-block px-1.5 py-0.5 rounded text-xs font-medium ${
ob.normative_strength === 'must' ? 'bg-red-100 text-red-700' :
ob.normative_strength === 'should' ? 'bg-amber-100 text-amber-700' :
'bg-green-100 text-green-700'
}`}>
{ob.normative_strength === 'must' ? 'MUSS' :
ob.normative_strength === 'should' ? 'SOLL' : 'KANN'}
</span>
{ob.action && <span className="text-xs text-amber-600">{ob.action}</span>}
{ob.object && <span className="text-xs text-amber-500"> {ob.object}</span>}
</div>
<p className="text-xs text-gray-700 leading-relaxed">
{ob.obligation_text.slice(0, 300)}
{ob.obligation_text.length > 300 ? '...' : ''}
</p>
</div>
))}
</div>
</section>
)}
{/* Merged Duplicates */}
{traceability && traceability.merged_duplicates && traceability.merged_duplicates.length > 0 && (
<section className="bg-slate-50 border border-slate-200 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<GitMerge className="w-4 h-4 text-slate-600" />
<h3 className="text-sm font-semibold text-slate-900">
Zusammengefuehrte Duplikate ({traceability.merged_duplicates_count ?? traceability.merged_duplicates.length})
</h3>
</div>
<div className="space-y-1.5">
{traceability.merged_duplicates.map((dup) => (
<div key={dup.control_id} className="flex items-center gap-2 text-sm">
{onNavigateToControl ? (
<button
onClick={() => onNavigateToControl(dup.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"
>
{dup.control_id}
</button>
) : (
<span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{dup.control_id}</span>
)}
<span className="text-gray-700 flex-1 truncate">{dup.title}</span>
{dup.source_regulation && (
<span className="text-xs text-slate-500 bg-slate-100 px-1.5 py-0.5 rounded">{dup.source_regulation}</span>
)}
</div>
))}
</div>
</section>
)}
{/* Child controls (rich controls that have atomic children) */}
{traceability && traceability.children.length > 0 && (
<section className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<GitMerge className="w-4 h-4 text-emerald-600" />
<h3 className="text-sm font-semibold text-emerald-900">
Abgeleitete Controls ({traceability.children.length})
</h3>
</div>
<div className="space-y-1.5">
{traceability.children.map((child) => (
<div key={child.control_id} className="flex items-center gap-2 text-sm">
{onNavigateToControl ? (
<button
onClick={() => onNavigateToControl(child.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"
>
{child.control_id}
</button>
) : (
<span className="font-mono text-xs text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{child.control_id}</span>
)}
<span className="text-gray-700 flex-1 truncate">{child.title}</span>
<SeverityBadge severity={child.severity} />
</div>
))}
</div>
</section>
)}
{/* Impliziter Gesetzesbezug (Rule 3 — reformuliert, kein Originaltext) */}
{!ctrl.source_citation && ctrl.open_anchors.length > 0 && (
<section className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<div className="flex items-center gap-2">
<Scale className="w-4 h-4 text-amber-600" />
<div className="flex-1">
<p className="text-xs text-amber-800 font-medium">Abgeleitet aus regulatorischen Anforderungen</p>
<p className="text-xs text-amber-700 mt-0.5">
Dieser Control wurde aus geschuetzten Quellen reformuliert (z.B. BSI Grundschutz, ISO 27001).
Die konkreten Massnahmen leiten sich aus den Open-Source-Referenzen unten ab.
</p>
</div>
</div>
</section>
)}
{/* Scope */}
{(ctrl.scope.platforms?.length || ctrl.scope.components?.length || ctrl.scope.data_classes?.length) ? (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Geltungsbereich</h3>
<div className="grid grid-cols-3 gap-4 text-xs">
{ctrl.scope.platforms?.length ? (
<div><span className="text-gray-500">Plattformen:</span> <span className="text-gray-700">{ctrl.scope.platforms.join(', ')}</span></div>
) : null}
{ctrl.scope.components?.length ? (
<div><span className="text-gray-500">Komponenten:</span> <span className="text-gray-700">{ctrl.scope.components.join(', ')}</span></div>
) : null}
{ctrl.scope.data_classes?.length ? (
<div><span className="text-gray-500">Datenklassen:</span> <span className="text-gray-700">{ctrl.scope.data_classes.join(', ')}</span></div>
) : null}
</div>
</section>
) : null}
{/* Requirements */}
{ctrl.requirements.length > 0 && (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
<ol className="list-decimal list-inside space-y-1">
{ctrl.requirements.map((r, i) => (
<li key={i} className="text-sm text-gray-700">{r}</li>
))}
</ol>
</section>
)}
{/* Test Procedure */}
{ctrl.test_procedure.length > 0 && (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
<ol className="list-decimal list-inside space-y-1">
{ctrl.test_procedure.map((s, i) => (
<li key={i} className="text-sm text-gray-700">{s}</li>
))}
</ol>
</section>
)}
{/* Evidence — handles both {type, description} objects and plain strings */}
{ctrl.evidence.length > 0 && (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Nachweise</h3>
<div className="space-y-2">
{ctrl.evidence.map((ev, i) => (
<div key={i} className="flex items-start gap-2 text-sm text-gray-700">
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
{typeof ev === 'string' ? (
<div>{ev}</div>
) : (
<div><span className="font-medium">{ev.type}:</span> {ev.description}</div>
)}
</div>
))}
</div>
</section>
)}
{/* Meta */}
<section className="grid grid-cols-3 gap-4 text-xs text-gray-500">
{ctrl.risk_score !== null && <div>Risiko-Score: <span className="text-gray-700 font-medium">{ctrl.risk_score}</span></div>}
{ctrl.implementation_effort && <div>Aufwand: <span className="text-gray-700 font-medium">{EFFORT_LABELS[ctrl.implementation_effort] || ctrl.implementation_effort}</span></div>}
{ctrl.tags.length > 0 && (
<div className="col-span-3 flex items-center gap-1 flex-wrap">
{ctrl.tags.map(t => (
<span key={t} className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">{t}</span>
))}
</div>
)}
</section>
{/* Open Anchors */}
<section className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<BookOpen className="w-4 h-4 text-green-700" />
<h3 className="text-sm font-semibold text-green-900">Open-Source-Referenzen ({ctrl.open_anchors.length})</h3>
</div>
{ctrl.open_anchors.length > 0 ? (
<div className="space-y-2">
{ctrl.open_anchors.map((anchor, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<ExternalLink className="w-3.5 h-3.5 text-green-600 flex-shrink-0" />
<span className="font-medium text-green-800">{anchor.framework}</span>
<span className="text-green-700">{anchor.ref}</span>
{anchor.url && (
<a href={anchor.url} target="_blank" rel="noopener noreferrer" className="text-green-600 hover:text-green-800 underline text-xs ml-auto">
Link
</a>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-green-600">Keine Referenzen vorhanden.</p>
)}
</section>
{/* Generation Metadata (internal) */}
{ctrl.generation_metadata && (
<section className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<Clock className="w-4 h-4 text-gray-500" />
<h3 className="text-sm font-semibold text-gray-700">Generierungsdetails (intern)</h3>
</div>
<div className="text-xs text-gray-600 space-y-1">
{ctrl.generation_metadata.processing_path && (
<p>Pfad: {String(ctrl.generation_metadata.processing_path)}</p>
)}
{ctrl.generation_metadata.decomposition_method && (
<p>Methode: {String(ctrl.generation_metadata.decomposition_method)}</p>
)}
{ctrl.generation_metadata.pass0b_model && (
<p>LLM: {String(ctrl.generation_metadata.pass0b_model)}</p>
)}
{ctrl.generation_metadata.obligation_type && (
<p>Obligation-Typ: {String(ctrl.generation_metadata.obligation_type)}</p>
)}
{ctrl.generation_metadata.similarity_status && (
<p className="text-red-600">Similarity: {String(ctrl.generation_metadata.similarity_status)}</p>
)}
{Array.isArray(ctrl.generation_metadata.similar_controls) && (
<div>
<p className="font-medium">Aehnliche Controls:</p>
{(ctrl.generation_metadata.similar_controls as Array<Record<string, unknown>>).map((s, i) => (
<p key={i} className="ml-2">{String(s.control_id)} {String(s.title)} ({String(s.similarity)})</p>
))}
</div>
)}
</div>
</section>
)}
{/* Similar Controls (Dedup) */}
<section className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<Search className="w-4 h-4 text-gray-600" />
<h3 className="text-sm font-semibold text-gray-800">Aehnliche Controls</h3>
{loadingSimilar && <span className="text-xs text-gray-400">Laden...</span>}
</div>
{similarControls.length > 0 ? (
<>
<div className="mb-3 p-2 bg-white border border-gray-100 rounded flex items-center gap-2">
<input type="radio" checked readOnly className="text-purple-600" />
<span className="text-sm font-medium text-purple-700">{ctrl.control_id} {ctrl.title}</span>
<span className="text-xs text-gray-400 ml-auto">Behalten (Haupt-Control)</span>
</div>
<div className="space-y-2">
{similarControls.map(sim => (
<div key={sim.control_id} className="p-2 bg-white border border-gray-100 rounded flex items-center gap-2">
<input
type="checkbox"
checked={selectedDuplicates.has(sim.control_id)}
onChange={() => toggleDuplicate(sim.control_id)}
className="text-red-600"
/>
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{sim.control_id}</span>
<span className="text-sm text-gray-700 flex-1">{sim.title}</span>
<span className="text-xs font-medium text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded">
{(sim.similarity * 100).toFixed(1)}%
</span>
<LicenseRuleBadge rule={sim.license_rule} />
<VerificationMethodBadge method={sim.verification_method} />
</div>
))}
</div>
{selectedDuplicates.size > 0 && (
<button
onClick={handleMergeDuplicates}
disabled={merging}
className="mt-3 flex items-center gap-1.5 px-3 py-1.5 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700 disabled:opacity-50"
>
<GitMerge className="w-3.5 h-3.5" />
{merging ? 'Zusammenfuehren...' : `${selectedDuplicates.size} Duplikat(e) zusammenfuehren`}
</button>
)}
</>
) : (
<p className="text-sm text-gray-500">
{loadingSimilar ? 'Suche aehnliche Controls...' : 'Keine aehnlichen Controls gefunden.'}
</p>
)}
</section>
{/* Review Actions */}
{['needs_review', 'too_close', 'duplicate'].includes(ctrl.release_state) && (
<section className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<Eye className="w-4 h-4 text-yellow-700" />
<h3 className="text-sm font-semibold text-yellow-900">Review erforderlich</h3>
{reviewMode && (
<span className="text-xs text-yellow-600 ml-auto">Review-Modus aktiv</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onReview(ctrl.control_id, 'approve')}
className="px-3 py-1.5 text-sm text-white bg-green-600 rounded-lg hover:bg-green-700"
>
<CheckCircle2 className="w-3.5 h-3.5 inline mr-1" />
Akzeptieren
</button>
<button
onClick={() => onReview(ctrl.control_id, 'reject')}
className="px-3 py-1.5 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700"
>
<Trash2 className="w-3.5 h-3.5 inline mr-1" />
Ablehnen
</button>
<button
onClick={onEdit}
className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
>
<Pencil className="w-3.5 h-3.5 inline mr-1" />
Ueberarbeiten
</button>
</div>
</section>
)}
</div>
</div>
)
}

View File

@@ -1,317 +0,0 @@
'use client'
import { useState } from 'react'
import { BookOpen, Trash2, Save, X } from 'lucide-react'
import { EMPTY_CONTROL, VERIFICATION_METHODS, CATEGORY_OPTIONS, TARGET_AUDIENCE_OPTIONS } from './helpers'
export function ControlForm({
initial,
onSave,
onCancel,
saving,
}: {
initial: typeof EMPTY_CONTROL
onSave: (data: typeof EMPTY_CONTROL) => void
onCancel: () => void
saving: boolean
}) {
const [form, setForm] = useState(initial)
const [tagInput, setTagInput] = useState(initial.tags.join(', '))
const [platformInput, setPlatformInput] = useState((initial.scope.platforms || []).join(', '))
const [componentInput, setComponentInput] = useState((initial.scope.components || []).join(', '))
const [dataClassInput, setDataClassInput] = useState((initial.scope.data_classes || []).join(', '))
const handleSave = () => {
const data = {
...form,
tags: tagInput.split(',').map(t => t.trim()).filter(Boolean),
scope: {
platforms: platformInput.split(',').map(t => t.trim()).filter(Boolean),
components: componentInput.split(',').map(t => t.trim()).filter(Boolean),
data_classes: dataClassInput.split(',').map(t => t.trim()).filter(Boolean),
},
requirements: form.requirements.filter(r => r.trim()),
test_procedure: form.test_procedure.filter(r => r.trim()),
evidence: form.evidence.filter(e => e.type.trim() || e.description.trim()),
open_anchors: form.open_anchors.filter(a => a.framework.trim() || a.ref.trim()),
}
onSave(data)
}
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">
{initial.control_id ? `Control ${initial.control_id} bearbeiten` : 'Neues Control erstellen'}
</h2>
<div className="flex items-center gap-2">
<button onClick={onCancel} className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
<X className="w-4 h-4 inline mr-1" />Abbrechen
</button>
<button onClick={handleSave} disabled={saving} className="px-3 py-1.5 text-sm text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
<Save className="w-4 h-4 inline mr-1" />{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
{/* Basic fields */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Control-ID *</label>
<input
value={form.control_id}
onChange={e => setForm({ ...form, control_id: e.target.value.toUpperCase() })}
placeholder="AUTH-003"
disabled={!!initial.control_id}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none disabled:bg-gray-100"
/>
<p className="text-xs text-gray-400 mt-1">Format: DOMAIN-NNN (z.B. AUTH-003, NET-005)</p>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Titel *</label>
<input
value={form.title}
onChange={e => setForm({ ...form, title: e.target.value })}
placeholder="Control-Titel"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Schweregrad</label>
<select value={form.severity} onChange={e => setForm({ ...form, severity: e.target.value })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
<option value="low">Niedrig</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
<option value="critical">Kritisch</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Risiko-Score (0-10)</label>
<input
type="number" min="0" max="10" step="0.5"
value={form.risk_score ?? ''}
onChange={e => setForm({ ...form, risk_score: e.target.value ? parseFloat(e.target.value) : null })}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Aufwand</label>
<select value={form.implementation_effort || ''} onChange={e => setForm({ ...form, implementation_effort: e.target.value || null })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
<option value="">-</option>
<option value="s">Klein (S)</option>
<option value="m">Mittel (M)</option>
<option value="l">Gross (L)</option>
<option value="xl">Sehr gross (XL)</option>
</select>
</div>
</div>
{/* Objective & Rationale */}
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Ziel *</label>
<textarea
value={form.objective}
onChange={e => setForm({ ...form, objective: e.target.value })}
rows={3}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Begruendung *</label>
<textarea
value={form.rationale}
onChange={e => setForm({ ...form, rationale: e.target.value })}
rows={3}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
/>
</div>
{/* Scope */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Plattformen (komma-getrennt)</label>
<input value={platformInput} onChange={e => setPlatformInput(e.target.value)} placeholder="web, mobile, api" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Komponenten (komma-getrennt)</label>
<input value={componentInput} onChange={e => setComponentInput(e.target.value)} placeholder="auth-service, gateway" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Datenklassen (komma-getrennt)</label>
<input value={dataClassInput} onChange={e => setDataClassInput(e.target.value)} placeholder="credentials, tokens" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
</div>
</div>
{/* Requirements */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs font-medium text-gray-600">Anforderungen</label>
<button onClick={() => setForm({ ...form, requirements: [...form.requirements, ''] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
</div>
{form.requirements.map((req, i) => (
<div key={i} className="flex gap-2 mb-2">
<span className="text-xs text-gray-400 mt-2.5">{i + 1}.</span>
<input
value={req}
onChange={e => { const r = [...form.requirements]; r[i] = e.target.value; setForm({ ...form, requirements: r }) }}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
/>
<button onClick={() => setForm({ ...form, requirements: form.requirements.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
{/* Test Procedure */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs font-medium text-gray-600">Pruefverfahren</label>
<button onClick={() => setForm({ ...form, test_procedure: [...form.test_procedure, ''] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
</div>
{form.test_procedure.map((step, i) => (
<div key={i} className="flex gap-2 mb-2">
<span className="text-xs text-gray-400 mt-2.5">{i + 1}.</span>
<input
value={step}
onChange={e => { const t = [...form.test_procedure]; t[i] = e.target.value; setForm({ ...form, test_procedure: t }) }}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
/>
<button onClick={() => setForm({ ...form, test_procedure: form.test_procedure.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
{/* Evidence */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs font-medium text-gray-600">Nachweisanforderungen</label>
<button onClick={() => setForm({ ...form, evidence: [...form.evidence, { type: '', description: '' }] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
</div>
{form.evidence.map((ev, i) => (
<div key={i} className="flex gap-2 mb-2">
<input
value={ev.type}
onChange={e => { const evs = [...form.evidence]; evs[i] = { ...evs[i], type: e.target.value }; setForm({ ...form, evidence: evs }) }}
placeholder="Typ (z.B. config, test_result)"
className="w-32 px-3 py-2 text-sm border border-gray-300 rounded-lg"
/>
<input
value={ev.description}
onChange={e => { const evs = [...form.evidence]; evs[i] = { ...evs[i], description: e.target.value }; setForm({ ...form, evidence: evs }) }}
placeholder="Beschreibung"
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
/>
<button onClick={() => setForm({ ...form, evidence: form.evidence.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
{/* Open Anchors */}
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-green-700" />
<label className="text-xs font-semibold text-green-900">Open-Source-Referenzen *</label>
</div>
<button onClick={() => setForm({ ...form, open_anchors: [...form.open_anchors, { framework: '', ref: '', url: '' }] })} className="text-xs text-green-700 hover:text-green-900">+ Hinzufuegen</button>
</div>
<p className="text-xs text-green-600 mb-3">Jedes Control braucht mindestens eine offene Referenz (OWASP, NIST, ENISA, etc.)</p>
{form.open_anchors.map((anchor, i) => (
<div key={i} className="flex gap-2 mb-2">
<input
value={anchor.framework}
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], framework: e.target.value }; setForm({ ...form, open_anchors: a }) }}
placeholder="Framework (z.B. OWASP ASVS)"
className="w-40 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
/>
<input
value={anchor.ref}
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], ref: e.target.value }; setForm({ ...form, open_anchors: a }) }}
placeholder="Referenz (z.B. V2.8)"
className="w-48 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
/>
<input
value={anchor.url}
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], url: e.target.value }; setForm({ ...form, open_anchors: a }) }}
placeholder="https://..."
className="flex-1 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
/>
<button onClick={() => setForm({ ...form, open_anchors: form.open_anchors.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
{/* Tags & State */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Tags (komma-getrennt)</label>
<input value={tagInput} onChange={e => setTagInput(e.target.value)} placeholder="mfa, auth, iam" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Status</label>
<select value={form.release_state} onChange={e => setForm({ ...form, release_state: e.target.value })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
<option value="draft">Draft</option>
<option value="review">Review</option>
<option value="approved">Approved</option>
<option value="deprecated">Deprecated</option>
</select>
</div>
</div>
{/* Verification Method, Category & Target Audience */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Nachweismethode</label>
<select
value={form.verification_method || ''}
onChange={e => setForm({ ...form, verification_method: e.target.value || null })}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg"
>
<option value=""> Nicht zugewiesen </option>
{Object.entries(VERIFICATION_METHODS).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
<p className="text-xs text-gray-400 mt-1">Wie wird dieses Control nachgewiesen?</p>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Kategorie</label>
<select
value={form.category || ''}
onChange={e => setForm({ ...form, category: e.target.value || null })}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg"
>
<option value=""> Nicht zugewiesen </option>
{CATEGORY_OPTIONS.map(c => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Zielgruppe</label>
<select
value={form.target_audience || ''}
onChange={e => setForm({ ...form, target_audience: e.target.value || null })}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg"
>
<option value=""> Nicht zugewiesen </option>
{Object.entries(TARGET_AUDIENCE_OPTIONS).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
<p className="text-xs text-gray-400 mt-1">Fuer wen ist dieses Control relevant?</p>
</div>
</div>
</div>
)
}

View File

@@ -1,222 +0,0 @@
'use client'
import { useState } from 'react'
import { Zap, X, RefreshCw, History, CheckCircle2 } from 'lucide-react'
import { BACKEND_URL, DOMAIN_OPTIONS, COLLECTION_OPTIONS } from './helpers'
interface GeneratorModalProps {
onClose: () => void
onComplete: () => void
}
export function GeneratorModal({ onClose, onComplete }: GeneratorModalProps) {
const [generating, setGenerating] = useState(false)
const [genResult, setGenResult] = useState<Record<string, unknown> | null>(null)
const [genDomain, setGenDomain] = useState('')
const [genMaxControls, setGenMaxControls] = useState(10)
const [genDryRun, setGenDryRun] = useState(true)
const [genCollections, setGenCollections] = useState<string[]>([])
const [showJobHistory, setShowJobHistory] = useState(false)
const [jobHistory, setJobHistory] = useState<Array<Record<string, unknown>>>([])
const handleGenerate = async () => {
setGenerating(true)
setGenResult(null)
try {
const res = await fetch(`${BACKEND_URL}?endpoint=generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
domain: genDomain || null,
collections: genCollections.length > 0 ? genCollections : null,
max_controls: genMaxControls,
dry_run: genDryRun,
skip_web_search: false,
}),
})
if (!res.ok) {
const err = await res.json()
setGenResult({ status: 'error', message: err.error || err.details || 'Fehler' })
return
}
const data = await res.json()
setGenResult(data)
if (!genDryRun) {
onComplete()
}
} catch {
setGenResult({ status: 'error', message: 'Netzwerkfehler' })
} finally {
setGenerating(false)
}
}
const loadJobHistory = async () => {
try {
const res = await fetch(`${BACKEND_URL}?endpoint=generate-jobs`)
if (res.ok) {
const data = await res.json()
setJobHistory(data.jobs || [])
}
} catch { /* ignore */ }
}
const toggleCollection = (col: string) => {
setGenCollections(prev =>
prev.includes(col) ? prev.filter(c => c !== col) : [...prev, col]
)
}
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 mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Zap className="w-5 h-5 text-amber-600" />
<h2 className="text-lg font-semibold text-gray-900">Control Generator</h2>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => { setShowJobHistory(!showJobHistory); if (!showJobHistory) loadJobHistory() }}
className="text-gray-400 hover:text-gray-600"
title="Job-Verlauf"
>
<History className="w-5 h-5" />
</button>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<X className="w-5 h-5" />
</button>
</div>
</div>
{showJobHistory ? (
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-700">Letzte Generierungs-Jobs</h3>
{jobHistory.length === 0 ? (
<p className="text-sm text-gray-400">Keine Jobs vorhanden.</p>
) : (
<div className="space-y-2 max-h-80 overflow-y-auto">
{jobHistory.map((job, i) => (
<div key={i} className="border border-gray-200 rounded-lg p-3 text-xs">
<div className="flex items-center justify-between mb-1">
<span className={`px-2 py-0.5 rounded font-medium ${
job.status === 'completed' ? 'bg-green-100 text-green-700' :
job.status === 'failed' ? 'bg-red-100 text-red-700' :
job.status === 'running' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-600'
}`}>
{String(job.status)}
</span>
<span className="text-gray-400">{String(job.created_at || '').slice(0, 16)}</span>
</div>
<div className="grid grid-cols-3 gap-1 text-gray-500 mt-1">
<span>Chunks: {String(job.total_chunks_scanned || 0)}</span>
<span>Generiert: {String(job.controls_generated || 0)}</span>
<span>Verifiziert: {String(job.controls_verified || 0)}</span>
</div>
</div>
))}
</div>
)}
<button
onClick={() => setShowJobHistory(false)}
className="w-full py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
>
Zurueck zum Generator
</button>
</div>
) : (
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Domain (optional)</label>
<select value={genDomain} onChange={e => setGenDomain(e.target.value)} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
<option value="">Alle Domains</option>
{DOMAIN_OPTIONS.map(d => (
<option key={d.value} value={d.value}>{d.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-2">Collections (optional)</label>
<div className="grid grid-cols-2 gap-1.5">
{COLLECTION_OPTIONS.map(col => (
<label key={col.value} className="flex items-center gap-2 text-xs text-gray-700 cursor-pointer">
<input
type="checkbox"
checked={genCollections.includes(col.value)}
onChange={() => toggleCollection(col.value)}
className="rounded border-gray-300"
/>
{col.label}
</label>
))}
</div>
{genCollections.length === 0 && (
<p className="text-xs text-gray-400 mt-1">Keine Auswahl = alle Collections</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Max. Controls: {genMaxControls}</label>
<input
type="range" min="1" max="100" step="1"
value={genMaxControls}
onChange={e => setGenMaxControls(parseInt(e.target.value))}
className="w-full"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="dryRun"
checked={genDryRun}
onChange={e => setGenDryRun(e.target.checked)}
className="rounded border-gray-300"
/>
<label htmlFor="dryRun" className="text-sm text-gray-700">Dry Run (Vorschau ohne Speicherung)</label>
</div>
<button
onClick={handleGenerate}
disabled={generating}
className="w-full py-2 text-sm text-white bg-amber-600 rounded-lg hover:bg-amber-700 disabled:opacity-50 flex items-center justify-center gap-2"
>
{generating ? (
<><RefreshCw className="w-4 h-4 animate-spin" /> Generiere...</>
) : (
<><Zap className="w-4 h-4" /> Generierung starten</>
)}
</button>
{/* Results */}
{genResult && (
<div className={`p-4 rounded-lg text-sm ${genResult.status === 'error' ? 'bg-red-50 text-red-800' : 'bg-green-50 text-green-800'}`}>
<div className="flex items-center gap-2 mb-1">
{genResult.status !== 'error' && <CheckCircle2 className="w-4 h-4" />}
<p className="font-medium">{String(genResult.message || genResult.status)}</p>
</div>
{genResult.status !== 'error' && (
<div className="grid grid-cols-2 gap-1 text-xs mt-2">
<span>Chunks gescannt: {String(genResult.total_chunks_scanned)}</span>
<span>Controls generiert: {String(genResult.controls_generated)}</span>
<span>Verifiziert: {String(genResult.controls_verified)}</span>
<span>Review noetig: {String(genResult.controls_needs_review)}</span>
<span>Zu aehnlich: {String(genResult.controls_too_close)}</span>
<span>Duplikate: {String(genResult.controls_duplicates_found)}</span>
</div>
)}
{Array.isArray(genResult.errors) && (genResult.errors as string[]).length > 0 && (
<div className="mt-2 text-xs text-red-600">
{(genResult.errors as string[]).slice(0, 3).map((e, i) => <p key={i}>{e}</p>)}
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -1,264 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import {
ArrowLeft, CheckCircle2, Trash2, Pencil, SkipForward,
ChevronLeft, Scale, BookOpen, ExternalLink, AlertTriangle,
FileText, Clock,
} from 'lucide-react'
import {
CanonicalControl, BACKEND_URL,
SeverityBadge, StateBadge, LicenseRuleBadge, CategoryBadge, TargetAudienceBadge,
} from './helpers'
// =============================================================================
// Compact Control Panel (used on both sides of the comparison)
// =============================================================================
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 */}
<div className={`sticky top-0 z-10 px-4 py-3 border-b ${highlight ? 'bg-yellow-100 border-yellow-200' : 'bg-gray-50 border-gray-200'}`}>
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-1">{label}</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-mono text-purple-600 bg-purple-50 px-2 py-0.5 rounded">{ctrl.control_id}</span>
<SeverityBadge severity={ctrl.severity} />
<StateBadge state={ctrl.release_state} />
<LicenseRuleBadge rule={ctrl.license_rule} />
<CategoryBadge category={ctrl.category} />
<TargetAudienceBadge audience={ctrl.target_audience} />
</div>
<h3 className="text-sm font-semibold text-gray-900 mt-1 leading-snug">{ctrl.title}</h3>
</div>
{/* Panel Content */}
<div className="p-4 space-y-4 text-sm">
{/* Objective */}
<section>
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Ziel</h4>
<p className="text-gray-700 leading-relaxed">{ctrl.objective}</p>
</section>
{/* Rationale */}
{ctrl.rationale && (
<section>
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Begruendung</h4>
<p className="text-gray-700 leading-relaxed">{ctrl.rationale}</p>
</section>
)}
{/* Source Citation (Rule 1+2) */}
{ctrl.source_citation && (
<section className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-center gap-1.5 mb-1">
<Scale className="w-3.5 h-3.5 text-blue-600" />
<span className="text-xs font-semibold text-blue-900">Gesetzliche Grundlage</span>
</div>
{ctrl.source_citation.source && (
<p className="text-xs text-blue-800">
{ctrl.source_citation.source}
{ctrl.source_citation.article && `${ctrl.source_citation.article}`}
{ctrl.source_citation.paragraph && ` ${ctrl.source_citation.paragraph}`}
</p>
)}
</section>
)}
{/* Requirements */}
{ctrl.requirements.length > 0 && (
<section>
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Anforderungen</h4>
<ol className="list-decimal list-inside space-y-1">
{ctrl.requirements.map((r, i) => (
<li key={i} className="text-gray-700 text-xs leading-relaxed">{r}</li>
))}
</ol>
</section>
)}
{/* Test Procedure */}
{ctrl.test_procedure.length > 0 && (
<section>
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Pruefverfahren</h4>
<ol className="list-decimal list-inside space-y-1">
{ctrl.test_procedure.map((s, i) => (
<li key={i} className="text-gray-700 text-xs leading-relaxed">{s}</li>
))}
</ol>
</section>
)}
{/* Open Anchors */}
{ctrl.open_anchors.length > 0 && (
<section className="bg-green-50 border border-green-200 rounded-lg p-3">
<div className="flex items-center gap-1.5 mb-2">
<BookOpen className="w-3.5 h-3.5 text-green-700" />
<span className="text-xs font-semibold text-green-900">Referenzen ({ctrl.open_anchors.length})</span>
</div>
<div className="space-y-1">
{ctrl.open_anchors.map((a, i) => (
<div key={i} className="flex items-center gap-1.5 text-xs">
<ExternalLink className="w-3 h-3 text-green-600 flex-shrink-0" />
<span className="font-medium text-green-800">{a.framework}</span>
<span className="text-green-700">{a.ref}</span>
</div>
))}
</div>
</section>
)}
{/* Tags */}
{ctrl.tags.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
{ctrl.tags.map(t => (
<span key={t} className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs">{t}</span>
))}
</div>
)}
</div>
</div>
)
}
// =============================================================================
// ReviewCompare — Side-by-Side Duplicate Comparison
// =============================================================================
interface ReviewCompareProps {
ctrl: CanonicalControl
onBack: () => void
onReview: (controlId: string, action: string) => void
onEdit: () => void
reviewIndex: number
reviewTotal: number
onReviewPrev: () => void
onReviewNext: () => void
}
export function ReviewCompare({
ctrl,
onBack,
onReview,
onEdit,
reviewIndex,
reviewTotal,
onReviewPrev,
onReviewNext,
}: ReviewCompareProps) {
const [suspectedDuplicate, setSuspectedDuplicate] = useState<CanonicalControl | null>(null)
const [loading, setLoading] = useState(false)
const [similarity, setSimilarity] = useState<number | null>(null)
// Load the suspected duplicate from generation_metadata.similar_controls
useEffect(() => {
const loadDuplicate = async () => {
const similarControls = ctrl.generation_metadata?.similar_controls as Array<{ control_id: string; title: string; similarity: number }> | undefined
if (!similarControls || similarControls.length === 0) {
setSuspectedDuplicate(null)
setSimilarity(null)
return
}
const suspect = similarControls[0]
setSimilarity(suspect.similarity)
setLoading(true)
try {
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${encodeURIComponent(suspect.control_id)}`)
if (res.ok) {
const data = await res.json()
setSuspectedDuplicate(data)
} else {
setSuspectedDuplicate(null)
}
} catch {
setSuspectedDuplicate(null)
} finally {
setLoading(false)
}
}
loadDuplicate()
}, [ctrl.control_id, ctrl.generation_metadata])
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">
<AlertTriangle className="w-4 h-4 text-amber-500" />
<span className="text-sm font-semibold text-gray-900">Duplikat-Vergleich</span>
{similarity !== null && (
<span className="text-xs font-medium text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full">
{(similarity * 100).toFixed(1)}% Aehnlichkeit
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{/* Navigation */}
<div className="flex items-center gap-1 mr-3">
<button onClick={onReviewPrev} disabled={reviewIndex === 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">{reviewIndex + 1} / {reviewTotal}</span>
<button onClick={onReviewNext} disabled={reviewIndex >= reviewTotal - 1} className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30">
<SkipForward className="w-4 h-4" />
</button>
</div>
{/* Actions */}
<button
onClick={() => onReview(ctrl.control_id, 'approve')}
className="px-3 py-1.5 text-sm text-white bg-green-600 rounded-lg hover:bg-green-700"
>
<CheckCircle2 className="w-3.5 h-3.5 inline mr-1" />Behalten
</button>
<button
onClick={() => onReview(ctrl.control_id, 'reject')}
className="px-3 py-1.5 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700"
>
<Trash2 className="w-3.5 h-3.5 inline mr-1" />Duplikat
</button>
<button
onClick={onEdit}
className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
>
<Pencil className="w-3.5 h-3.5 inline mr-1" />Bearbeiten
</button>
</div>
</div>
{/* Side-by-Side Panels */}
<div className="flex-1 flex overflow-hidden">
{/* Left: Control to review */}
<div className="w-1/2 border-r border-gray-200 overflow-y-auto">
<ControlPanel ctrl={ctrl} label="Zu pruefen" highlight />
</div>
{/* Right: Suspected duplicate */}
<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>
) : suspectedDuplicate ? (
<ControlPanel ctrl={suspectedDuplicate} label="Bestehendes Control (Verdacht)" />
) : (
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
Kein Duplikat-Kandidat gefunden
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,155 +0,0 @@
'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

@@ -1,408 +0,0 @@
import { AlertTriangle, CheckCircle2, Info } from 'lucide-react'
import React from 'react'
// =============================================================================
// TYPES
// =============================================================================
export interface OpenAnchor {
framework: string
ref: string
url: string
}
export interface EvidenceItem {
type: string
description: string
}
export interface CanonicalControl {
id: string
framework_id: string
control_id: string
title: string
objective: string
rationale: string
scope: {
platforms?: string[]
components?: string[]
data_classes?: string[]
}
requirements: string[]
test_procedure: string[]
evidence: (EvidenceItem | string)[]
severity: string
risk_score: number | null
implementation_effort: string | null
evidence_confidence: number | null
open_anchors: OpenAnchor[]
release_state: string
tags: string[]
license_rule?: number | null
source_original_text?: string | null
source_citation?: Record<string, string> | null
customer_visible?: boolean
verification_method: string | null
category: string | null
evidence_type: string | null
target_audience: string | string[] | null
generation_metadata?: Record<string, unknown> | null
generation_strategy?: string | null
parent_control_uuid?: string | null
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
}
export interface Framework {
id: string
framework_id: string
name: string
version: string
description: string
release_state: string
}
// =============================================================================
// CONSTANTS
// =============================================================================
export const BACKEND_URL = '/api/sdk/v1/canonical'
export const SEVERITY_CONFIG: Record<string, { bg: string; label: string; icon: React.ComponentType<{ className?: string }> }> = {
critical: { bg: 'bg-red-100 text-red-800', label: 'Kritisch', icon: AlertTriangle },
high: { bg: 'bg-orange-100 text-orange-800', label: 'Hoch', icon: AlertTriangle },
medium: { bg: 'bg-yellow-100 text-yellow-800', label: 'Mittel', icon: Info },
low: { bg: 'bg-green-100 text-green-800', label: 'Niedrig', icon: CheckCircle2 },
}
export const EFFORT_LABELS: Record<string, string> = {
s: 'Klein (S)',
m: 'Mittel (M)',
l: 'Gross (L)',
xl: 'Sehr gross (XL)',
}
export const EMPTY_CONTROL = {
framework_id: 'bp_security_v1',
control_id: '',
title: '',
objective: '',
rationale: '',
scope: { platforms: [] as string[], components: [] as string[], data_classes: [] as string[] },
requirements: [''],
test_procedure: [''],
evidence: [{ type: '', description: '' }],
severity: 'medium',
risk_score: null as number | null,
implementation_effort: 'm' as string | null,
open_anchors: [{ framework: '', ref: '', url: '' }],
release_state: 'draft',
tags: [] as string[],
verification_method: null as string | null,
category: null as string | null,
evidence_type: null as string | null,
target_audience: null as string | null,
}
export const DOMAIN_OPTIONS = [
{ value: 'AUTH', label: 'AUTH — Authentifizierung' },
{ value: 'CRYPT', label: 'CRYPT — Kryptographie' },
{ value: 'NET', label: 'NET — Netzwerk' },
{ value: 'DATA', label: 'DATA — Datenschutz' },
{ value: 'LOG', label: 'LOG — Logging' },
{ value: 'ACC', label: 'ACC — Zugriffskontrolle' },
{ value: 'SEC', label: 'SEC — Sicherheit' },
{ value: 'INC', label: 'INC — Incident Response' },
{ value: 'AI', label: 'AI — Kuenstliche Intelligenz' },
{ value: 'COMP', label: 'COMP — Compliance' },
]
export const VERIFICATION_METHODS: Record<string, { bg: string; label: string }> = {
code_review: { bg: 'bg-blue-100 text-blue-700', label: 'Code Review' },
document: { bg: 'bg-amber-100 text-amber-700', label: 'Dokument' },
tool: { bg: 'bg-teal-100 text-teal-700', label: 'Tool' },
hybrid: { bg: 'bg-purple-100 text-purple-700', label: 'Hybrid' },
}
export const CATEGORY_OPTIONS = [
{ value: 'encryption', label: 'Verschluesselung & Kryptographie' },
{ value: 'authentication', label: 'Authentisierung & Zugriffskontrolle' },
{ value: 'network', label: 'Netzwerksicherheit' },
{ value: 'data_protection', label: 'Datenschutz & Datensicherheit' },
{ value: 'logging', label: 'Logging & Monitoring' },
{ value: 'incident', label: 'Vorfallmanagement' },
{ value: 'continuity', label: 'Notfall & Wiederherstellung' },
{ value: 'compliance', label: 'Compliance & Audit' },
{ value: 'supply_chain', label: 'Lieferkettenmanagement' },
{ value: 'physical', label: 'Physische Sicherheit' },
{ value: 'personnel', label: 'Personal & Schulung' },
{ value: 'application', label: 'Anwendungssicherheit' },
{ value: 'system', label: 'Systemhaertung & -betrieb' },
{ value: 'risk', label: 'Risikomanagement' },
{ value: 'governance', label: 'Sicherheitsorganisation' },
{ value: 'hardware', label: 'Hardware & Plattformsicherheit' },
{ value: 'identity', label: 'Identitaetsmanagement' },
]
export const EVIDENCE_TYPE_CONFIG: Record<string, { bg: string; label: string }> = {
code: { bg: 'bg-sky-100 text-sky-700', label: 'Code' },
process: { bg: 'bg-amber-100 text-amber-700', label: 'Prozess' },
hybrid: { bg: 'bg-violet-100 text-violet-700', label: 'Hybrid' },
}
export const EVIDENCE_TYPE_OPTIONS = [
{ value: 'code', label: 'Code — Technisch (Source Code, IaC, CI/CD)' },
{ value: 'process', label: 'Prozess — Organisatorisch (Dokumente, Policies)' },
{ value: 'hybrid', label: 'Hybrid — Code + Prozess' },
]
export const TARGET_AUDIENCE_OPTIONS: Record<string, { bg: string; label: string }> = {
// Legacy English keys
enterprise: { bg: 'bg-cyan-100 text-cyan-700', label: 'Unternehmen' },
authority: { bg: 'bg-rose-100 text-rose-700', label: 'Behoerden' },
provider: { bg: 'bg-violet-100 text-violet-700', label: 'Anbieter' },
all: { bg: 'bg-gray-100 text-gray-700', label: 'Alle' },
// German keys from LLM generation
unternehmen: { bg: 'bg-cyan-100 text-cyan-700', label: 'Unternehmen' },
behoerden: { bg: 'bg-rose-100 text-rose-700', label: 'Behoerden' },
entwickler: { bg: 'bg-sky-100 text-sky-700', label: 'Entwickler' },
datenschutzbeauftragte: { bg: 'bg-purple-100 text-purple-700', label: 'DSB' },
geschaeftsfuehrung: { bg: 'bg-amber-100 text-amber-700', label: 'GF' },
'it-abteilung': { bg: 'bg-blue-100 text-blue-700', label: 'IT' },
rechtsabteilung: { bg: 'bg-fuchsia-100 text-fuchsia-700', label: 'Recht' },
'compliance-officer': { bg: 'bg-indigo-100 text-indigo-700', label: 'Compliance' },
personalwesen: { bg: 'bg-pink-100 text-pink-700', label: 'Personal' },
einkauf: { bg: 'bg-lime-100 text-lime-700', label: 'Einkauf' },
produktion: { bg: 'bg-orange-100 text-orange-700', label: 'Produktion' },
vertrieb: { bg: 'bg-teal-100 text-teal-700', label: 'Vertrieb' },
gesundheitswesen: { bg: 'bg-red-100 text-red-700', label: 'Gesundheit' },
finanzwesen: { bg: 'bg-emerald-100 text-emerald-700', label: 'Finanzen' },
oeffentlicher_dienst: { bg: 'bg-rose-100 text-rose-700', label: 'Oeffentl. Dienst' },
}
export const COLLECTION_OPTIONS = [
{ value: 'bp_compliance_ce', label: 'CE (OWASP, ENISA, BSI)' },
{ value: 'bp_compliance_gesetze', label: 'Gesetze (EU, DE, BSI)' },
{ value: 'bp_compliance_datenschutz', label: 'Datenschutz' },
{ value: 'bp_compliance_recht', label: 'Recht' },
{ value: 'bp_dsfa_corpus', label: 'DSFA Corpus' },
{ value: 'bp_legal_templates', label: 'Legal Templates' },
]
// =============================================================================
// BADGE COMPONENTS
// =============================================================================
export function SeverityBadge({ severity }: { severity: string }) {
const config = SEVERITY_CONFIG[severity] || SEVERITY_CONFIG.medium
const Icon = config.icon
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>
<Icon className="w-3 h-3" />
{config.label}
</span>
)
}
export function StateBadge({ state }: { state: string }) {
const config: Record<string, string> = {
draft: 'bg-gray-100 text-gray-600',
review: 'bg-blue-100 text-blue-700',
approved: 'bg-green-100 text-green-700',
deprecated: 'bg-red-100 text-red-600',
needs_review: 'bg-yellow-100 text-yellow-800',
too_close: 'bg-red-100 text-red-700',
duplicate: 'bg-orange-100 text-orange-700',
}
const labels: Record<string, string> = {
needs_review: 'Review noetig',
too_close: 'Zu aehnlich',
duplicate: 'Duplikat',
}
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config[state] || config.draft}`}>
{labels[state] || state}
</span>
)
}
export function LicenseRuleBadge({ rule }: { rule: number | null | undefined }) {
if (!rule) return null
const config: Record<number, { bg: string; label: string }> = {
1: { bg: 'bg-green-100 text-green-700', label: 'Free Use' },
2: { bg: 'bg-blue-100 text-blue-700', label: 'Zitation' },
3: { bg: 'bg-amber-100 text-amber-700', label: 'Reformuliert' },
}
const c = config[rule]
if (!c) return null
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${c.bg}`}>{c.label}</span>
}
export function VerificationMethodBadge({ method }: { method: string | null }) {
if (!method) return null
const config = VERIFICATION_METHODS[method]
if (!config) return null
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
}
export function CategoryBadge({ category }: { category: string | null }) {
if (!category) return null
const opt = CATEGORY_OPTIONS.find(c => c.value === category)
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-50 text-indigo-700">
{opt?.label || category}
</span>
)
}
export function EvidenceTypeBadge({ type }: { type: string | null }) {
if (!type) return null
const config = EVIDENCE_TYPE_CONFIG[type]
if (!config) return null
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
}
export function TargetAudienceBadge({ audience }: { audience: string | string[] | null }) {
if (!audience) return null
// Parse JSON array string from DB (e.g. '["unternehmen", "einkauf"]')
let items: string[] = []
if (typeof audience === 'string') {
if (audience.startsWith('[')) {
try { items = JSON.parse(audience) } catch { items = [audience] }
} else {
items = [audience]
}
} else if (Array.isArray(audience)) {
items = audience
}
if (items.length === 0) return null
return (
<span className="inline-flex items-center gap-1 flex-wrap">
{items.map((item, i) => {
const config = TARGET_AUDIENCE_OPTIONS[item]
if (!config) return <span key={i} className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600">{item}</span>
return <span key={i} className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
})}
</span>
)
}
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>
}
if (strategy === 'document_grouped') {
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-emerald-100 text-emerald-700">v2</span>
}
if (strategy === 'phase74_gap_fill') {
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700">v5 Gap</span>
}
if (strategy === 'pass0b_atomic' || strategy === 'pass0b') {
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-violet-100 text-violet-700">Atomar</span>
}
return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500">{strategy}</span>
}
export const OBLIGATION_TYPE_CONFIG: Record<string, { bg: string; label: string }> = {
pflicht: { bg: 'bg-red-100 text-red-700', label: 'Pflicht' },
empfehlung: { bg: 'bg-amber-100 text-amber-700', label: 'Empfehlung' },
kann: { bg: 'bg-green-100 text-green-700', label: 'Kann' },
}
export function ObligationTypeBadge({ type }: { type: string | null | undefined }) {
if (!type) return null
const config = OBLIGATION_TYPE_CONFIG[type]
if (!config) return null
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
}
export function getDomain(controlId: string): string {
return controlId.split('-')[0] || ''
}
// =============================================================================
// PROVENANCE TYPES
// =============================================================================
export interface ObligationInfo {
candidate_id: string
obligation_text: string
action: string | null
object: string | null
normative_strength: string
release_state: string
}
export interface DocumentReference {
regulation_code: string
article: string | null
paragraph: string | null
extraction_method: string
confidence: number | null
}
export interface MergedDuplicate {
control_id: string
title: string
source_regulation: string | null
}
export interface RegulationSummary {
regulation_code: string
articles: string[]
link_types: string[]
}
// =============================================================================
// PROVENANCE BADGES
// =============================================================================
const EXTRACTION_METHOD_CONFIG: Record<string, { bg: string; label: string }> = {
exact_match: { bg: 'bg-green-100 text-green-700', label: 'Exakt' },
embedding_match: { bg: 'bg-blue-100 text-blue-700', label: 'Embedding' },
llm_extracted: { bg: 'bg-violet-100 text-violet-700', label: 'LLM' },
inferred: { bg: 'bg-gray-100 text-gray-600', label: 'Abgeleitet' },
}
export function ExtractionMethodBadge({ method }: { method: string }) {
const config = EXTRACTION_METHOD_CONFIG[method] || EXTRACTION_METHOD_CONFIG.inferred
return <span className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${config.bg}`}>{config.label}</span>
}
export function RegulationCountBadge({ count }: { count: number }) {
if (count <= 0) return null
return (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-violet-100 text-violet-700">
{count} {count === 1 ? 'Regulierung' : 'Regulierungen'}
</span>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -72,132 +72,6 @@ aus geschuetzten Quellen uebernommen.
- **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',
@@ -205,41 +79,22 @@ Jedes freigegebene Control muss mindestens 1 Open Anchor haben.
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.
proprietaeren Frameworks unterscheidet:
### 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.
| Domain | Name | Abgrenzung |
|--------|------|------------|
| AUTH | Identity & Access Management | Eigene Struktur, nicht BSI O.Auth_* |
| NET | Network & Transport Security | Eigene Struktur, nicht BSI O.Netz_* |
| SUP | Software Supply Chain | NIST SSDF / SLSA-basiert |
| LOG | Security Operations & Logging | OWASP Logging Best Practices |
| WEB | Web Application Security | OWASP ASVS-basiert |
| DATA | Data Governance & Classification | NIST SP 800-60 basiert |
| CRYP | Cryptographic Operations | NIST SP 800-57 basiert |
| REL | Release & Change Governance | OWASP SAMM basiert |
### ID-Format
Control-IDs folgen dem Muster \`DOMAIN-NNN\` (z.B. AUTH-001, SEC-042). Dieses Format ist
Control-IDs folgen dem Muster \`DOMAIN-NNN\` (z.B. AUTH-001, NET-002). Dieses Format ist
**nicht von BSI oder anderen proprietaeren Standards abgeleitet**, sondern folgt einem
allgemein ueblichen Nummerierungsschema.`,
},
@@ -304,104 +159,6 @@ Kein Text, keine Struktur, keine Bezeichner aus diesen Quellen erscheinen im Pro
| 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',

View File

@@ -196,15 +196,7 @@ function ControlCard({
{/* 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>
<span className="text-xs text-gray-500 mb-1 block">Nachweise:</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 ${
@@ -213,9 +205,6 @@ function ControlCard({
'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>
@@ -370,49 +359,6 @@ interface RAGControlSuggestion {
// 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>
)
}
export default function ControlsPage() {
const { state, dispatch } = useSDK()
const router = useRouter()
@@ -427,9 +373,6 @@ export default function ControlsPage() {
const [showRagPanel, setShowRagPanel] = useState(false)
const [selectedRequirementId, setSelectedRequirementId] = useState<string>('')
// 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
@@ -442,7 +385,7 @@ export default function ControlsPage() {
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 }[]> = {}
const map: Record<string, { id: string; title: string; status: string }[]> = {}
for (const ev of allEvidence) {
const ctrlId = ev.control_id || ''
if (!map[ctrlId]) map[ctrlId] = []
@@ -450,7 +393,6 @@ export default function ControlsPage() {
id: ev.id,
title: ev.title || ev.name || 'Nachweis',
status: ev.status || 'pending',
confidenceLevel: ev.confidence_level || undefined,
})
}
setEvidenceMap(map)
@@ -541,56 +483,20 @@ 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
const handleStatusChange = async (controlId: string, status: ImplementationStatus) => {
dispatch({
type: 'UPDATE_CONTROL',
payload: { id: controlId, data: { implementationStatus: newStatus } },
payload: { id: controlId, data: { implementationStatus: status } },
})
try {
const res = await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ implementation_status: newStatus }),
body: JSON.stringify({ implementation_status: status }),
})
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')
// Silently fail — SDK state is already updated
}
}
@@ -839,15 +745,6 @@ export default function ControlsPage() {
</div>
)}
{/* Transition Error Banner (Anti-Fake-Evidence 409 violations) */}
{transitionError && (
<TransitionErrorBanner
controlId={transitionError.controlId}
violations={transitionError.violations}
onDismiss={() => setTransitionError(null)}
/>
)}
{/* Requirements Alert */}
{state.requirements.length === 0 && !loading && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">

View File

@@ -32,28 +32,18 @@ import {
const CATEGORIES: { key: string; label: string; types: string[] | null }[] = [
{ key: 'all', label: 'Alle', types: null },
// Legal / Vertragsvorlagen
{ key: 'privacy_policy', label: 'Datenschutz', types: ['privacy_policy'] },
{ key: 'terms', label: 'AGB', types: ['terms_of_service', 'agb', 'clause'] },
{ key: 'impressum', label: 'Impressum', types: ['impressum'] },
{ key: 'dpa', label: 'AVV/DPA', types: ['dpa'] },
{ key: 'nda', label: 'NDA', types: ['nda'] },
{ key: 'sla', label: 'SLA', types: ['sla'] },
{ key: 'acceptable_use', label: 'AUP', types: ['acceptable_use'] },
{ key: 'widerruf', label: 'Widerruf', types: ['widerruf'] },
{ key: 'cookie', label: 'Cookie', types: ['cookie_policy', 'cookie_banner'] },
{ key: 'cloud', label: 'Cloud', types: ['cloud_service_agreement'] },
{ key: 'misc', label: 'Weitere', types: ['community_guidelines', 'copyright_policy', 'data_usage_clause'] },
{ key: 'dsfa', label: 'DSFA', types: ['dsfa'] },
// Sicherheitskonzepte (Migration 051)
{ key: 'security', label: 'Sicherheitskonzepte', types: ['it_security_concept', 'data_protection_concept', 'backup_recovery_concept', 'logging_concept', 'incident_response_plan', 'access_control_concept', 'risk_management_concept', 'cybersecurity_policy'] },
// Policy-Bibliothek (Migration 071/072)
{ key: 'it_security_policies', label: 'IT-Sicherheit Policies', types: ['information_security_policy', 'access_control_policy', 'password_policy', 'encryption_policy', 'logging_policy', 'backup_policy', 'incident_response_policy', 'change_management_policy', 'patch_management_policy', 'asset_management_policy', 'cloud_security_policy', 'devsecops_policy', 'secrets_management_policy', 'vulnerability_management_policy'] },
{ key: 'data_policies', label: 'Daten-Policies', types: ['data_protection_policy', 'data_classification_policy', 'data_retention_policy', 'data_transfer_policy', 'privacy_incident_policy'] },
{ key: 'hr_policies', label: 'Personal-Policies', types: ['employee_security_policy', 'security_awareness_policy', 'acceptable_use', 'remote_work_policy', 'offboarding_policy'] },
{ key: 'vendor_policies', label: 'Lieferanten-Policies', types: ['vendor_risk_management_policy', 'third_party_security_policy', 'supplier_security_policy'] },
{ key: 'bcm_policies', label: 'BCM/Notfall', types: ['business_continuity_policy', 'disaster_recovery_policy', 'crisis_management_policy'] },
// Modul-Dokumente (Migration 073)
{ key: 'module_docs', label: 'DSGVO-Dokumente', types: ['vvt_register', 'tom_documentation', 'loeschkonzept', 'pflichtenregister'] },
]
// =============================================================================

View File

@@ -1,111 +0,0 @@
"use client"
import React from "react"
const badgeBase = "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
// ---------------------------------------------------------------------------
// Confidence Level Badge (E0E4)
// ---------------------------------------------------------------------------
const confidenceColors: Record<string, string> = {
E0: "bg-red-100 text-red-800",
E1: "bg-yellow-100 text-yellow-800",
E2: "bg-blue-100 text-blue-800",
E3: "bg-green-100 text-green-800",
E4: "bg-emerald-100 text-emerald-800",
}
const confidenceLabels: Record<string, string> = {
E0: "E0 — Generiert",
E1: "E1 — Manuell",
E2: "E2 — Intern validiert",
E3: "E3 — System-beobachtet",
E4: "E4 — Extern auditiert",
}
export function ConfidenceLevelBadge({ level }: { level?: string | null }) {
if (!level) return null
const color = confidenceColors[level] || "bg-gray-100 text-gray-800"
const label = confidenceLabels[level] || level
return <span className={`${badgeBase} ${color}`}>{label}</span>
}
// ---------------------------------------------------------------------------
// Truth Status Badge
// ---------------------------------------------------------------------------
const truthColors: Record<string, string> = {
generated: "bg-violet-100 text-violet-800",
uploaded: "bg-gray-100 text-gray-800",
observed: "bg-blue-100 text-blue-800",
validated: "bg-green-100 text-green-800",
rejected: "bg-red-100 text-red-800",
audited: "bg-emerald-100 text-emerald-800",
}
const truthLabels: Record<string, string> = {
generated: "Generiert",
uploaded: "Hochgeladen",
observed: "Beobachtet",
validated: "Validiert",
rejected: "Abgelehnt",
audited: "Auditiert",
}
export function TruthStatusBadge({ status }: { status?: string | null }) {
if (!status) return null
const color = truthColors[status] || "bg-gray-100 text-gray-800"
const label = truthLabels[status] || status
return <span className={`${badgeBase} ${color}`}>{label}</span>
}
// ---------------------------------------------------------------------------
// Generation Mode Badge (sparkles icon)
// ---------------------------------------------------------------------------
export function GenerationModeBadge({ mode }: { mode?: string | null }) {
if (!mode) return null
return (
<span className={`${badgeBase} bg-violet-100 text-violet-800`}>
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path d="M5 2a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0V6H3a1 1 0 010-2h1V3a1 1 0 011-1zm0 10a1 1 0 011 1v1h1a1 1 0 010 2H6v1a1 1 0 01-2 0v-1H3a1 1 0 010-2h1v-1a1 1 0 011-1zm7-10a1 1 0 01.967.744L14.146 7.2 17.5 7.512a1 1 0 010 1.976l-3.354.313-1.18 4.456a1 1 0 01-1.932 0l-1.18-4.456-3.354-.313a1 1 0 010-1.976l3.354-.313 1.18-4.456A1 1 0 0112 2z" />
</svg>
KI-generiert
</span>
)
}
// ---------------------------------------------------------------------------
// Approval Status Badge (Four-Eyes)
// ---------------------------------------------------------------------------
const approvalColors: Record<string, string> = {
none: "bg-gray-100 text-gray-600",
pending_first: "bg-yellow-100 text-yellow-800",
first_approved: "bg-blue-100 text-blue-800",
approved: "bg-green-100 text-green-800",
rejected: "bg-red-100 text-red-800",
}
const approvalLabels: Record<string, string> = {
none: "Kein Review",
pending_first: "Warte auf 1. Review",
first_approved: "1. Review OK",
approved: "Genehmigt (4-Augen)",
rejected: "Abgelehnt",
}
export function ApprovalStatusBadge({
status,
requiresFourEyes,
}: {
status?: string | null
requiresFourEyes?: boolean | null
}) {
if (!requiresFourEyes) return null
const s = status || "none"
const color = approvalColors[s] || "bg-gray-100 text-gray-600"
const label = approvalLabels[s] || s
return <span className={`${badgeBase} ${color}`}>{label}</span>
}

File diff suppressed because it is too large Load Diff

View File

@@ -12,46 +12,6 @@ interface Component {
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 = [
@@ -138,11 +98,6 @@ function ComponentTreeNode({
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 && (
@@ -334,289 +289,6 @@ function buildTree(components: Component[]): Component[] {
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
// ============================================================================
export default function ComponentsPage() {
const params = useParams()
const projectId = params.projectId as string
@@ -625,7 +297,6 @@ export default function ComponentsPage() {
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()
@@ -694,32 +365,6 @@ export default function ComponentsPage() {
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) {
@@ -741,41 +386,22 @@ export default function ComponentsPage() {
</p>
</div>
{!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"
>
<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"
>
<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>
Komponente hinzufuegen
</button>
</div>
<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"
>
<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>
Komponente hinzufuegen
</button>
)}
</div>
{/* Library Modal */}
{showLibrary && (
<ComponentLibraryModal
onAdd={handleAddFromLibrary}
onClose={() => setShowLibrary(false)}
/>
)}
{/* Form */}
{showForm && (
<ComponentForm
@@ -828,20 +454,12 @@ export default function ComponentsPage() {
Beginnen Sie mit der Erfassung aller relevanten Komponenten Ihrer Maschine.
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"
>
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"
>
Manuell hinzufuegen
</button>
</div>
<button
onClick={() => setShowForm(true)}
className="mt-6 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Erste Komponente hinzufuegen
</button>
</div>
)
)}

File diff suppressed because it is too large Load Diff

View File

@@ -14,51 +14,20 @@ interface Mitigation {
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',
label: '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" />
@@ -66,21 +35,10 @@ const REDUCTION_TYPES = {
),
},
protection: {
label: 'Stufe 2: Schutz',
label: '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" />
@@ -88,18 +46,10 @@ const REDUCTION_TYPES = {
),
},
information: {
label: 'Stufe 3: Information',
label: '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" />
@@ -126,281 +76,6 @@ function StatusBadge({ status }: { status: string }) {
)
}
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
@@ -413,13 +88,11 @@ function MitigationForm({
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: '',
@@ -439,15 +112,7 @@ function MitigationForm({
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>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Neue Massnahme</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
@@ -467,9 +132,9 @@ function MitigationForm({
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>
<option value="design">Design - Inhaerent sichere Konstruktion</option>
<option value="protection">Schutz - Technische Schutzmassnahmen</option>
<option value="information">Information - Hinweise und Schulungen</option>
</select>
</div>
</div>
@@ -536,14 +201,7 @@ function MitigationCard({
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>
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title}</h4>
<StatusBadge status={mitigation.status} />
</div>
{mitigation.description && (
@@ -588,12 +246,6 @@ export default function MitigationsPage() {
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)
useEffect(() => {
fetchData()
@@ -607,14 +259,11 @@ export default function MitigationsPage() {
])
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)
setMitigations(json.mitigations || json || [])
}
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 })))
setHazards((json.hazards || json || []).map((h: Hazard) => ({ id: h.id, name: h.name, risk_level: h.risk_level })))
}
} catch (err) {
console.error('Failed to fetch data:', err)
@@ -623,55 +272,6 @@ export default function MitigationsPage() {
}
}
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`, {
@@ -689,26 +289,6 @@ export default function MitigationsPage() {
}
}
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`, {
@@ -761,50 +341,23 @@ export default function MitigationsPage() {
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Massnahmen</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Risikominderung nach dem 3-Stufen-Verfahren: Design &rarr; Schutz &rarr; Information.
Risikominderung nach dem 3-Stufen-Verfahren: Design, Schutz, Information.
</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"
>
<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"
>
<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"
>
<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>
Massnahme hinzufuegen
</button>
</div>
<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"
>
<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>
Massnahme hinzufuegen
</button>
</div>
{/* Hierarchy Warning */}
{hierarchyWarning && (
<HierarchyWarning onDismiss={() => setHierarchyWarning(false)} />
)}
{/* Form */}
{showForm && (
<MitigationForm
@@ -815,27 +368,6 @@ export default function MitigationsPage() {
}}
hazards={hazards}
preselectedType={preselectedType}
onOpenLibrary={handleOpenLibrary}
/>
)}
{/* Measures Library Modal */}
{showLibrary && (
<MeasuresLibraryModal
measures={measures}
onSelect={handleSelectMeasure}
onClose={() => setShowLibrary(false)}
filterType={libraryFilter}
/>
)}
{/* Suggest Measures Modal (Phase 5) */}
{showSuggest && (
<SuggestMeasuresModal
hazards={hazards}
projectId={projectId}
onAddMeasure={handleAddSuggestedMeasure}
onClose={() => setShowSuggest(false)}
/>
)}
@@ -846,7 +378,7 @@ export default function MitigationsPage() {
const items = 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`}>
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-4`}>
{config.icon}
<div>
<h3 className="text-sm font-semibold">{config.label}</h3>
@@ -855,15 +387,6 @@ export default function MitigationsPage() {
<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">
{st.label}
</span>
))}
</div>
<div className="space-y-3">
{items.map((m) => (
<MitigationCard
@@ -875,23 +398,12 @@ export default function MitigationsPage() {
))}
</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"
>
+ Hinzufuegen
</button>
<button
onClick={() => 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"
>
<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>
</button>
</div>
<button
onClick={() => handleAddForType(type)}
className="mt-3 w-full 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"
>
+ Massnahme hinzufuegen
</button>
</div>
)
})}

View File

@@ -1,8 +1,7 @@
'use client'
import React, { useState, useEffect, useRef } from 'react'
import React, { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import { TechFileEditor } from '@/components/sdk/iace/TechFileEditor'
interface TechFileSection {
id: string
@@ -68,14 +67,6 @@ const STATUS_CONFIG: Record<string, { label: string; color: string; bgColor: str
approved: { label: 'Freigegeben', color: 'text-green-700', bgColor: 'bg-green-100' },
}
const EXPORT_FORMATS: { value: string; label: string; extension: string }[] = [
{ value: 'pdf', label: 'PDF', extension: '.pdf' },
{ value: 'xlsx', label: 'Excel', extension: '.xlsx' },
{ value: 'docx', label: 'Word', extension: '.docx' },
{ value: 'md', label: 'Markdown', extension: '.md' },
{ value: 'json', label: 'JSON', extension: '.json' },
]
function StatusBadge({ status }: { status: string }) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.empty
return (
@@ -96,6 +87,7 @@ function SectionViewer({
onApprove: (id: string) => void
onSave: (id: string, content: string) => void
}) {
const [editedContent, setEditedContent] = useState(section.content || '')
const [editing, setEditing] = useState(false)
return (
@@ -119,10 +111,13 @@ function SectionViewer({
)}
{editing && (
<button
onClick={() => setEditing(false)}
className="text-sm px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
onClick={() => {
onSave(section.id, editedContent)
setEditing(false)
}}
className="text-sm px-3 py-1.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Fertig
Speichern
</button>
)}
{section.status !== 'approved' && section.content && !editing && (
@@ -141,19 +136,19 @@ function SectionViewer({
</div>
</div>
<div className="p-6">
{section.content ? (
editing ? (
<TechFileEditor
content={section.content}
onSave={(html) => onSave(section.id, html)}
/>
) : (
<TechFileEditor
content={section.content}
onSave={() => {}}
readOnly
/>
)
{editing ? (
<textarea
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
rows={20}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent font-mono text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
) : section.content ? (
<div className="prose prose-sm max-w-none dark:prose-invert">
<pre className="whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-750 p-4 rounded-lg">
{section.content}
</pre>
</div>
) : (
<div className="text-center py-8 text-gray-500">
Kein Inhalt vorhanden. Klicken Sie &quot;Generieren&quot; um den Abschnitt zu erstellen.
@@ -172,21 +167,6 @@ export default function TechFilePage() {
const [generatingSection, setGeneratingSection] = useState<string | null>(null)
const [viewingSection, setViewingSection] = useState<TechFileSection | null>(null)
const [exporting, setExporting] = useState(false)
const [showExportMenu, setShowExportMenu] = useState(false)
const exportMenuRef = useRef<HTMLDivElement>(null)
// Close export menu when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (exportMenuRef.current && !exportMenuRef.current.contains(event.target as Node)) {
setShowExportMenu(false)
}
}
if (showExportMenu) {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}
}, [showExportMenu])
useEffect(() => {
fetchSections()
@@ -256,22 +236,18 @@ export default function TechFilePage() {
}
}
async function handleExport(format: string) {
async function handleExportZip() {
setExporting(true)
setShowExportMenu(false)
try {
const res = await fetch(
`/api/sdk/v1/iace/projects/${projectId}/tech-file/export?format=${format}`,
{ method: 'GET' }
)
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/tech-file/export`, {
method: 'POST',
})
if (res.ok) {
const blob = await res.blob()
const url = window.URL.createObjectURL(blob)
const formatConfig = EXPORT_FORMATS.find((f) => f.value === format)
const extension = formatConfig?.extension || `.${format}`
const a = document.createElement('a')
a.href = url
a.download = `CE-Akte-${projectId}${extension}`
a.download = `CE-Akte-${projectId}.zip`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
@@ -308,45 +284,25 @@ export default function TechFilePage() {
Sie alle erforderlichen Abschnitte.
</p>
</div>
{/* Export Dropdown */}
<div className="relative" ref={exportMenuRef}>
<button
onClick={() => setShowExportMenu((prev) => !prev)}
disabled={!allRequiredApproved || exporting}
title={!allRequiredApproved ? 'Alle Pflichtabschnitte muessen freigegeben sein' : 'CE-Akte exportieren'}
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
allRequiredApproved && !exporting
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
{exporting ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
)}
Exportieren
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
<button
onClick={handleExportZip}
disabled={!allRequiredApproved || exporting}
title={!allRequiredApproved ? 'Alle Pflichtabschnitte muessen freigegeben sein' : 'CE-Akte als ZIP exportieren'}
className={`flex items-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors ${
allRequiredApproved && !exporting
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
{exporting ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
) : (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</button>
{showExportMenu && allRequiredApproved && !exporting && (
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50">
{EXPORT_FORMATS.map((fmt) => (
<button
key={fmt.value}
onClick={() => handleExport(fmt.value)}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex items-center gap-3"
>
<span className="text-xs font-mono uppercase w-10 text-gray-400">{fmt.extension}</span>
<span>{fmt.label}</span>
</button>
))}
</div>
)}
</div>
ZIP exportieren
</button>
</div>
{/* Progress */}

View File

@@ -19,25 +19,14 @@ 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' },
{ value: 'test', label: 'Test' },
{ value: 'analysis', label: 'Analyse' },
{ value: 'inspection', label: 'Inspektion' },
{ value: 'simulation', label: 'Simulation' },
{ value: 'review', label: 'Review' },
{ value: 'demonstration', label: 'Demonstration' },
{ value: 'certification', label: 'Zertifizierung' },
]
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
@@ -249,130 +238,6 @@ function CompleteModal({
)
}
// ============================================================================
// 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,8 +247,6 @@ 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()
@@ -431,26 +294,6 @@ export default function VerificationPage() {
}
}
async function handleAddSuggestedEvidence(title: string, description: string, method: string, mitigationId: string) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
description,
method,
linked_mitigation_id: mitigationId,
}),
})
if (res.ok) {
await fetchData()
}
} catch (err) {
console.error('Failed to add suggested evidence:', err)
}
}
async function handleComplete(id: string, result: string, passed: boolean) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}/complete`, {
@@ -501,28 +344,15 @@ export default function VerificationPage() {
Nachweisfuehrung fuer alle Schutzmassnahmen und Sicherheitsanforderungen.
</p>
</div>
<div className="flex items-center gap-2">
{mitigations.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"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
Nachweise vorschlagen
</button>
)}
<button
onClick={() => setShowForm(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Verifikation hinzufuegen
</button>
</div>
<button
onClick={() => setShowForm(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Verifikation hinzufuegen
</button>
</div>
{/* Stats */}
@@ -566,16 +396,6 @@ export default function VerificationPage() {
/>
)}
{/* Suggest Evidence Modal (Phase 5) */}
{showSuggest && (
<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">
@@ -649,22 +469,12 @@ export default function VerificationPage() {
Definieren Sie Verifikationsschritte fuer Ihre Schutzmassnahmen.
Jede Massnahme sollte durch mindestens eine Verifikation abgedeckt sein.
</p>
<div className="mt-6 flex items-center justify-center gap-3">
{mitigations.length > 0 && (
<button
onClick={() => 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"
>
Erste Verifikation anlegen
</button>
</div>
<button
onClick={() => setShowForm(true)}
className="mt-6 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Erste Verifikation anlegen
</button>
</div>
)
)}

View File

@@ -2,6 +2,7 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import {
LoeschfristPolicy, LegalHold, StorageLocation,
@@ -14,6 +15,7 @@ import {
formatRetentionDuration, isPolicyOverdue, getActiveLegalHolds,
getEffectiveDeletionTrigger,
} from '@/lib/sdk/loeschfristen-types'
import { BASELINE_TEMPLATES, templateToPolicy, getTemplateById, getAllTemplateTags } from '@/lib/sdk/loeschfristen-baseline-catalog'
import {
PROFILING_STEPS, ProfilingAnswer, ProfilingStep,
isStepComplete, getProfilingProgress, generatePoliciesFromProfile,
@@ -25,18 +27,12 @@ import {
exportPoliciesAsJSON, exportPoliciesAsCSV,
generateComplianceSummary, downloadFile,
} from '@/lib/sdk/loeschfristen-export'
import {
buildLoeschkonzeptHtml,
type LoeschkonzeptOrgHeader,
type LoeschkonzeptRevision,
createDefaultLoeschkonzeptOrgHeader,
} from '@/lib/sdk/loeschfristen-document'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type Tab = 'uebersicht' | 'editor' | 'generator' | 'export' | 'loeschkonzept'
type Tab = 'uebersicht' | 'editor' | 'generator' | 'export'
// ---------------------------------------------------------------------------
// Helper: TagInput
@@ -105,6 +101,7 @@ function TagInput({
export default function LoeschfristenPage() {
const router = useRouter()
const sdk = useSDK()
// ---- Core state ----
const [tab, setTab] = useState<Tab>('uebersicht')
@@ -124,19 +121,15 @@ export default function LoeschfristenPage() {
// ---- Compliance state ----
const [complianceResult, setComplianceResult] = useState<ComplianceCheckResult | null>(null)
// ---- Legal Hold management ----
const [managingLegalHolds, setManagingLegalHolds] = useState(false)
// ---- Saving state ----
const [saving, setSaving] = useState(false)
// ---- VVT data ----
const [vvtActivities, setVvtActivities] = useState<any[]>([])
// ---- Vendor data ----
const [vendorList, setVendorList] = useState<Array<{id: string, name: string}>>([])
// ---- Loeschkonzept document state ----
const [orgHeader, setOrgHeader] = useState<LoeschkonzeptOrgHeader>(createDefaultLoeschkonzeptOrgHeader())
const [revisions, setRevisions] = useState<LoeschkonzeptRevision[]>([])
// --------------------------------------------------------------------------
// Persistence (API-backed)
// --------------------------------------------------------------------------
@@ -191,7 +184,6 @@ export default function LoeschfristenPage() {
responsiblePerson: raw.responsible_person || '',
releaseProcess: raw.release_process || '',
linkedVVTActivityIds: raw.linked_vvt_activity_ids || [],
linkedVendorIds: raw.linked_vendor_ids || [],
status: raw.status || 'DRAFT',
lastReviewDate: raw.last_review_date || base.lastReviewDate,
nextReviewDate: raw.next_review_date || base.nextReviewDate,
@@ -226,7 +218,6 @@ export default function LoeschfristenPage() {
responsible_person: p.responsiblePerson,
release_process: p.releaseProcess,
linked_vvt_activity_ids: p.linkedVVTActivityIds,
linked_vendor_ids: p.linkedVendorIds,
status: p.status,
last_review_date: p.lastReviewDate || null,
next_review_date: p.nextReviewDate || null,
@@ -256,59 +247,6 @@ export default function LoeschfristenPage() {
})
}, [tab, editingId])
// Load vendor list from API
useEffect(() => {
fetch('/api/sdk/v1/vendor-compliance/vendors?limit=500')
.then(r => r.ok ? r.json() : null)
.then(data => {
const items = data?.data?.items || []
setVendorList(items.map((v: any) => ({ id: v.id, name: v.name })))
})
.catch(() => {})
}, [])
// Load Loeschkonzept org header from VVT organization data + revisions from localStorage
useEffect(() => {
// Load revisions from localStorage
try {
const raw = localStorage.getItem('bp_loeschkonzept_revisions')
if (raw) {
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) setRevisions(parsed)
}
} catch { /* ignore */ }
// Load org header from localStorage (user overrides)
try {
const raw = localStorage.getItem('bp_loeschkonzept_orgheader')
if (raw) {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
setOrgHeader(prev => ({ ...prev, ...parsed }))
return // User has saved org header, skip VVT fetch
}
}
} catch { /* ignore */ }
// Fallback: fetch from VVT organization API
fetch('/api/sdk/v1/compliance/vvt/organization')
.then(res => res.ok ? res.json() : null)
.then(data => {
if (data) {
setOrgHeader(prev => ({
...prev,
organizationName: data.organization_name || data.organizationName || prev.organizationName,
industry: data.industry || prev.industry,
dpoName: data.dpo_name || data.dpoName || prev.dpoName,
dpoContact: data.dpo_contact || data.dpoContact || prev.dpoContact,
responsiblePerson: data.responsible_person || data.responsiblePerson || prev.responsiblePerson,
employeeCount: data.employee_count || data.employeeCount || prev.employeeCount,
}))
}
})
.catch(() => { /* ignore */ })
}, [])
// --------------------------------------------------------------------------
// Derived
// --------------------------------------------------------------------------
@@ -551,7 +489,6 @@ export default function LoeschfristenPage() {
{ key: 'editor', label: 'Editor' },
{ key: 'generator', label: 'Generator' },
{ key: 'export', label: 'Export & Compliance' },
{ key: 'loeschkonzept', label: 'Loeschkonzept' },
]
// --------------------------------------------------------------------------
@@ -1418,13 +1355,13 @@ export default function LoeschfristenPage() {
Verarbeitungstaetigkeit aus Ihrem VVT.
</p>
<div className="space-y-2">
{policy.linkedVVTActivityIds && policy.linkedVVTActivityIds.length > 0 && (
{policy.linkedVvtIds && policy.linkedVvtIds.length > 0 && (
<div className="mb-3">
<label className="block text-xs font-medium text-gray-500 mb-1">
Verknuepfte Taetigkeiten:
</label>
<div className="flex flex-wrap gap-1">
{policy.linkedVVTActivityIds.map((vvtId: string) => {
{policy.linkedVvtIds.map((vvtId: string) => {
const activity = vvtActivities.find(
(a: any) => a.id === vvtId,
)
@@ -1439,8 +1376,8 @@ export default function LoeschfristenPage() {
onClick={() =>
updatePolicy(pid, (p) => ({
...p,
linkedVVTActivityIds: (
p.linkedVVTActivityIds || []
linkedVvtIds: (
p.linkedVvtIds || []
).filter((id: string) => id !== vvtId),
}))
}
@@ -1459,11 +1396,11 @@ export default function LoeschfristenPage() {
const val = e.target.value
if (
val &&
!(policy.linkedVVTActivityIds || []).includes(val)
!(policy.linkedVvtIds || []).includes(val)
) {
updatePolicy(pid, (p) => ({
...p,
linkedVVTActivityIds: [...(p.linkedVVTActivityIds || []), val],
linkedVvtIds: [...(p.linkedVvtIds || []), val],
}))
}
e.target.value = ''
@@ -1476,7 +1413,7 @@ export default function LoeschfristenPage() {
{vvtActivities
.filter(
(a: any) =>
!(policy.linkedVVTActivityIds || []).includes(a.id),
!(policy.linkedVvtIds || []).includes(a.id),
)
.map((a: any) => (
<option key={a.id} value={a.id}>
@@ -1495,95 +1432,6 @@ export default function LoeschfristenPage() {
)}
</div>
{/* Sektion 5b: Auftragsverarbeiter-Verknuepfung */}
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
5b. Verknuepfte Auftragsverarbeiter
</h3>
{vendorList.length > 0 ? (
<div>
<p className="text-sm text-gray-500 mb-3">
Verknuepfen Sie diese Loeschfrist mit relevanten Auftragsverarbeitern.
</p>
<div className="space-y-2">
{policy.linkedVendorIds && policy.linkedVendorIds.length > 0 && (
<div className="mb-3">
<label className="block text-xs font-medium text-gray-500 mb-1">
Verknuepfte Auftragsverarbeiter:
</label>
<div className="flex flex-wrap gap-1">
{policy.linkedVendorIds.map((vendorId: string) => {
const vendor = vendorList.find(
(v) => v.id === vendorId,
)
return (
<span
key={vendorId}
className="inline-flex items-center gap-1 bg-orange-100 text-orange-800 text-xs font-medium px-2 py-0.5 rounded-full"
>
{vendor?.name || vendorId}
<button
type="button"
onClick={() =>
updatePolicy(pid, (p) => ({
...p,
linkedVendorIds: (
p.linkedVendorIds || []
).filter((id: string) => id !== vendorId),
}))
}
className="text-orange-600 hover:text-orange-900"
>
x
</button>
</span>
)
})}
</div>
</div>
)}
<select
onChange={(e) => {
const val = e.target.value
if (
val &&
!(policy.linkedVendorIds || []).includes(val)
) {
updatePolicy(pid, (p) => ({
...p,
linkedVendorIds: [...(p.linkedVendorIds || []), val],
}))
}
e.target.value = ''
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="">
Auftragsverarbeiter verknuepfen...
</option>
{vendorList
.filter(
(v) =>
!(policy.linkedVendorIds || []).includes(v.id),
)
.map((v) => (
<option key={v.id} value={v.id}>
{v.name || v.id}
</option>
))}
</select>
</div>
</div>
) : (
<p className="text-sm text-gray-400">
Keine Auftragsverarbeiter gefunden. Erstellen Sie zuerst
Auftragsverarbeiter im Vendor-Compliance-Modul, um hier Verknuepfungen
herstellen zu koennen.
</p>
)}
</div>
{/* Sektion 6: Review-Einstellungen */}
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
<h3 className="text-lg font-semibold text-gray-900">
@@ -2430,316 +2278,6 @@ export default function LoeschfristenPage() {
)
}
// ==========================================================================
// Tab 5: Loeschkonzept Document
// ==========================================================================
function handleOrgHeaderChange(field: keyof LoeschkonzeptOrgHeader, value: string | string[]) {
const updated = { ...orgHeader, [field]: value }
setOrgHeader(updated)
localStorage.setItem('bp_loeschkonzept_orgheader', JSON.stringify(updated))
}
function handleAddRevision() {
const newRev: LoeschkonzeptRevision = {
version: orgHeader.loeschkonzeptVersion,
date: new Date().toISOString().split('T')[0],
author: orgHeader.dpoName || orgHeader.responsiblePerson || '',
changes: '',
}
const updated = [...revisions, newRev]
setRevisions(updated)
localStorage.setItem('bp_loeschkonzept_revisions', JSON.stringify(updated))
}
function handleUpdateRevision(index: number, field: keyof LoeschkonzeptRevision, value: string) {
const updated = revisions.map((r, i) => i === index ? { ...r, [field]: value } : r)
setRevisions(updated)
localStorage.setItem('bp_loeschkonzept_revisions', JSON.stringify(updated))
}
function handleRemoveRevision(index: number) {
const updated = revisions.filter((_, i) => i !== index)
setRevisions(updated)
localStorage.setItem('bp_loeschkonzept_revisions', JSON.stringify(updated))
}
function handlePrintLoeschkonzept() {
const htmlContent = buildLoeschkonzeptHtml(policies, orgHeader, vvtActivities, complianceResult, revisions)
const printWindow = window.open('', '_blank')
if (printWindow) {
printWindow.document.write(htmlContent)
printWindow.document.close()
printWindow.focus()
setTimeout(() => printWindow.print(), 300)
}
}
function handleDownloadLoeschkonzeptHtml() {
const htmlContent = buildLoeschkonzeptHtml(policies, orgHeader, vvtActivities, complianceResult, revisions)
downloadFile(htmlContent, `loeschkonzept-${new Date().toISOString().split('T')[0]}.html`, 'text/html;charset=utf-8')
}
function renderLoeschkonzept() {
const activePolicies = policies.filter(p => p.status !== 'ARCHIVED')
return (
<div className="space-y-4">
{/* Action bar */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">
Loeschkonzept (Art. 5/17/30 DSGVO)
</h3>
<p className="text-sm text-gray-500 mt-0.5">
Druckfertiges Loeschkonzept mit Deckblatt, Loeschregeln, VVT-Verknuepfung und Compliance-Status.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleDownloadLoeschkonzeptHtml}
disabled={activePolicies.length === 0}
className="bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg px-4 py-2 text-sm font-medium transition flex items-center gap-1.5"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
HTML herunterladen
</button>
<button
onClick={handlePrintLoeschkonzept}
disabled={activePolicies.length === 0}
className="bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg px-4 py-2 text-sm font-medium transition flex items-center gap-1.5"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" /></svg>
Als PDF drucken
</button>
</div>
</div>
{activePolicies.length === 0 && (
<div className="bg-yellow-50 text-yellow-700 text-sm rounded-lg p-3 border border-yellow-200">
Keine aktiven Policies vorhanden. Erstellen Sie mindestens eine Policy, um das Loeschkonzept zu generieren.
</div>
)}
</div>
{/* Org Header Form */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="text-sm font-semibold text-gray-900 mb-4">Organisationsdaten (Deckblatt)</h4>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Organisation</label>
<input
type="text"
value={orgHeader.organizationName}
onChange={e => handleOrgHeaderChange('organizationName', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="Name der Organisation"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Branche</label>
<input
type="text"
value={orgHeader.industry}
onChange={e => handleOrgHeaderChange('industry', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="z.B. IT / Software"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Datenschutzbeauftragter</label>
<input
type="text"
value={orgHeader.dpoName}
onChange={e => handleOrgHeaderChange('dpoName', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="Name des DSB"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">DSB-Kontakt</label>
<input
type="text"
value={orgHeader.dpoContact}
onChange={e => handleOrgHeaderChange('dpoContact', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="E-Mail oder Telefon"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Verantwortlicher (Art. 4 Nr. 7)</label>
<input
type="text"
value={orgHeader.responsiblePerson}
onChange={e => handleOrgHeaderChange('responsiblePerson', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="Name des Verantwortlichen"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Mitarbeiter</label>
<input
type="text"
value={orgHeader.employeeCount}
onChange={e => handleOrgHeaderChange('employeeCount', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="z.B. 50-249"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Version</label>
<input
type="text"
value={orgHeader.loeschkonzeptVersion}
onChange={e => handleOrgHeaderChange('loeschkonzeptVersion', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
placeholder="1.0"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Pruefintervall</label>
<select
value={orgHeader.reviewInterval}
onChange={e => handleOrgHeaderChange('reviewInterval', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="Vierteljaehrlich">Vierteljaehrlich</option>
<option value="Halbjaehrlich">Halbjaehrlich</option>
<option value="Jaehrlich">Jaehrlich</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Letzte Pruefung</label>
<input
type="date"
value={orgHeader.lastReviewDate}
onChange={e => handleOrgHeaderChange('lastReviewDate', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Naechste Pruefung</label>
<input
type="date"
value={orgHeader.nextReviewDate}
onChange={e => handleOrgHeaderChange('nextReviewDate', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
</div>
{/* Revisions */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h4 className="text-sm font-semibold text-gray-900">Aenderungshistorie</h4>
<button
onClick={handleAddRevision}
className="text-xs bg-purple-50 text-purple-700 hover:bg-purple-100 rounded-lg px-3 py-1.5 font-medium transition"
>
+ Revision hinzufuegen
</button>
</div>
{revisions.length === 0 ? (
<p className="text-sm text-gray-400">
Noch keine Revisionen. Die Erstversion wird automatisch im Dokument eingefuegt.
</p>
) : (
<div className="space-y-3">
{revisions.map((rev, idx) => (
<div key={idx} className="grid grid-cols-[80px_120px_1fr_1fr_32px] gap-2 items-start">
<input
type="text"
value={rev.version}
onChange={e => handleUpdateRevision(idx, 'version', e.target.value)}
className="rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
placeholder="1.1"
/>
<input
type="date"
value={rev.date}
onChange={e => handleUpdateRevision(idx, 'date', e.target.value)}
className="rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
/>
<input
type="text"
value={rev.author}
onChange={e => handleUpdateRevision(idx, 'author', e.target.value)}
className="rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
placeholder="Autor"
/>
<input
type="text"
value={rev.changes}
onChange={e => handleUpdateRevision(idx, 'changes', e.target.value)}
className="rounded-lg border border-gray-300 px-2 py-1.5 text-xs"
placeholder="Beschreibung der Aenderungen"
/>
<button
onClick={() => handleRemoveRevision(idx)}
className="text-red-400 hover:text-red-600 p-1"
title="Revision entfernen"
>
<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>
)}
</div>
{/* Document Preview */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="text-sm font-semibold text-gray-900 mb-4">Dokument-Vorschau</h4>
<div className="bg-gray-50 rounded-lg p-6 border border-gray-200">
{/* Cover preview */}
<div className="text-center mb-6">
<div className="text-2xl font-bold text-purple-700 mb-1">Loeschkonzept</div>
<div className="text-sm text-purple-500 mb-4">gemaess Art. 5/17/30 DSGVO</div>
<div className="text-sm text-gray-600">
{orgHeader.organizationName || <span className="text-gray-400 italic">Organisation nicht angegeben</span>}
</div>
<div className="text-xs text-gray-400 mt-2">
Version {orgHeader.loeschkonzeptVersion} | {new Date().toLocaleDateString('de-DE')}
</div>
</div>
{/* Section list */}
<div className="border-t border-gray-200 pt-4">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">12 Sektionen</div>
<div className="grid grid-cols-2 gap-1 text-xs text-gray-600">
<div>1. Ziel und Zweck</div>
<div>7. Auftragsverarbeiter</div>
<div>2. Geltungsbereich</div>
<div>8. Legal Hold Verfahren</div>
<div>3. Grundprinzipien</div>
<div>9. Verantwortlichkeiten</div>
<div>4. Loeschregeln-Uebersicht</div>
<div>10. Pruef-/Revisionszyklus</div>
<div>5. Detaillierte Loeschregeln</div>
<div>11. Compliance-Status</div>
<div>6. VVT-Verknuepfung</div>
<div>12. Aenderungshistorie</div>
</div>
</div>
{/* Stats */}
<div className="border-t border-gray-200 pt-4 mt-4 flex gap-6 text-xs text-gray-500">
<span><strong className="text-gray-700">{activePolicies.length}</strong> Loeschregeln</span>
<span><strong className="text-gray-700">{policies.filter(p => p.linkedVVTActivityIds.length > 0).length}</strong> VVT-Verknuepfungen</span>
<span><strong className="text-gray-700">{policies.filter(p => p.linkedVendorIds.length > 0).length}</strong> Vendor-Verknuepfungen</span>
<span><strong className="text-gray-700">{revisions.length}</strong> Revisionen</span>
{complianceResult && (
<span>Compliance-Score: <strong className={complianceResult.score >= 75 ? 'text-green-600' : complianceResult.score >= 50 ? 'text-yellow-600' : 'text-red-600'}>{complianceResult.score}/100</strong></span>
)}
</div>
</div>
</div>
</div>
)
}
// ==========================================================================
// Main render
// ==========================================================================
@@ -2779,7 +2317,6 @@ export default function LoeschfristenPage() {
{tab === 'editor' && renderEditor()}
{tab === 'generator' && renderGenerator()}
{tab === 'export' && renderExport()}
{tab === 'loeschkonzept' && renderLoeschkonzept()}
</div>
)
}

View File

@@ -1,20 +1,35 @@
'use client'
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import React, { useState, useEffect, useCallback } from 'react'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import TOMControlPanel from '@/components/sdk/obligations/TOMControlPanel'
import GapAnalysisView from '@/components/sdk/obligations/GapAnalysisView'
import { ObligationDocumentTab } from '@/components/sdk/obligations/ObligationDocumentTab'
import { useSDK } from '@/lib/sdk'
import { buildAssessmentPayload } from '@/lib/sdk/scope-to-facts'
import type { ApplicableRegulation } from '@/lib/sdk/compliance-scope-types'
import type { Obligation, ObligationComplianceCheckResult } from '@/lib/sdk/obligations-compliance'
import { runObligationComplianceCheck } from '@/lib/sdk/obligations-compliance'
// =============================================================================
// Types (local only — Obligation imported from obligations-compliance.ts)
// Types
// =============================================================================
interface Obligation {
id: string
title: string
description: string
source: string
source_article: string
deadline: string | null
status: 'pending' | 'in-progress' | 'completed' | 'overdue'
priority: 'critical' | 'high' | 'medium' | 'low'
responsible: string
linked_systems: string[]
assessment_id?: string
rule_code?: string
notes?: string
created_at?: string
updated_at?: string
}
interface ObligationStats {
pending: number
in_progress: number
@@ -35,7 +50,6 @@ interface ObligationFormData {
priority: string
responsible: string
linked_systems: string
linked_vendor_ids: string
notes: string
}
@@ -49,26 +63,11 @@ const EMPTY_FORM: ObligationFormData = {
priority: 'medium',
responsible: '',
linked_systems: '',
linked_vendor_ids: '',
notes: '',
}
const API = '/api/sdk/v1/compliance/obligations'
// =============================================================================
// Tab definitions
// =============================================================================
type Tab = 'uebersicht' | 'editor' | 'profiling' | 'gap-analyse' | 'pflichtenregister'
const TABS: { key: Tab; label: string }[] = [
{ key: 'uebersicht', label: 'Uebersicht' },
{ key: 'editor', label: 'Detail-Editor' },
{ key: 'profiling', label: 'Profiling' },
{ key: 'gap-analyse', label: 'Gap-Analyse' },
{ key: 'pflichtenregister', label: 'Pflichtenregister' },
]
// =============================================================================
// Status helpers
// =============================================================================
@@ -263,18 +262,6 @@ function ObligationModal({
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verknuepfte Auftragsverarbeiter</label>
<input
type="text"
value={form.linked_vendor_ids}
onChange={e => update('linked_vendor_ids', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
placeholder="Kommagetrennt: Vendor-ID-1, Vendor-ID-2"
/>
<p className="text-xs text-gray-400 mt-1">IDs der Auftragsverarbeiter aus dem Vendor Register</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notizen</label>
<textarea
@@ -378,19 +365,6 @@ function ObligationDetail({ obligation, onClose, onStatusChange, onEdit, onDelet
</div>
)}
{obligation.linked_vendor_ids && obligation.linked_vendor_ids.length > 0 && (
<div>
<span className="text-gray-500">Verknuepfte Auftragsverarbeiter</span>
<div className="flex flex-wrap gap-1 mt-1">
{obligation.linked_vendor_ids.map(id => (
<a key={id} href="/sdk/vendor-compliance" className="px-2 py-0.5 text-xs bg-indigo-50 text-indigo-700 rounded hover:bg-indigo-100 transition-colors">
{id}
</a>
))}
</div>
</div>
)}
{obligation.notes && (
<div>
<span className="text-gray-500">Notizen</span>
@@ -585,15 +559,9 @@ export default function ObligationsPage() {
const [showModal, setShowModal] = useState(false)
const [editObligation, setEditObligation] = useState<Obligation | null>(null)
const [detailObligation, setDetailObligation] = useState<Obligation | null>(null)
const [showGapAnalysis, setShowGapAnalysis] = useState(false)
const [profiling, setProfiling] = useState(false)
const [applicableRegs, setApplicableRegs] = useState<ApplicableRegulation[]>([])
const [activeTab, setActiveTab] = useState<Tab>('uebersicht')
// Compliance check result — auto-computed when obligations change
const complianceResult = useMemo<ObligationComplianceCheckResult | null>(() => {
if (obligations.length === 0) return null
return runObligationComplianceCheck(obligations)
}, [obligations])
const loadData = useCallback(async () => {
setLoading(true)
@@ -645,7 +613,6 @@ export default function ObligationsPage() {
priority: form.priority,
responsible: form.responsible || null,
linked_systems: form.linked_systems ? form.linked_systems.split(',').map(s => s.trim()).filter(Boolean) : [],
linked_vendor_ids: form.linked_vendor_ids ? form.linked_vendor_ids.split(',').map(s => s.trim()).filter(Boolean) : [],
notes: form.notes || null,
}),
})
@@ -667,12 +634,12 @@ export default function ObligationsPage() {
priority: form.priority,
responsible: form.responsible || null,
linked_systems: form.linked_systems ? form.linked_systems.split(',').map(s => s.trim()).filter(Boolean) : [],
linked_vendor_ids: form.linked_vendor_ids ? form.linked_vendor_ids.split(',').map(s => s.trim()).filter(Boolean) : [],
notes: form.notes || null,
}),
})
if (!res.ok) throw new Error('Aktualisierung fehlgeschlagen')
await loadData()
// Refresh detail if open
if (detailObligation?.id === id) {
const updated = await fetch(`${API}/${id}`)
if (updated.ok) setDetailObligation(await updated.json())
@@ -689,6 +656,7 @@ export default function ObligationsPage() {
const updated = await res.json()
setObligations(prev => prev.map(o => o.id === id ? updated : o))
if (detailObligation?.id === id) setDetailObligation(updated)
// Refresh stats
fetch(`${API}/stats`).then(r => r.json()).then(setStats).catch(() => {})
}
@@ -704,6 +672,7 @@ export default function ObligationsPage() {
setProfiling(true)
setError(null)
try {
// Build payload from real CompanyProfile + Scope data
const profile = sdkState.companyProfile
const scopeState = sdkState.complianceScope
const scopeAnswers = scopeState?.answers || []
@@ -713,6 +682,7 @@ export default function ObligationsPage() {
if (profile) {
payload = buildAssessmentPayload(profile, scopeAnswers, scopeDecision) as unknown as Record<string, unknown>
} else {
// Fallback: Minimaldaten wenn kein Profil vorhanden
payload = {
employee_count: 50,
industry: 'technology',
@@ -732,9 +702,11 @@ export default function ObligationsPage() {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
// Store applicable regulations for the info box
const regs: ApplicableRegulation[] = data.overview?.applicable_regulations || data.applicable_regulations || []
setApplicableRegs(regs)
// Extract obligations from response (can be nested under overview)
const rawObls = data.overview?.obligations || data.obligations || []
if (rawObls.length > 0) {
const autoObls: Obligation[] = rawObls.map((o: Record<string, unknown>) => ({
@@ -766,6 +738,11 @@ export default function ObligationsPage() {
const stepInfo = STEP_EXPLANATIONS['obligations']
const filteredObligations = obligations.filter(o => {
// Status/priority filter
if (filter === 'ai') {
if (!o.source.toLowerCase().includes('ai')) return false
}
// Regulation filter
if (regulationFilter !== 'all') {
const src = o.source?.toLowerCase() || ''
const key = regulationFilter.toLowerCase()
@@ -774,12 +751,91 @@ export default function ObligationsPage() {
return true
})
// ---------------------------------------------------------------------------
// Tab Content Renderers
// ---------------------------------------------------------------------------
return (
<div className="space-y-6">
{/* Modals */}
{(showModal || editObligation) && !detailObligation && (
<ObligationModal
initial={editObligation ? {
title: editObligation.title,
description: editObligation.description,
source: editObligation.source,
source_article: editObligation.source_article,
deadline: editObligation.deadline ? editObligation.deadline.slice(0, 10) : '',
status: editObligation.status,
priority: editObligation.priority,
responsible: editObligation.responsible,
linked_systems: editObligation.linked_systems?.join(', ') || '',
notes: editObligation.notes || '',
} : undefined}
onClose={() => { setShowModal(false); setEditObligation(null) }}
onSave={async (form) => {
if (editObligation) {
await handleUpdate(editObligation.id, form)
setEditObligation(null)
} else {
await handleCreate(form)
setShowModal(false)
}
}}
/>
)}
{detailObligation && (
<ObligationDetail
obligation={detailObligation}
onClose={() => setDetailObligation(null)}
onStatusChange={handleStatusChange}
onDelete={handleDelete}
onEdit={() => {
setEditObligation(detailObligation)
setDetailObligation(null)
}}
/>
)}
{/* Header */}
<StepHeader
stepId="obligations"
title={stepInfo?.title || 'Pflichten-Management'}
description={stepInfo?.description || 'DSGVO & AI-Act Compliance-Pflichten verwalten'}
explanation={stepInfo?.explanation || ''}
tips={stepInfo?.tips || []}
>
<div className="flex items-center gap-2">
<button
onClick={handleAutoProfiling}
disabled={profiling}
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 text-sm disabled:opacity-50"
>
<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>
{profiling ? 'Profiling...' : 'Auto-Profiling'}
</button>
<button
onClick={() => setShowGapAnalysis(v => !v)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors text-sm ${
showGapAnalysis ? 'bg-purple-100 text-purple-700' : 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
<svg className="w-4 h-4" 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>
Gap-Analyse
</button>
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Pflicht hinzufuegen
</button>
</div>
</StepHeader>
const renderUebersichtTab = () => (
<>
{/* Error */}
{error && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-700">{error}</div>
@@ -816,13 +872,12 @@ export default function ObligationsPage() {
)}
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ label: 'Ausstehend', value: stats?.pending ?? 0, color: 'text-gray-600', border: 'border-gray-200' },
{ label: 'In Bearbeitung',value: stats?.in_progress ?? 0, color: 'text-blue-600', border: 'border-blue-200' },
{ label: 'Ueberfaellig', value: stats?.overdue ?? 0, color: 'text-red-600', border: 'border-red-200' },
{ label: 'Abgeschlossen', value: stats?.completed ?? 0, color: 'text-green-600', border: 'border-green-200'},
{ label: 'Compliance-Score', value: complianceResult ? complianceResult.score : '—', color: 'text-purple-600', border: 'border-purple-200'},
].map(s => (
<div key={s.label} className={`bg-white rounded-xl border ${s.border} p-5`}>
<div className={`text-xs ${s.color}`}>{s.label}</div>
@@ -846,26 +901,9 @@ export default function ObligationsPage() {
</div>
)}
{/* Compliance Issues Summary */}
{complianceResult && complianceResult.issues.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-5">
<h3 className="text-sm font-semibold text-gray-900 mb-3">Compliance-Befunde ({complianceResult.issues.length})</h3>
<div className="space-y-2">
{complianceResult.issues.map((issue, i) => (
<div key={i} className="flex items-start gap-3 text-sm">
<span className={`px-2 py-0.5 text-xs rounded-full flex-shrink-0 ${
issue.severity === 'CRITICAL' ? 'bg-red-100 text-red-700' :
issue.severity === 'HIGH' ? 'bg-orange-100 text-orange-700' :
issue.severity === 'MEDIUM' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-600'
}`}>
{issue.severity === 'CRITICAL' ? 'Kritisch' : issue.severity === 'HIGH' ? 'Hoch' : issue.severity === 'MEDIUM' ? 'Mittel' : 'Niedrig'}
</span>
<span className="text-gray-700">{issue.message}</span>
</div>
))}
</div>
</div>
{/* Gap Analysis View */}
{showGapAnalysis && (
<GapAnalysisView />
)}
{/* Regulation Filter Chips */}
@@ -932,7 +970,7 @@ export default function ObligationsPage() {
</div>
<h3 className="text-base font-semibold text-gray-900">Keine Pflichten gefunden</h3>
<p className="mt-2 text-sm text-gray-500">
Klicken Sie auf &quot;Pflicht hinzufuegen&quot;, um die erste Compliance-Pflicht zu erfassen.
Klicken Sie auf "Pflicht hinzufuegen", um die erste Compliance-Pflicht zu erfassen.
</p>
<button
onClick={() => setShowModal(true)}
@@ -944,220 +982,6 @@ export default function ObligationsPage() {
)}
</div>
)}
</>
)
const renderEditorTab = () => (
<>
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-gray-900">Pflichten bearbeiten ({obligations.length})</h3>
<button
onClick={() => setShowModal(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm"
>
Pflicht hinzufuegen
</button>
</div>
{loading && <p className="text-gray-500 text-sm">Lade...</p>}
{!loading && obligations.length === 0 && (
<p className="text-gray-500 text-sm">Noch keine Pflichten vorhanden. Erstellen Sie eine neue Pflicht oder nutzen Sie Auto-Profiling.</p>
)}
{!loading && obligations.length > 0 && (
<div className="space-y-2 max-h-[60vh] overflow-y-auto">
{obligations.map(o => (
<div
key={o.id}
className="flex items-center justify-between p-3 border border-gray-100 rounded-lg hover:bg-gray-50 cursor-pointer"
onClick={() => {
setEditObligation(o)
}}
>
<div className="flex items-center gap-3 min-w-0">
<span className={`px-2 py-0.5 text-xs rounded-full flex-shrink-0 ${STATUS_COLORS[o.status]}`}>
{STATUS_LABELS[o.status]}
</span>
<span className={`px-2 py-0.5 text-xs rounded-full flex-shrink-0 ${PRIORITY_COLORS[o.priority]}`}>
{PRIORITY_LABELS[o.priority]}
</span>
<span className="text-sm text-gray-900 truncate">{o.title}</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className="text-xs text-gray-400">{o.source}</span>
<button
onClick={(e) => { e.stopPropagation(); setEditObligation(o) }}
className="text-xs text-purple-600 hover:text-purple-700 font-medium"
>
Bearbeiten
</button>
</div>
</div>
))}
</div>
)}
</div>
</>
)
const renderProfilingTab = () => (
<>
{error && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-700">{error}</div>
)}
{!sdkState.companyProfile && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm text-yellow-700">
Kein Unternehmensprofil vorhanden. Auto-Profiling verwendet Beispieldaten.{' '}
<a href="/sdk/company-profile" className="underline font-medium">Profil anlegen </a>
</div>
)}
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
<div className="w-12 h-12 mx-auto bg-purple-50 rounded-full flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h3 className="text-sm font-semibold text-gray-900">Auto-Profiling</h3>
<p className="text-xs text-gray-500 mt-1 mb-4">
Ermittelt automatisch anwendbare Regulierungen und Pflichten aus dem Unternehmensprofil und Compliance-Scope.
</p>
<button
onClick={handleAutoProfiling}
disabled={profiling}
className="px-5 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{profiling ? 'Profiling laeuft...' : 'Auto-Profiling starten'}
</button>
</div>
{applicableRegs.length > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<h3 className="text-sm font-semibold text-blue-900 mb-2">Anwendbare Regulierungen</h3>
<div className="flex flex-wrap gap-2">
{applicableRegs.map(reg => (
<span
key={reg.id}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-white border border-blue-300 text-blue-800"
>
<svg className="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{reg.name}
{reg.classification && <span className="text-blue-500">({reg.classification})</span>}
<span className="text-blue-400">{reg.obligation_count} Pflichten</span>
</span>
))}
</div>
</div>
)}
</>
)
const renderGapAnalyseTab = () => (
<GapAnalysisView />
)
const renderPflichtenregisterTab = () => (
<ObligationDocumentTab
obligations={obligations}
complianceResult={complianceResult}
/>
)
const renderTabContent = () => {
switch (activeTab) {
case 'uebersicht': return renderUebersichtTab()
case 'editor': return renderEditorTab()
case 'profiling': return renderProfilingTab()
case 'gap-analyse': return renderGapAnalyseTab()
case 'pflichtenregister': return renderPflichtenregisterTab()
}
}
return (
<div className="space-y-6">
{/* Modals */}
{(showModal || editObligation) && !detailObligation && (
<ObligationModal
initial={editObligation ? {
title: editObligation.title,
description: editObligation.description,
source: editObligation.source,
source_article: editObligation.source_article,
deadline: editObligation.deadline ? editObligation.deadline.slice(0, 10) : '',
status: editObligation.status,
priority: editObligation.priority,
responsible: editObligation.responsible,
linked_systems: editObligation.linked_systems?.join(', ') || '',
notes: editObligation.notes || '',
} : undefined}
onClose={() => { setShowModal(false); setEditObligation(null) }}
onSave={async (form) => {
if (editObligation) {
await handleUpdate(editObligation.id, form)
setEditObligation(null)
} else {
await handleCreate(form)
setShowModal(false)
}
}}
/>
)}
{detailObligation && (
<ObligationDetail
obligation={detailObligation}
onClose={() => setDetailObligation(null)}
onStatusChange={handleStatusChange}
onDelete={handleDelete}
onEdit={() => {
setEditObligation(detailObligation)
setDetailObligation(null)
}}
/>
)}
{/* Header */}
<StepHeader
stepId="obligations"
title={stepInfo?.title || 'Pflichten-Management'}
description={stepInfo?.description || 'DSGVO & AI-Act Compliance-Pflichten verwalten'}
explanation={stepInfo?.explanation || ''}
tips={stepInfo?.tips || []}
>
<div className="flex items-center gap-2">
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Pflicht hinzufuegen
</button>
</div>
</StepHeader>
{/* Tab Navigation */}
<div className="flex gap-1 border-b border-gray-200">
{TABS.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2.5 text-sm font-medium transition-colors ${
activeTab === tab.key
? 'border-b-2 border-purple-500 text-purple-700'
: 'text-gray-500 hover:text-gray-700 hover:border-b-2 hover:border-gray-300'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
{renderTabContent()}
</div>
)
}

View File

@@ -1,496 +0,0 @@
'use client'
import React, { useState, useEffect } from 'react'
interface PaymentControl {
control_id: string
domain: string
title: string
objective: string
check_target: string
evidence: string[]
automation: string
}
interface PaymentDomain {
id: string
name: string
description: string
}
interface Assessment {
id: string
project_name: string
tender_reference: string
customer_name: string
system_type: string
total_controls: number
controls_passed: number
controls_failed: number
controls_partial: number
controls_not_applicable: number
controls_not_checked: number
compliance_score: number
status: string
created_at: string
}
interface TenderAnalysis {
id: string
file_name: string
file_size: number
project_name: string
customer_name: string
status: string
total_requirements: number
matched_count: number
unmatched_count: number
partial_count: number
requirements?: Array<{ req_id: string; text: string; obligation_level: string; technical_domain: string; confidence: number }>
match_results?: Array<{ req_id: string; req_text: string; verdict: string; matched_controls: Array<{ control_id: string; title: string; relevance: number }>; gap_description?: string }>
created_at: string
}
const AUTOMATION_STYLES: Record<string, { bg: string; text: string }> = {
high: { bg: 'bg-green-100', text: 'text-green-700' },
medium: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
partial: { bg: 'bg-orange-100', text: 'text-orange-700' },
low: { bg: 'bg-red-100', text: 'text-red-700' },
}
const TARGET_ICONS: Record<string, string> = {
code: '💻', system: '🖥️', config: '⚙️', process: '📋',
repository: '📦', certificate: '📜',
}
export default function PaymentCompliancePage() {
const [controls, setControls] = useState<PaymentControl[]>([])
const [domains, setDomains] = useState<PaymentDomain[]>([])
const [assessments, setAssessments] = useState<Assessment[]>([])
const [tenderAnalyses, setTenderAnalyses] = useState<TenderAnalysis[]>([])
const [selectedTender, setSelectedTender] = useState<TenderAnalysis | null>(null)
const [selectedDomain, setSelectedDomain] = useState<string>('all')
const [loading, setLoading] = useState(true)
const [tab, setTab] = useState<'controls' | 'assessments' | 'tender'>('controls')
const [uploading, setUploading] = useState(false)
const [processing, setProcessing] = useState(false)
const [showNewAssessment, setShowNewAssessment] = useState(false)
const [newProject, setNewProject] = useState({ project_name: '', tender_reference: '', customer_name: '', system_type: 'full_stack' })
useEffect(() => {
loadData()
}, [])
async function loadData() {
try {
setLoading(true)
const [ctrlResp, assessResp, tenderResp] = await Promise.all([
fetch('/api/sdk/v1/payment-compliance?endpoint=controls'),
fetch('/api/sdk/v1/payment-compliance?endpoint=assessments'),
fetch('/api/sdk/v1/payment-compliance/tender'),
])
if (ctrlResp.ok) {
const data = await ctrlResp.json()
setControls(data.controls || [])
setDomains(data.domains || [])
}
if (assessResp.ok) {
const data = await assessResp.json()
setAssessments(data.assessments || [])
}
if (tenderResp.ok) {
const data = await tenderResp.json()
setTenderAnalyses(data.analyses || [])
}
} catch {}
finally { setLoading(false) }
}
async function handleTenderUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
try {
const formData = new FormData()
formData.append('file', file)
formData.append('project_name', file.name.replace(/\.[^.]+$/, ''))
const resp = await fetch('/api/sdk/v1/payment-compliance/tender', { method: 'POST', body: formData })
if (resp.ok) {
const data = await resp.json()
// Auto-start extraction + matching
setProcessing(true)
const extractResp = await fetch(`/api/sdk/v1/payment-compliance/tender/${data.id}?action=extract`, { method: 'POST' })
if (extractResp.ok) {
await fetch(`/api/sdk/v1/payment-compliance/tender/${data.id}?action=match`, { method: 'POST' })
}
// Reload and show result
const detailResp = await fetch(`/api/sdk/v1/payment-compliance/tender/${data.id}`)
if (detailResp.ok) {
const detail = await detailResp.json()
setSelectedTender(detail)
}
loadData()
}
} catch {} finally {
setUploading(false)
setProcessing(false)
}
}
async function handleViewTender(id: string) {
const resp = await fetch(`/api/sdk/v1/payment-compliance/tender/${id}`)
if (resp.ok) {
setSelectedTender(await resp.json())
}
}
async function handleCreateAssessment() {
const resp = await fetch('/api/sdk/v1/payment-compliance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newProject),
})
if (resp.ok) {
setShowNewAssessment(false)
setNewProject({ project_name: '', tender_reference: '', customer_name: '', system_type: 'full_stack' })
loadData()
}
}
const filteredControls = selectedDomain === 'all'
? controls
: controls.filter(c => c.domain === selectedDomain)
const domainStats = domains.map(d => ({
...d,
count: controls.filter(c => c.domain === d.id).length,
}))
return (
<div className="max-w-6xl mx-auto p-6">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Payment Terminal Compliance</h1>
<p className="text-sm text-gray-500 mt-1">
Technische Pruefbibliothek fuer Zahlungssysteme {controls.length} Controls in {domains.length} Domaenen
</p>
</div>
<div className="flex gap-2">
<button onClick={() => setTab('controls')}
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === 'controls' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700'}`}>
Controls ({controls.length})
</button>
<button onClick={() => setTab('assessments')}
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === 'assessments' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700'}`}>
Assessments ({assessments.length})
</button>
<button onClick={() => setTab('tender')}
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === 'tender' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700'}`}>
Ausschreibung ({tenderAnalyses.length})
</button>
</div>
</div>
{/* Info Box */}
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-xl text-sm text-blue-800">
<div className="font-semibold mb-2">Wie funktioniert Payment Terminal Compliance?</div>
<div className="grid grid-cols-3 gap-4">
<div>
<div className="font-medium mb-1">1. Controls durchsuchen</div>
<p className="text-xs text-blue-700">Unsere Bibliothek enthaelt {controls.length} technische Pruefregeln fuer Zahlungssysteme von Transaktionslogik ueber Kryptographie bis ZVT/OPI-Protokollverhalten. Jeder Control definiert was geprueft wird und welche Evidenz noetig ist.</p>
</div>
<div>
<div className="font-medium mb-1">2. Assessment erstellen</div>
<p className="text-xs text-blue-700">Ein Assessment ist eine projektbezogene Pruefung z.B. fuer eine bestimmte Ausschreibung oder einen Kunden. Sie ordnet jedem Control einen Status zu: bestanden, fehlgeschlagen, teilweise oder nicht anwendbar.</p>
</div>
<div>
<div className="font-medium mb-1">3. Ausschreibung analysieren</div>
<p className="text-xs text-blue-700">Laden Sie ein Ausschreibungsdokument hoch. Die KI extrahiert automatisch die Anforderungen und matcht sie gegen unsere Controls. Ergebnis: Welche Anforderungen sind abgedeckt und wo gibt es Luecken.</p>
</div>
</div>
</div>
{loading ? (
<div className="text-center py-12 text-gray-500">Lade...</div>
) : tab === 'controls' ? (
<>
{/* Domain Filter */}
<div className="grid grid-cols-5 gap-3 mb-6">
<button onClick={() => setSelectedDomain('all')}
className={`p-3 rounded-xl border text-center ${selectedDomain === 'all' ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-purple-300'}`}>
<div className="text-lg font-bold text-purple-700">{controls.length}</div>
<div className="text-xs text-gray-500">Alle</div>
</button>
{domainStats.map(d => (
<button key={d.id} onClick={() => setSelectedDomain(d.id)}
className={`p-3 rounded-xl border text-center ${selectedDomain === d.id ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-purple-300'}`}>
<div className="text-lg font-bold text-gray-900">{d.count}</div>
<div className="text-xs text-gray-500 truncate">{d.id}</div>
</button>
))}
</div>
{/* Domain Description */}
{selectedDomain !== 'all' && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-800">
<strong>{domains.find(d => d.id === selectedDomain)?.name}:</strong>{' '}
{domains.find(d => d.id === selectedDomain)?.description}
</div>
)}
{/* Controls List */}
<div className="space-y-3">
{filteredControls.map(ctrl => {
const autoStyle = AUTOMATION_STYLES[ctrl.automation] || AUTOMATION_STYLES.low
return (
<div key={ctrl.control_id} className="bg-white rounded-xl border border-gray-200 p-4 hover:border-purple-300 transition-all">
<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-purple-600 bg-purple-50 px-2 py-0.5 rounded">{ctrl.control_id}</span>
<span className="text-xs text-gray-400">{TARGET_ICONS[ctrl.check_target] || '🔍'} {ctrl.check_target}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${autoStyle.bg} ${autoStyle.text}`}>
{ctrl.automation}
</span>
</div>
<h3 className="text-sm font-semibold text-gray-900">{ctrl.title}</h3>
<p className="text-xs text-gray-500 mt-1">{ctrl.objective}</p>
</div>
</div>
<div className="flex gap-1 mt-2">
{ctrl.evidence.map(ev => (
<span key={ev} className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">{ev}</span>
))}
</div>
</div>
)
})}
</div>
</>
) : tab === 'assessments' ? (
<>
{/* Assessments Tab */}
<div className="mb-4">
<button onClick={() => setShowNewAssessment(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
+ Neues Assessment
</button>
</div>
{showNewAssessment && (
<div className="mb-6 p-6 bg-white rounded-xl border border-purple-200">
<h3 className="font-semibold text-gray-900 mb-4">Neues Payment Compliance Assessment</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Projektname *</label>
<input value={newProject.project_name} onChange={e => setNewProject(p => ({ ...p, project_name: e.target.value }))}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" placeholder="z.B. Ausschreibung Muenchen 2026" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Ausschreibungs-Referenz</label>
<input value={newProject.tender_reference} onChange={e => setNewProject(p => ({ ...p, tender_reference: e.target.value }))}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" placeholder="z.B. 2026-PAY-001" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kunde</label>
<input value={newProject.customer_name} onChange={e => setNewProject(p => ({ ...p, customer_name: e.target.value }))}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" placeholder="z.B. Stadt Muenchen" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Systemtyp</label>
<select value={newProject.system_type} onChange={e => setNewProject(p => ({ ...p, system_type: e.target.value }))}
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500">
<option value="full_stack">Full Stack (Terminal + Backend)</option>
<option value="terminal">Nur Terminal</option>
<option value="backend">Nur Backend</option>
</select>
</div>
</div>
<div className="flex gap-2 mt-4">
<button onClick={handleCreateAssessment} disabled={!newProject.project_name}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">Erstellen</button>
<button onClick={() => setShowNewAssessment(false)}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">Abbrechen</button>
</div>
</div>
)}
{assessments.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<p className="text-lg mb-2">Noch keine Assessments</p>
<p className="text-sm">Erstelle ein neues Assessment fuer eine Ausschreibung.</p>
</div>
) : (
<div className="space-y-4">
{assessments.map(a => (
<div key={a.id} className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-lg font-semibold text-gray-900">{a.project_name}</h3>
<div className="text-sm text-gray-500">
{a.customer_name && <span>{a.customer_name} · </span>}
{a.tender_reference && <span>Ref: {a.tender_reference} · </span>}
<span>{new Date(a.created_at).toLocaleDateString('de-DE')}</span>
</div>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
a.status === 'completed' ? 'bg-green-100 text-green-700' :
a.status === 'in_progress' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-700'
}`}>{a.status}</span>
</div>
<div className="grid grid-cols-6 gap-2">
<div className="text-center p-2 bg-gray-50 rounded">
<div className="text-lg font-bold">{a.total_controls}</div>
<div className="text-xs text-gray-500">Total</div>
</div>
<div className="text-center p-2 bg-green-50 rounded">
<div className="text-lg font-bold text-green-700">{a.controls_passed}</div>
<div className="text-xs text-gray-500">Passed</div>
</div>
<div className="text-center p-2 bg-red-50 rounded">
<div className="text-lg font-bold text-red-700">{a.controls_failed}</div>
<div className="text-xs text-gray-500">Failed</div>
</div>
<div className="text-center p-2 bg-yellow-50 rounded">
<div className="text-lg font-bold text-yellow-700">{a.controls_partial}</div>
<div className="text-xs text-gray-500">Partial</div>
</div>
<div className="text-center p-2 bg-gray-50 rounded">
<div className="text-lg font-bold text-gray-400">{a.controls_not_applicable}</div>
<div className="text-xs text-gray-500">N/A</div>
</div>
<div className="text-center p-2 bg-gray-50 rounded">
<div className="text-lg font-bold text-gray-400">{a.controls_not_checked}</div>
<div className="text-xs text-gray-500">Offen</div>
</div>
</div>
</div>
))}
</div>
)}
</>
) : tab === 'tender' ? (
<>
{/* Tender Analysis Tab */}
<div className="mb-6 p-6 bg-white rounded-xl border-2 border-dashed border-purple-300 text-center">
<h3 className="text-lg font-semibold text-gray-900 mb-2">Ausschreibung analysieren</h3>
<p className="text-sm text-gray-500 mb-4">
Laden Sie ein Ausschreibungsdokument hoch. Die KI extrahiert automatisch alle Anforderungen und matcht sie gegen die Control-Bibliothek.
</p>
<label className="inline-block px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 cursor-pointer">
{uploading ? 'Hochladen...' : processing ? 'Analysiere...' : 'PDF / Dokument hochladen'}
<input type="file" className="hidden" accept=".pdf,.txt,.doc,.docx" onChange={handleTenderUpload} disabled={uploading || processing} />
</label>
<p className="text-xs text-gray-400 mt-2">PDF, TXT oder Word. Max 50 MB. Dokument wird nur fuer diese Analyse verwendet.</p>
</div>
{/* Selected Tender Detail */}
{selectedTender && (
<div className="mb-6 p-6 bg-white rounded-xl border border-purple-200">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">{selectedTender.project_name}</h3>
<p className="text-sm text-gray-500">{selectedTender.file_name} {selectedTender.status}</p>
</div>
<button onClick={() => setSelectedTender(null)} className="text-gray-400 hover:text-gray-600 text-xl">&times;</button>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-3 mb-6">
<div className="text-center p-3 bg-gray-50 rounded-lg">
<div className="text-2xl font-bold">{selectedTender.total_requirements}</div>
<div className="text-xs text-gray-500">Anforderungen</div>
</div>
<div className="text-center p-3 bg-green-50 rounded-lg">
<div className="text-2xl font-bold text-green-700">{selectedTender.matched_count}</div>
<div className="text-xs text-gray-500">Abgedeckt</div>
</div>
<div className="text-center p-3 bg-yellow-50 rounded-lg">
<div className="text-2xl font-bold text-yellow-700">{selectedTender.partial_count}</div>
<div className="text-xs text-gray-500">Teilweise</div>
</div>
<div className="text-center p-3 bg-red-50 rounded-lg">
<div className="text-2xl font-bold text-red-700">{selectedTender.unmatched_count}</div>
<div className="text-xs text-gray-500">Luecken</div>
</div>
</div>
{/* Match Results */}
{selectedTender.match_results && selectedTender.match_results.length > 0 && (
<div className="space-y-3">
<h4 className="font-semibold text-gray-900">Requirement Control Matching</h4>
{selectedTender.match_results.map((mr, idx) => (
<div key={idx} className={`p-4 rounded-lg border ${
mr.verdict === 'matched' ? 'border-green-200 bg-green-50' :
mr.verdict === 'partial' ? 'border-yellow-200 bg-yellow-50' :
'border-red-200 bg-red-50'
}`}>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono bg-white px-2 py-0.5 rounded border">{mr.req_id}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${
mr.verdict === 'matched' ? 'bg-green-200 text-green-800' :
mr.verdict === 'partial' ? 'bg-yellow-200 text-yellow-800' :
'bg-red-200 text-red-800'
}`}>
{mr.verdict === 'matched' ? 'Abgedeckt' : mr.verdict === 'partial' ? 'Teilweise' : 'Luecke'}
</span>
</div>
<p className="text-sm text-gray-900">{mr.req_text}</p>
</div>
</div>
{mr.matched_controls && mr.matched_controls.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{mr.matched_controls.map(mc => (
<span key={mc.control_id} className="text-xs bg-white border px-2 py-0.5 rounded">
{mc.control_id} ({Math.round(mc.relevance * 100)}%)
</span>
))}
</div>
)}
{mr.gap_description && (
<p className="text-xs text-orange-700 mt-2">{mr.gap_description}</p>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Previous Analyses */}
{tenderAnalyses.length > 0 && (
<div>
<h4 className="font-semibold text-gray-900 mb-3">Bisherige Analysen</h4>
<div className="space-y-3">
{tenderAnalyses.map(ta => (
<button key={ta.id} onClick={() => handleViewTender(ta.id)}
className="w-full text-left bg-white rounded-xl border border-gray-200 p-4 hover:border-purple-300 transition-all">
<div className="flex items-center justify-between">
<div>
<h3 className="font-medium text-gray-900">{ta.project_name}</h3>
<p className="text-xs text-gray-500">{ta.file_name} {new Date(ta.created_at).toLocaleDateString('de-DE')}</p>
</div>
<div className="flex gap-2">
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">{ta.matched_count} matched</span>
{ta.unmatched_count > 0 && (
<span className="text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded-full">{ta.unmatched_count} gaps</span>
)}
<span className={`text-xs px-2 py-0.5 rounded-full ${
ta.status === 'matched' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'
}`}>{ta.status}</span>
</div>
</div>
</button>
))}
</div>
</div>
)}
</>
) : null}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -142,8 +142,8 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
checkpointId: 'CP-UC',
checkpointType: 'REQUIRED',
checkpointReviewer: 'NONE',
description: 'Systematische Erfassung aller Datenverarbeitungs- und KI-Anwendungsfaelle ueber einen 8-Schritte-Wizard mit Kachel-Auswahl. Inkl. BetrVG-Mitbestimmungspruefung und Betriebsrats-Konflikt-Score.',
descriptionLong: 'In einem 8-Schritte-Wizard werden alle Use Cases erfasst: (1) Grundlegendes — Titel, Beschreibung, KI-Kategorie (21 Kacheln), Branche wird automatisch aus dem Profil abgeleitet. (2) Datenkategorien — ~60 Kategorien in 10 Gruppen als Kacheln (inkl. Art. 9 hervorgehoben). (3) Verarbeitungszweck — 16 Zweck-Kacheln, Rechtsgrundlage wird vom SDK automatisch ermittelt. (4) Automatisierungsgrad + BetrVG — assistiv/teilautomatisiert/vollautomatisiert, plus 3 BetrVG-Toggles: Ueberwachungseignung, HR-Entscheidungsunterstuetzung, BR-Konsultation. Das SDK berechnet daraus einen Betriebsrats-Konflikt-Score (0-100) und leitet BetrVG-Pflichten ab (§87, §90, §94, §95). (5) Hosting & Modell — Provider, Region, Modellnutzung (Inferenz/RAG/Fine-Tuning/Training). (6) Datentransfer — Transferziele und Schutzmechanismen. (7) Datenhaltung — Aufbewahrungsfristen. (8) Vertraege — vorhandene Compliance-Dokumente. Die RAG-Collection bp_compliance_ce wird verwendet, um relevante CE-Regulierungen automatisch den Use Cases zuzuordnen (UCCA). Die Collection bp_compliance_datenschutz enthaelt 14 BAG-Urteile zu IT-Mitbestimmung (M365, SAP, SaaS, Video).',
description: 'Systematische Erfassung aller Datenverarbeitungs- und KI-Anwendungsfaelle ueber einen 8-Schritte-Wizard mit Kachel-Auswahl.',
descriptionLong: 'In einem 8-Schritte-Wizard werden alle Use Cases erfasst: (1) Grundlegendes — Titel, Beschreibung, KI-Kategorie (21 Kacheln), Branche wird automatisch aus dem Profil abgeleitet. (2) Datenkategorien — ~60 Kategorien in 10 Gruppen als Kacheln (inkl. Art. 9 hervorgehoben). (3) Verarbeitungszweck — 16 Zweck-Kacheln, Rechtsgrundlage wird vom SDK automatisch ermittelt. (4) Automatisierungsgrad — assistiv/teilautomatisiert/vollautomatisiert. (5) Hosting & Modell — Provider, Region, Modellnutzung (Inferenz/RAG/Fine-Tuning/Training). (6) Datentransfer — Transferziele und Schutzmechanismen. (7) Datenhaltung — Aufbewahrungsfristen. (8) Vertraege — vorhandene Compliance-Dokumente. Die RAG-Collection bp_compliance_ce wird verwendet, um relevante CE-Regulierungen automatisch den Use Cases zuzuordnen (UCCA).',
legalBasis: 'Art. 30 DSGVO (Verzeichnis von Verarbeitungstaetigkeiten)',
inputs: ['companyProfile'],
outputs: ['useCases'],
@@ -155,27 +155,6 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
isOptional: false,
url: '/sdk/use-cases',
},
{
id: 'ai-registration',
name: 'EU AI Database Registrierung',
nameShort: 'EU-Reg',
package: 'vorbereitung',
seq: 350,
checkpointId: 'CP-REG',
checkpointType: 'CONDITIONAL',
checkpointReviewer: 'NONE',
description: 'Registrierung von Hochrisiko-KI-Systemen in der EU AI Database gemaess Art. 49 KI-Verordnung.',
descriptionLong: 'Fuer Hochrisiko-KI-Systeme (Annex III) ist eine Registrierung in der EU AI Database Pflicht. Ein 6-Schritte-Wizard fuehrt durch den Prozess: (1) Anbieter-Daten — Name, Rechtsform, Adresse, EU-Repraesentant. (2) System-Details — Name, Version, Beschreibung, Einsatzzweck (vorausgefuellt aus UCCA Assessment). (3) Klassifikation — Risikoklasse und Annex III Kategorie (aus Decision Tree). (4) Konformitaet — CE-Kennzeichnung, Notified Body. (5) Trainingsdaten — Zusammenfassung der Datenquellen (Art. 10). (6) Pruefung + Export — JSON-Download fuer EU-Datenbank-Submission. Der Status-Workflow ist: Entwurf → Bereit → Eingereicht → Registriert.',
legalBasis: 'Art. 49 KI-Verordnung (EU) 2024/1689',
inputs: ['useCases', 'companyProfile'],
outputs: ['euRegistration'],
prerequisiteSteps: ['use-case-assessment'],
dbTables: ['ai_system_registrations'],
dbMode: 'read/write',
ragCollections: [],
isOptional: true,
url: '/sdk/ai-registration',
},
{
id: 'import',
name: 'Dokument-Import',
@@ -693,19 +672,19 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
id: 'vendor-compliance',
name: 'Vendor Compliance',
nameShort: 'Vendor',
package: 'dokumentation',
seq: 2500,
package: 'betrieb',
seq: 4200,
checkpointId: 'CP-VEND',
checkpointType: 'REQUIRED',
checkpointReviewer: 'NONE',
description: 'Pruefung und Verwaltung aller Auftragsverarbeiter und Drittanbieter — Cross-Modul-Integration mit VVT, Obligations, TOM und Loeschfristen.',
descriptionLong: 'Vendor Compliance verwaltet alle externen Dienstleister, die im Auftrag personenbezogene Daten verarbeiten (Auftragsverarbeiter nach Art. 28 DSGVO). Fuer jeden Vendor wird geprueft: Gibt es einen AVV? Wo werden Daten gespeichert (EU/Drittland)? Welche TOMs hat der Vendor? Gibt es Subunternehmer? Cross-Modul-Integration: VVT-Processor-Tab liest Vendors mit role=PROCESSOR direkt aus der Vendor-API, Obligations und Loeschfristen verknuepfen Vendors ueber linked_vendor_ids (JSONB), TOM zeigt Vendor-Controls als Querverweis.',
description: 'Pruefung und Verwaltung aller Auftragsverarbeiter und Drittanbieter.',
descriptionLong: 'Vendor Compliance verwaltet alle externen Dienstleister, die im Auftrag personenbezogene Daten verarbeiten (Auftragsverarbeiter nach Art. 28 DSGVO). Fuer jeden Vendor wird geprueft: Gibt es einen AVV? Wo werden Daten gespeichert (EU/Drittland)? Welche TOMs hat der Vendor? Gibt es Subunternehmer? Die Pruefung umfasst auch regelmässige Re-Assessments und die Verwaltung von Standardvertragsklauseln (SCCs) fuer Drittlandtransfers.',
legalBasis: 'Art. 28 DSGVO (Auftragsverarbeiter), Art. 44-49 (Drittlandtransfer)',
inputs: ['modules', 'vvt'],
outputs: ['vendorAssessments', 'vendorControlInstances'],
prerequisiteSteps: ['vvt'],
dbTables: ['vendor_vendors', 'vendor_contracts', 'vendor_findings', 'vendor_control_instances', 'compliance_templates'],
dbMode: 'read/write',
outputs: ['vendorAssessments'],
prerequisiteSteps: ['escalations'],
dbTables: [],
dbMode: 'none',
ragCollections: ['bp_compliance_recht'],
ragPurpose: 'AVV-Vorlagen und Pruefkataloge',
isOptional: false,

View File

@@ -1,19 +1,18 @@
'use client'
import React, { useState, useCallback, useMemo, useEffect } from 'react'
import React, { useState, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import { useTOMGenerator } from '@/lib/sdk/tom-generator/context'
import { DerivedTOM } from '@/lib/sdk/tom-generator/types'
import { TOMOverviewTab, TOMEditorTab, TOMGapExportTab, TOMDocumentTab } from '@/components/sdk/tom-dashboard'
import { runTOMComplianceCheck, type TOMComplianceCheckResult } from '@/lib/sdk/tom-compliance'
import { TOMOverviewTab, TOMEditorTab, TOMGapExportTab } from '@/components/sdk/tom-dashboard'
// =============================================================================
// TYPES
// =============================================================================
type Tab = 'uebersicht' | 'editor' | 'generator' | 'gap-export' | 'tom-dokument'
type Tab = 'uebersicht' | 'editor' | 'generator' | 'gap-export'
interface TabDefinition {
key: Tab
@@ -25,7 +24,6 @@ const TABS: TabDefinition[] = [
{ key: 'editor', label: 'Detail-Editor' },
{ key: 'generator', label: 'Generator' },
{ key: 'gap-export', label: 'Gap-Analyse & Export' },
{ key: 'tom-dokument', label: 'TOM-Dokument' },
]
// =============================================================================
@@ -35,7 +33,7 @@ const TABS: TabDefinition[] = [
export default function TOMPage() {
const router = useRouter()
const sdk = useSDK()
const { state, dispatch, runGapAnalysis } = useTOMGenerator()
const { state, dispatch, bulkUpdateTOMs, runGapAnalysis } = useTOMGenerator()
// ---------------------------------------------------------------------------
// Local state
@@ -43,58 +41,6 @@ export default function TOMPage() {
const [tab, setTab] = useState<Tab>('uebersicht')
const [selectedTOMId, setSelectedTOMId] = useState<string | null>(null)
const [complianceResult, setComplianceResult] = useState<TOMComplianceCheckResult | null>(null)
const [vendorControls, setVendorControls] = useState<Array<{
vendorId: string
vendorName: string
controlId: string
controlName: string
domain: string
status: string
lastTestedAt?: string
}>>([])
// ---------------------------------------------------------------------------
// Compliance check (auto-run when derivedTOMs change)
// ---------------------------------------------------------------------------
useEffect(() => {
if (state?.derivedTOMs && Array.isArray(state.derivedTOMs) && state.derivedTOMs.length > 0) {
setComplianceResult(runTOMComplianceCheck(state))
}
}, [state?.derivedTOMs])
// ---------------------------------------------------------------------------
// Vendor controls cross-reference (fetch when overview tab is active)
// ---------------------------------------------------------------------------
useEffect(() => {
if (tab !== 'uebersicht') return
Promise.all([
fetch('/api/sdk/v1/vendor-compliance/control-instances?limit=500').then(r => r.ok ? r.json() : null),
fetch('/api/sdk/v1/vendor-compliance/vendors?limit=500').then(r => r.ok ? r.json() : null),
]).then(([ciData, vendorData]) => {
const instances = ciData?.data?.items || []
const vendors = vendorData?.data?.items || []
const vendorMap = new Map<string, string>()
for (const v of vendors) {
vendorMap.set(v.id, v.name)
}
// Filter for TOM-domain controls
const tomControls = instances
.filter((ci: any) => ci.domain === 'TOM' || ci.controlId?.startsWith('VND-TOM'))
.map((ci: any) => ({
vendorId: ci.vendorId || ci.vendor_id,
vendorName: vendorMap.get(ci.vendorId || ci.vendor_id) || 'Unbekannt',
controlId: ci.controlId || ci.control_id,
controlName: ci.controlName || ci.control_name || ci.controlId || ci.control_id,
domain: ci.domain || 'TOM',
status: ci.status || 'UNKNOWN',
lastTestedAt: ci.lastTestedAt || ci.last_tested_at,
}))
setVendorControls(tomControls)
}).catch(() => {})
}, [tab])
// ---------------------------------------------------------------------------
// Computed / memoised values
@@ -370,17 +316,6 @@ export default function TOMPage() {
/>
)
// ---------------------------------------------------------------------------
// Tab 5 TOM-Dokument
// ---------------------------------------------------------------------------
const renderTOMDokument = () => (
<TOMDocumentTab
state={state}
complianceResult={complianceResult}
/>
)
// ---------------------------------------------------------------------------
// Tab content router
// ---------------------------------------------------------------------------
@@ -395,8 +330,6 @@ export default function TOMPage() {
return renderGenerator()
case 'gap-export':
return renderGapExport()
case 'tom-dokument':
return renderTOMDokument()
default:
return renderUebersicht()
}
@@ -418,60 +351,6 @@ export default function TOMPage() {
{/* Active tab content */}
<div>{renderActiveTab()}</div>
{/* Vendor-Controls cross-reference (only on overview tab) */}
{tab === 'uebersicht' && vendorControls.length > 0 && (
<div className="mt-6 bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-base font-semibold text-gray-900">Auftragsverarbeiter-Controls (Art. 28)</h3>
<p className="text-sm text-gray-500 mt-0.5">TOM-relevante Controls aus dem Vendor Register</p>
</div>
<a href="/sdk/vendor-compliance" className="text-sm text-purple-600 hover:text-purple-700 font-medium">
Zum Vendor Register &rarr;
</a>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-2 px-3 text-xs font-medium text-gray-500 uppercase">Vendor</th>
<th className="text-left py-2 px-3 text-xs font-medium text-gray-500 uppercase">Control</th>
<th className="text-left py-2 px-3 text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="text-left py-2 px-3 text-xs font-medium text-gray-500 uppercase">Letzte Pruefung</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{vendorControls.map((vc, i) => (
<tr key={`${vc.vendorId}-${vc.controlId}-${i}`} className="hover:bg-gray-50">
<td className="py-2.5 px-3 font-medium text-gray-900">{vc.vendorName}</td>
<td className="py-2.5 px-3">
<span className="font-mono text-xs text-gray-500">{vc.controlId}</span>
<span className="ml-2 text-gray-700">{vc.controlName !== vc.controlId ? vc.controlName : ''}</span>
</td>
<td className="py-2.5 px-3">
<span className={`px-2 py-0.5 text-xs rounded-full ${
vc.status === 'PASS' ? 'bg-green-100 text-green-700' :
vc.status === 'PARTIAL' ? 'bg-yellow-100 text-yellow-700' :
vc.status === 'FAIL' ? 'bg-red-100 text-red-700' :
'bg-gray-100 text-gray-600'
}`}>
{vc.status === 'PASS' ? 'Bestanden' :
vc.status === 'PARTIAL' ? 'Teilweise' :
vc.status === 'FAIL' ? 'Nicht bestanden' :
vc.status}
</span>
</td>
<td className="py-2.5 px-3 text-gray-500">
{vc.lastTestedAt ? new Date(vc.lastTestedAt).toLocaleDateString('de-DE') : '\u2014'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,560 +0,0 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import {
getAssignments, getContent, getModuleMedia, getQuiz, submitQuiz,
startAssignment, generateCertificate, listCertificates, downloadCertificatePDF,
getMediaStreamURL, getInteractiveManifest, completeAssignment,
} from '@/lib/sdk/training/api'
import type {
TrainingAssignment, ModuleContent, TrainingMedia, QuizSubmitResponse,
InteractiveVideoManifest,
} from '@/lib/sdk/training/types'
import {
STATUS_LABELS, STATUS_COLORS, REGULATION_LABELS,
} from '@/lib/sdk/training/types'
import InteractiveVideoPlayer from '@/components/training/InteractiveVideoPlayer'
type Tab = 'assignments' | 'content' | 'quiz' | 'certificates'
interface QuizQuestionItem {
id: string
question: string
options: string[]
difficulty: string
}
export default function LearnerPage() {
const [activeTab, setActiveTab] = useState<Tab>('assignments')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Assignments
const [assignments, setAssignments] = useState<TrainingAssignment[]>([])
// Content
const [selectedAssignment, setSelectedAssignment] = useState<TrainingAssignment | null>(null)
const [content, setContent] = useState<ModuleContent | null>(null)
const [media, setMedia] = useState<TrainingMedia[]>([])
// Quiz
const [questions, setQuestions] = useState<QuizQuestionItem[]>([])
const [answers, setAnswers] = useState<Record<string, number>>({})
const [quizResult, setQuizResult] = useState<QuizSubmitResponse | null>(null)
const [quizSubmitting, setQuizSubmitting] = useState(false)
const [quizTimer, setQuizTimer] = useState(0)
const [quizActive, setQuizActive] = useState(false)
// Certificates
const [certificates, setCertificates] = useState<TrainingAssignment[]>([])
const [certGenerating, setCertGenerating] = useState(false)
// Interactive Video
const [interactiveManifest, setInteractiveManifest] = useState<InteractiveVideoManifest | null>(null)
// User simulation
const [userId] = useState('00000000-0000-0000-0000-000000000001')
const loadAssignments = useCallback(async () => {
setLoading(true)
try {
const data = await getAssignments({ user_id: userId, limit: 100 })
setAssignments(data.assignments || [])
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setLoading(false)
}
}, [userId])
const loadCertificates = useCallback(async () => {
try {
const data = await listCertificates()
setCertificates(data.certificates || [])
} catch {
// Certificates may not exist yet
}
}, [])
useEffect(() => {
loadAssignments()
loadCertificates()
}, [loadAssignments, loadCertificates])
// Quiz timer
useEffect(() => {
if (!quizActive) return
const interval = setInterval(() => setQuizTimer(t => t + 1), 1000)
return () => clearInterval(interval)
}, [quizActive])
async function loadInteractiveManifest(moduleId: string, assignmentId: string) {
try {
const manifest = await getInteractiveManifest(moduleId, assignmentId)
if (manifest && manifest.checkpoints && manifest.checkpoints.length > 0) {
setInteractiveManifest(manifest)
} else {
setInteractiveManifest(null)
}
} catch {
setInteractiveManifest(null)
}
}
async function handleStartAssignment(assignment: TrainingAssignment) {
try {
await startAssignment(assignment.id)
setSelectedAssignment({ ...assignment, status: 'in_progress' })
// Load content
const [contentData, mediaData] = await Promise.all([
getContent(assignment.module_id).catch(() => null),
getModuleMedia(assignment.module_id).catch(() => ({ media: [] })),
])
setContent(contentData)
setMedia(mediaData.media || [])
await loadInteractiveManifest(assignment.module_id, assignment.id)
setActiveTab('content')
loadAssignments()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Starten')
}
}
async function handleResumeContent(assignment: TrainingAssignment) {
setSelectedAssignment(assignment)
try {
const [contentData, mediaData] = await Promise.all([
getContent(assignment.module_id).catch(() => null),
getModuleMedia(assignment.module_id).catch(() => ({ media: [] })),
])
setContent(contentData)
setMedia(mediaData.media || [])
await loadInteractiveManifest(assignment.module_id, assignment.id)
setActiveTab('content')
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
}
}
async function handleAllCheckpointsPassed() {
if (!selectedAssignment) return
try {
await completeAssignment(selectedAssignment.id)
setSelectedAssignment({ ...selectedAssignment, status: 'completed' })
loadAssignments()
} catch {
// Assignment completion may already be handled
}
}
async function handleStartQuiz() {
if (!selectedAssignment) return
try {
const data = await getQuiz(selectedAssignment.module_id)
setQuestions(data.questions || [])
setAnswers({})
setQuizResult(null)
setQuizTimer(0)
setQuizActive(true)
setActiveTab('quiz')
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Quiz-Laden')
}
}
async function handleSubmitQuiz() {
if (!selectedAssignment || questions.length === 0) return
setQuizSubmitting(true)
setQuizActive(false)
try {
const answerList = questions.map(q => ({
question_id: q.id,
selected_index: answers[q.id] ?? -1,
}))
const result = await submitQuiz(selectedAssignment.module_id, {
assignment_id: selectedAssignment.id,
answers: answerList,
duration_seconds: quizTimer,
})
setQuizResult(result)
loadAssignments()
} catch (e) {
setError(e instanceof Error ? e.message : 'Quiz-Abgabe fehlgeschlagen')
} finally {
setQuizSubmitting(false)
}
}
async function handleGenerateCertificate(assignmentId: string) {
setCertGenerating(true)
try {
const data = await generateCertificate(assignmentId)
if (data.certificate_id) {
const blob = await downloadCertificatePDF(data.certificate_id)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `zertifikat-${data.certificate_id.substring(0, 8)}.pdf`
a.click()
URL.revokeObjectURL(url)
}
loadAssignments()
loadCertificates()
} catch (e) {
setError(e instanceof Error ? e.message : 'Zertifikat-Erstellung fehlgeschlagen')
} finally {
setCertGenerating(false)
}
}
async function handleDownloadPDF(certId: string) {
try {
const blob = await downloadCertificatePDF(certId)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `zertifikat-${certId.substring(0, 8)}.pdf`
a.click()
URL.revokeObjectURL(url)
} catch (e) {
setError(e instanceof Error ? e.message : 'PDF-Download fehlgeschlagen')
}
}
function simpleMarkdownToHtml(md: string): string {
return md
.replace(/^### (.+)$/gm, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
.replace(/^## (.+)$/gm, '<h2 class="text-xl font-bold mt-6 mb-3">$1</h2>')
.replace(/^# (.+)$/gm, '<h1 class="text-2xl font-bold mt-6 mb-3">$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/^- (.+)$/gm, '<li class="ml-4 list-disc">$1</li>')
.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 list-decimal">$2</li>')
.replace(/\n\n/g, '<br/><br/>')
}
function formatTimer(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
const tabs: { key: Tab; label: string }[] = [
{ key: 'assignments', label: 'Meine Schulungen' },
{ key: 'content', label: 'Schulungsinhalt' },
{ key: 'quiz', label: 'Quiz' },
{ key: 'certificates', label: 'Zertifikate' },
]
return (
<div className="max-w-7xl mx-auto p-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Learner Portal</h1>
<p className="text-gray-500 mt-1">Absolvieren Sie Ihre Compliance-Schulungen</p>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
<button onClick={() => setError(null)} className="ml-2 text-red-500 hover:text-red-700">x</button>
</div>
)}
{/* Tabs */}
<div className="border-b border-gray-200 mb-6">
<div className="flex gap-6">
{tabs.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.key
? 'border-indigo-500 text-indigo-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Tab: Meine Schulungen */}
{activeTab === 'assignments' && (
<div>
{loading ? (
<div className="text-center py-12 text-gray-400">Lade Schulungen...</div>
) : assignments.length === 0 ? (
<div className="text-center py-12 text-gray-400">Keine Schulungen zugewiesen</div>
) : (
<div className="grid gap-4">
{assignments.map(a => (
<div key={a.id} className="bg-white border border-gray-200 rounded-lg p-5 hover:shadow-sm transition-shadow">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-gray-900">{a.module_title || a.module_code}</h3>
<span className={`px-2 py-0.5 text-xs rounded-full ${STATUS_COLORS[a.status]?.bg || 'bg-gray-100'} ${STATUS_COLORS[a.status]?.text || 'text-gray-700'}`}>
{STATUS_LABELS[a.status] || a.status}
</span>
</div>
<p className="text-sm text-gray-500 mt-1">
Code: {a.module_code} | Deadline: {new Date(a.deadline).toLocaleDateString('de-DE')}
{a.quiz_score != null && ` | Quiz: ${Math.round(a.quiz_score)}%`}
</p>
{/* Progress bar */}
<div className="mt-3 w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${a.status === 'completed' ? 'bg-green-500' : 'bg-indigo-500'}`}
style={{ width: `${a.progress_percent}%` }}
/>
</div>
<p className="text-xs text-gray-400 mt-1">{a.progress_percent}% abgeschlossen</p>
</div>
<div className="flex gap-2 ml-4">
{a.status === 'pending' && (
<button
onClick={() => handleStartAssignment(a)}
className="px-3 py-1.5 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700"
>
Starten
</button>
)}
{a.status === 'in_progress' && (
<button
onClick={() => handleResumeContent(a)}
className="px-3 py-1.5 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700"
>
Fortsetzen
</button>
)}
{a.status === 'completed' && a.quiz_passed && !a.certificate_id && (
<button
onClick={() => handleGenerateCertificate(a.id)}
disabled={certGenerating}
className="px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 disabled:opacity-50"
>
{certGenerating ? 'Erstelle...' : 'Zertifikat'}
</button>
)}
{a.certificate_id && (
<button
onClick={() => handleDownloadPDF(a.certificate_id!)}
className="px-3 py-1.5 bg-green-100 text-green-700 text-sm rounded-lg hover:bg-green-200"
>
PDF
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Tab: Schulungsinhalt */}
{activeTab === 'content' && (
<div>
{!selectedAssignment ? (
<div className="text-center py-12 text-gray-400">
Waehlen Sie eine Schulung aus dem Tab &quot;Meine Schulungen&quot;
</div>
) : (
<div>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">{selectedAssignment.module_title}</h2>
<button
onClick={handleStartQuiz}
className="px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700"
>
Quiz starten
</button>
</div>
{/* Interactive Video Player */}
{interactiveManifest && selectedAssignment && (
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<p className="text-sm font-medium text-gray-700">Interaktive Video-Schulung</p>
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded-full">Interaktiv</span>
</div>
<InteractiveVideoPlayer
manifest={interactiveManifest}
assignmentId={selectedAssignment.id}
onAllCheckpointsPassed={handleAllCheckpointsPassed}
/>
</div>
)}
{/* Media players (standard audio/video) */}
{media.length > 0 && (
<div className="mb-6 grid gap-4 md:grid-cols-2">
{media.filter(m => m.media_type === 'audio' && m.status === 'completed').map(m => (
<div key={m.id} className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm font-medium text-gray-700 mb-2">Audio-Schulung</p>
<audio controls className="w-full" src={getMediaStreamURL(m.id)}>
Ihr Browser unterstuetzt kein Audio.
</audio>
</div>
))}
{media.filter(m => m.media_type === 'video' && m.status === 'completed' && m.generated_by !== 'tts_ffmpeg_interactive').map(m => (
<div key={m.id} className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm font-medium text-gray-700 mb-2">Video-Schulung</p>
<video controls className="w-full rounded" src={getMediaStreamURL(m.id)}>
Ihr Browser unterstuetzt kein Video.
</video>
</div>
))}
</div>
)}
{/* Content body */}
{content ? (
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div
className="prose max-w-none text-gray-800"
dangerouslySetInnerHTML={{ __html: simpleMarkdownToHtml(content.content_body) }}
/>
</div>
) : (
<div className="text-center py-8 text-gray-400">Kein Schulungsinhalt verfuegbar</div>
)}
</div>
)}
</div>
)}
{/* Tab: Quiz */}
{activeTab === 'quiz' && (
<div>
{questions.length === 0 ? (
<div className="text-center py-12 text-gray-400">
Starten Sie ein Quiz aus dem Schulungsinhalt-Tab
</div>
) : quizResult ? (
/* Quiz Results */
<div className="max-w-lg mx-auto">
<div className={`text-center p-8 rounded-lg border-2 ${quizResult.passed ? 'border-green-300 bg-green-50' : 'border-red-300 bg-red-50'}`}>
<div className="text-4xl mb-3">{quizResult.passed ? '\u2705' : '\u274C'}</div>
<h2 className="text-2xl font-bold mb-2">
{quizResult.passed ? 'Bestanden!' : 'Nicht bestanden'}
</h2>
<p className="text-lg text-gray-700">
{quizResult.correct_count} von {quizResult.total_count} richtig ({Math.round(quizResult.score)}%)
</p>
<p className="text-sm text-gray-500 mt-1">
Bestehensgrenze: {quizResult.threshold}% | Zeit: {formatTimer(quizTimer)}
</p>
{quizResult.passed && selectedAssignment && !selectedAssignment.certificate_id && (
<button
onClick={() => handleGenerateCertificate(selectedAssignment.id)}
disabled={certGenerating}
className="mt-4 px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50"
>
{certGenerating ? 'Erstelle Zertifikat...' : 'Zertifikat generieren & herunterladen'}
</button>
)}
{!quizResult.passed && (
<button
onClick={handleStartQuiz}
className="mt-4 px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
>
Quiz erneut versuchen
</button>
)}
</div>
</div>
) : (
/* Quiz Questions */
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Quiz {selectedAssignment?.module_title}</h2>
<span className="text-sm text-gray-500 font-mono bg-gray-100 px-3 py-1 rounded">
{formatTimer(quizTimer)}
</span>
</div>
<div className="space-y-6">
{questions.map((q, idx) => (
<div key={q.id} className="bg-white border border-gray-200 rounded-lg p-5">
<p className="font-medium text-gray-900 mb-3">
<span className="text-indigo-600 mr-2">Frage {idx + 1}.</span>
{q.question}
</p>
<div className="space-y-2">
{q.options.map((opt, oi) => (
<label
key={oi}
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
answers[q.id] === oi
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-200 hover:bg-gray-50'
}`}
>
<input
type="radio"
name={q.id}
checked={answers[q.id] === oi}
onChange={() => setAnswers(prev => ({ ...prev, [q.id]: oi }))}
className="text-indigo-600"
/>
<span className="text-sm text-gray-700">{opt}</span>
</label>
))}
</div>
</div>
))}
</div>
<div className="mt-6 flex justify-end">
<button
onClick={handleSubmitQuiz}
disabled={quizSubmitting || Object.keys(answers).length < questions.length}
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
>
{quizSubmitting ? 'Wird ausgewertet...' : `Quiz abgeben (${Object.keys(answers).length}/${questions.length})`}
</button>
</div>
</div>
)}
</div>
)}
{/* Tab: Zertifikate */}
{activeTab === 'certificates' && (
<div>
{certificates.length === 0 ? (
<div className="text-center py-12 text-gray-400">
Noch keine Zertifikate vorhanden. Schliessen Sie eine Schulung mit Quiz ab.
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{certificates.map(cert => (
<div key={cert.id} className="bg-white border border-gray-200 rounded-lg p-5">
<div className="flex items-start justify-between mb-3">
<h3 className="font-semibold text-gray-900 text-sm">{cert.module_title}</h3>
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">Bestanden</span>
</div>
<div className="text-xs text-gray-500 space-y-1">
<p>Mitarbeiter: {cert.user_name}</p>
<p>Abschluss: {cert.completed_at ? new Date(cert.completed_at).toLocaleDateString('de-DE') : '-'}</p>
{cert.quiz_score != null && <p>Ergebnis: {Math.round(cert.quiz_score)}%</p>}
<p className="font-mono text-[10px] text-gray-400">ID: {cert.certificate_id?.substring(0, 12)}</p>
</div>
{cert.certificate_id && (
<button
onClick={() => handleDownloadPDF(cert.certificate_id!)}
className="mt-3 w-full px-3 py-1.5 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700"
>
PDF herunterladen
</button>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -9,18 +9,14 @@ import {
createModule, updateModule, deleteModule,
deleteMatrixEntry, setMatrixEntry,
startAssignment, completeAssignment, updateAssignment,
listBlockConfigs, createBlockConfig, deleteBlockConfig,
previewBlock, generateBlock, getCanonicalMeta,
generateInteractiveVideo,
} from '@/lib/sdk/training/api'
import type {
TrainingModule, TrainingAssignment,
MatrixResponse, TrainingStats, DeadlineInfo, AuditLogEntry, ModuleContent, TrainingMedia,
TrainingBlockConfig, CanonicalControlMeta, BlockPreview, BlockGenerateResult,
} from '@/lib/sdk/training/types'
import {
REGULATION_LABELS, REGULATION_COLORS, FREQUENCY_LABELS,
STATUS_LABELS, STATUS_COLORS, ROLE_LABELS, ALL_ROLES, TARGET_AUDIENCE_LABELS,
STATUS_LABELS, STATUS_COLORS, ROLE_LABELS, ALL_ROLES,
} from '@/lib/sdk/training/types'
import AudioPlayer from '@/components/training/AudioPlayer'
import VideoPlayer from '@/components/training/VideoPlayer'
@@ -45,7 +41,6 @@ export default function TrainingPage() {
const [bulkGenerating, setBulkGenerating] = useState(false)
const [bulkResult, setBulkResult] = useState<{ generated: number; skipped: number; errors: string[] } | null>(null)
const [moduleMedia, setModuleMedia] = useState<TrainingMedia[]>([])
const [interactiveGenerating, setInteractiveGenerating] = useState(false)
const [statusFilter, setStatusFilter] = useState<string>('')
const [regulationFilter, setRegulationFilter] = useState<string>('')
@@ -57,15 +52,6 @@ export default function TrainingPage() {
const [selectedAssignment, setSelectedAssignment] = useState<TrainingAssignment | null>(null)
const [escalationResult, setEscalationResult] = useState<{ total_checked: number; escalated: number } | null>(null)
// Block (Controls → Module) state
const [blocks, setBlocks] = useState<TrainingBlockConfig[]>([])
const [canonicalMeta, setCanonicalMeta] = useState<CanonicalControlMeta | null>(null)
const [showBlockCreate, setShowBlockCreate] = useState(false)
const [blockPreview, setBlockPreview] = useState<BlockPreview | null>(null)
const [blockPreviewId, setBlockPreviewId] = useState<string>('')
const [blockGenerating, setBlockGenerating] = useState(false)
const [blockResult, setBlockResult] = useState<BlockGenerateResult | null>(null)
useEffect(() => {
loadData()
}, [])
@@ -80,15 +66,13 @@ export default function TrainingPage() {
setLoading(true)
setError(null)
try {
const [statsRes, modulesRes, matrixRes, assignmentsRes, deadlinesRes, auditRes, blocksRes, metaRes] = await Promise.allSettled([
const [statsRes, modulesRes, matrixRes, assignmentsRes, deadlinesRes, auditRes] = await Promise.allSettled([
getStats(),
getModules(),
getMatrix(),
getAssignments({ limit: 50 }),
getDeadlines(10),
getAuditLog({ limit: 30 }),
listBlockConfigs(),
getCanonicalMeta(),
])
if (statsRes.status === 'fulfilled') setStats(statsRes.value)
@@ -97,8 +81,6 @@ export default function TrainingPage() {
if (assignmentsRes.status === 'fulfilled') setAssignments(assignmentsRes.value.assignments)
if (deadlinesRes.status === 'fulfilled') setDeadlines(deadlinesRes.value.deadlines)
if (auditRes.status === 'fulfilled') setAuditLog(auditRes.value.entries)
if (blocksRes.status === 'fulfilled') setBlocks(blocksRes.value.blocks)
if (metaRes.status === 'fulfilled') setCanonicalMeta(metaRes.value)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
@@ -132,19 +114,6 @@ export default function TrainingPage() {
}
}
async function handleGenerateInteractiveVideo() {
if (!selectedModuleId) return
setInteractiveGenerating(true)
try {
await generateInteractiveVideo(selectedModuleId)
await loadModuleMedia(selectedModuleId)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler bei der interaktiven Video-Generierung')
} finally {
setInteractiveGenerating(false)
}
}
async function handlePublishContent(contentId: string) {
try {
await publishContent(contentId)
@@ -221,59 +190,6 @@ export default function TrainingPage() {
}
}
// Block handlers
async function handleCreateBlock(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;
}) {
try {
await createBlockConfig(data)
setShowBlockCreate(false)
const res = await listBlockConfigs()
setBlocks(res.blocks)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Erstellen')
}
}
async function handleDeleteBlock(id: string) {
if (!confirm('Block-Konfiguration wirklich loeschen?')) return
try {
await deleteBlockConfig(id)
const res = await listBlockConfigs()
setBlocks(res.blocks)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Loeschen')
}
}
async function handlePreviewBlock(id: string) {
setBlockPreviewId(id)
setBlockPreview(null)
setBlockResult(null)
try {
const preview = await previewBlock(id)
setBlockPreview(preview)
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Preview')
}
}
async function handleGenerateBlock(id: string) {
setBlockGenerating(true)
setBlockResult(null)
try {
const result = await generateBlock(id, { language: 'de', auto_matrix: true })
setBlockResult(result)
await loadData()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler bei der Block-Generierung')
} finally {
setBlockGenerating(false)
}
}
const tabs: { id: Tab; label: string }[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'modules', label: 'Modulkatalog' },
@@ -605,228 +521,6 @@ export default function TrainingPage() {
{activeTab === 'content' && (
<div className="space-y-6">
{/* Training Blocks — Controls → Schulungsmodule */}
<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={() => setShowBlockCreate(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>
{/* Block list */}
{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={() => handlePreviewBlock(block.id)}
className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
>
Preview
</button>
<button
onClick={() => handleGenerateBlock(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={() => handleDeleteBlock(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>
)}
{/* Preview result */}
{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>
)}
{/* Generate result */}
{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)
handleCreateBlock({
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={() => setShowBlockCreate(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>
@@ -926,35 +620,6 @@ export default function TrainingPage() {
/>
)}
{/* Interactive Video */}
{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={handleGenerateInteractiveVideo}
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>
)}
{/* Script Preview */}
{selectedModuleId && generatedContent?.is_published && (
<ScriptPreview moduleId={selectedModuleId} />

View File

@@ -57,8 +57,6 @@ interface FullAssessment {
dsfa_recommended: boolean
art22_risk: boolean
training_allowed: string
betrvg_conflict_score?: number
betrvg_consultation_required?: boolean
triggered_rules?: TriggeredRule[]
required_controls?: RequiredControl[]
recommended_architecture?: PatternRecommendation[]
@@ -169,8 +167,6 @@ export default function AssessmentDetailPage() {
dsfa_recommended: assessment.dsfa_recommended,
art22_risk: assessment.art22_risk,
training_allowed: assessment.training_allowed,
betrvg_conflict_score: assessment.betrvg_conflict_score,
betrvg_consultation_required: assessment.betrvg_consultation_required,
// AssessmentResultCard expects rule_code; backend stores code — map here
triggered_rules: assessment.triggered_rules?.map(r => ({
rule_code: r.code,

View File

@@ -10,8 +10,6 @@ interface Assessment {
feasibility: string
risk_level: string
risk_score: number
betrvg_conflict_score?: number
betrvg_consultation_required?: boolean
domain: string
created_at: string
}
@@ -196,16 +194,6 @@ export default function UseCasesPage() {
<span className={`px-2 py-0.5 text-xs rounded-full ${feasibility.bg} ${feasibility.text}`}>
{feasibility.label}
</span>
{assessment.betrvg_conflict_score != null && assessment.betrvg_conflict_score > 0 && (
<span className={`px-2 py-0.5 text-xs rounded-full ${
assessment.betrvg_conflict_score >= 75 ? 'bg-red-100 text-red-700' :
assessment.betrvg_conflict_score >= 50 ? 'bg-orange-100 text-orange-700' :
assessment.betrvg_conflict_score >= 25 ? 'bg-yellow-100 text-yellow-700' :
'bg-green-100 text-green-700'
}`}>
BR {assessment.betrvg_conflict_score}
</span>
)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span>{assessment.domain}</span>

File diff suppressed because it is too large Load Diff

View File

@@ -1128,88 +1128,24 @@ export default function WhistleblowerPage() {
</div>
)}
{/* Info Box about HinSchG (Overview Tab) */}
{/* Info Box about HinSchG Deadlines (Overview Tab) */}
{activeTab === 'overview' && (
<div className="space-y-4">
{/* Gesetzliche Grundlage */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-5">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" 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>
<div>
<h4 className="font-medium text-blue-800">Gesetzliche Grundlage: Hinweisgeberschutzgesetz (HinSchG)</h4>
<p className="text-sm text-blue-600 mt-1">
Das HinSchG setzt die <strong>EU-Whistleblowing-Richtlinie (2019/1937)</strong> in deutsches Recht um
und ist seit dem <strong>2. Juli 2023</strong> in Kraft. Seit dem <strong>17. Dezember 2023</strong> gilt
die Pflicht zur Einrichtung einer internen Meldestelle auch fuer Unternehmen ab 50 Beschaeftigten (ss 12 HinSchG).
</p>
</div>
</div>
</div>
{/* Fristen & Pflichten */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white border border-gray-200 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h5 className="text-sm font-semibold text-gray-900">7-Tage-Frist</h5>
</div>
<p className="text-xs text-gray-600">
Eingangsbestaetigung an den Hinweisgeber innerhalb von 7 Tagen nach Meldungseingang (ss 17 Abs. 1 S. 2 HinSchG).
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" 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>
<div>
<h4 className="font-medium text-blue-800">HinSchG-Fristen</h4>
<p className="text-sm text-blue-600 mt-1">
Nach dem Hinweisgeberschutzgesetz (HinSchG) gelten folgende Fristen:
Die Eingangsbestaetigung muss innerhalb von <strong>7 Tagen</strong> an den
Hinweisgeber versendet werden (ss 17 Abs. 1 S. 2).
Eine Rueckmeldung ueber ergriffene Massnahmen muss innerhalb von <strong>3 Monaten</strong> nach
Eingangsbestaetigung erfolgen (ss 17 Abs. 2).
Der Schutz des Hinweisgebers vor Repressalien ist zwingend sicherzustellen (ss 36).
</p>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<h5 className="text-sm font-semibold text-gray-900">3-Monate-Frist</h5>
</div>
<p className="text-xs text-gray-600">
Rueckmeldung ueber ergriffene Folgemaßnahmen innerhalb von 3 Monaten nach Eingangsbestaetigung (ss 17 Abs. 2 HinSchG).
</p>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-4">
<div className="flex items-center gap-2 mb-2">
<svg className="w-4 h-4 text-red-500" 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>
<h5 className="text-sm font-semibold text-gray-900">3 Jahre Aufbewahrung</h5>
</div>
<p className="text-xs text-gray-600">
Dokumentation der Meldungen und Folgemaßnahmen ist 3 Jahre nach Abschluss aufzubewahren (ss 11 Abs. 5 HinSchG).
</p>
</div>
</div>
{/* Sachlicher Anwendungsbereich & Schutz */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
<h5 className="text-sm font-semibold text-amber-800 mb-2">Sachlicher Anwendungsbereich (ss 2 HinSchG)</h5>
<ul className="text-xs text-amber-700 space-y-1">
<li>Verstoesse gegen Strafvorschriften (StGB, Nebenstrafrecht)</li>
<li>Verstoesse gegen Datenschutzrecht (DSGVO, BDSG)</li>
<li>Geldwaesche und Terrorismusfinanzierung (GwG)</li>
<li>Produktsicherheit und Verbraucherschutz</li>
<li>Umweltschutz und Lebensmittelsicherheit</li>
<li>Arbeitsschutz und Arbeitnehmerrechte</li>
<li>Wettbewerbs- und Kartellrecht</li>
<li>Steuer- und Abgabenrecht (bei Unternehmen)</li>
</ul>
</div>
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
<h5 className="text-sm font-semibold text-green-800 mb-2">Schutz des Hinweisgebers (ss 3637 HinSchG)</h5>
<ul className="text-xs text-green-700 space-y-1">
<li><strong>Repressalienverbot:</strong> Jede Benachteiligung ist untersagt (ss 36)</li>
<li><strong>Beweislastumkehr:</strong> Arbeitgeber muss beweisen, dass Maßnahmen nicht mit Meldung zusammenhaengen</li>
<li><strong>Schadensersatz:</strong> Bei Verstoessen gegen Repressalienverbot (ss 37)</li>
<li><strong>Vertraulichkeit:</strong> Identitaet darf nur bei Zustimmung oder gesetzlicher Pflicht offengelegt werden (ss 8)</li>
<li><strong>Bussgelder:</strong> Bis zu 50.000 EUR bei Verstoessen gegen die Einrichtungspflicht (ss 40)</li>
</ul>
</div>
</div>
</div>
)}

View File

@@ -546,89 +546,6 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
/>
</div>
{/* KI-Compliance */}
<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">
KI-Compliance
</div>
)}
<AdditionalModuleItem
href="/sdk/advisory-board"
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 2" />
</svg>
}
label="Use Case Erfassung"
isActive={pathname === '/sdk/advisory-board'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/use-cases"
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 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
}
label="Use Cases"
isActive={pathname?.startsWith('/sdk/use-cases') ?? false}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/ai-act"
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="AI Act"
isActive={pathname?.startsWith('/sdk/ai-act') ?? false}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/ai-registration"
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 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
}
label="EU Registrierung"
isActive={pathname?.startsWith('/sdk/ai-registration') ?? false}
collapsed={collapsed}
projectId={projectId}
/>
</div>
{/* Payment Compliance */}
<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">
Payment / Terminal
</div>
)}
<AdditionalModuleItem
href="/sdk/payment-compliance"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
}
label="Payment Compliance"
isActive={pathname?.startsWith('/sdk/payment-compliance') ?? false}
collapsed={collapsed}
projectId={projectId}
/>
</div>
{/* Additional Modules */}
<div className="border-t border-gray-100 py-2">
{!collapsed && (
@@ -636,32 +553,6 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
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={
@@ -726,19 +617,6 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
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={

View File

@@ -545,8 +545,8 @@ export const STEP_EXPLANATIONS = {
},
'tom': {
title: 'Technische und Organisatorische Massnahmen',
description: 'TOMs nach Art. 32 DSGVO mit Vendor-Controls-Querverweis',
explanation: 'TOMs sind konkrete Sicherheitsmassnahmen zum Schutz personenbezogener Daten. Das Dashboard zeigt den Status aller aus dem TOM Generator abgeleiteten Massnahmen mit SDM-Mapping und Gap-Analyse. Im Uebersicht-Tab werden zusaetzlich Vendor-TOM-Controls (VND-TOM-01 bis VND-TOM-06) aus dem Vendor-Compliance-Modul als Querverweis angezeigt.',
description: 'Dokumentieren Sie Ihre TOMs nach Art. 32 DSGVO',
explanation: 'TOMs sind konkrete Sicherheitsmassnahmen zum Schutz personenbezogener Daten. Das Dashboard zeigt den Status aller aus dem TOM Generator abgeleiteten Massnahmen mit SDM-Mapping und Gap-Analyse.',
tips: [
{
icon: 'warning' as const,
@@ -563,17 +563,12 @@ export const STEP_EXPLANATIONS = {
title: 'SDM-Mapping',
description: 'Kontrollen werden den 7 SDM-Gewaehrleistungszielen zugeordnet: Verfuegbarkeit, Integritaet, Vertraulichkeit, Nichtverkettung, Intervenierbarkeit, Transparenz, Datenminimierung.',
},
{
icon: 'success' as const,
title: 'Vendor-Controls',
description: 'Im Uebersicht-Tab werden Vendor-TOM-Controls (VND-TOM-01 bis 06) als Read-Only-Querverweis angezeigt: Verschluesselung, Zugriffskontrolle, Verfuegbarkeit und Ueberpruefungsverfahren Ihrer Auftragsverarbeiter.',
},
],
},
'vvt': {
title: 'Verarbeitungsverzeichnis',
description: 'Verarbeitungsverzeichnis nach Art. 30 DSGVO mit integriertem Processor-Tab',
explanation: 'Das Verarbeitungsverzeichnis (VVT) dokumentiert alle Verarbeitungstaetigkeiten mit personenbezogenen Daten. Der integrierte Generator-Fragebogen befuellt 70-90% der Pflichtfelder automatisch. Der Tab "Auftragsverarbeiter (Abs. 2)" liest Vendors mit role=PROCESSOR/SUB_PROCESSOR direkt aus der Vendor-Compliance-API — keine doppelte Datenhaltung.',
description: 'Erstellen und verwalten Sie Ihr Verzeichnis nach Art. 30 DSGVO',
explanation: 'Das Verarbeitungsverzeichnis (VVT) dokumentiert alle Verarbeitungstaetigkeiten mit personenbezogenen Daten. Der integrierte Generator-Fragebogen befuellt 70-90% der Pflichtfelder automatisch anhand Ihres Unternehmensprofils.',
tips: [
{
icon: 'warning' as const,
@@ -590,11 +585,6 @@ export const STEP_EXPLANATIONS = {
title: 'Kein oeffentliches Dokument',
description: 'Das VVT ist ein internes Dokument. Es muss der Aufsichtsbehoerde nur auf Verlangen vorgelegt werden (Art. 30 Abs. 4).',
},
{
icon: 'success' as const,
title: 'Processor-Tab (Art. 30 Abs. 2)',
description: 'Auftragsverarbeiter werden direkt aus dem Vendor Register gelesen (Read-Only). Neue Vendors werden im Vendor-Compliance-Modul angelegt und erscheinen hier automatisch. PDF-Druck fuer Art. 30 Abs. 2 Dokument.',
},
],
},
'cookie-banner': {
@@ -621,8 +611,8 @@ export const STEP_EXPLANATIONS = {
},
'obligations': {
title: 'Pflichtenuebersicht',
description: 'Regulatorische Pflichten mit 12 Compliance-Checks und Vendor-Verknuepfung',
explanation: 'Die Pflichtenuebersicht aggregiert alle Anforderungen aus DSGVO, AI Act, NIS2 und weiteren Regulierungen. 12 automatische Compliance-Checks pruefen Vollstaendigkeit, Fristen, Nachweise und Vendor-Verknuepfungen. Art.-28-Pflichten koennen mit Auftragsverarbeitern aus dem Vendor Register verknuepft werden. Das Pflichtenregister-Dokument (11 Sektionen) kann als auditfaehiges PDF gedruckt werden.',
description: 'Alle regulatorischen Pflichten auf einen Blick',
explanation: 'Die Pflichtenuebersicht aggregiert alle Anforderungen aus DSGVO, AI Act, NIS2 und weiteren Regulierungen. Sie sehen auf einen Blick, welche Pflichten fuer Ihr Unternehmen gelten.',
tips: [
{
icon: 'info' as const,
@@ -631,25 +621,15 @@ export const STEP_EXPLANATIONS = {
},
{
icon: 'warning' as const,
title: 'Compliance-Checks',
description: '12 automatische Checks: Fehlende Verantwortliche, ueberfaellige Fristen, fehlende Nachweise, keine Rechtsreferenz, stagnierende Regulierungen, nicht gestartete High-Priority-Pflichten, fehlende Vendor-Verknuepfung (Art. 28) u.v.m.',
},
{
icon: 'success' as const,
title: 'Vendor-Verknuepfung',
description: 'Art.-28-Pflichten (Auftragsverarbeitung) koennen direkt mit Vendors aus dem Vendor Register verknuepft werden. Check #12 (MISSING_VENDOR_LINK) warnt bei fehlender Verknuepfung.',
},
{
icon: 'lightbulb' as const,
title: 'Pflichtenregister-Dokument',
description: 'Generieren Sie ein auditfaehiges Pflichtenregister mit 11 Sektionen: Ziel, Geltungsbereich, Methodik, Regulatorische Grundlagen, Pflichtenuebersicht, Details, Verantwortlichkeiten, Fristen, Nachweisverzeichnis, Compliance-Status und Aenderungshistorie.',
title: 'Fristen',
description: 'Achten Sie auf die Umsetzungsfristen. Einige Pflichten haben feste Deadlines.',
},
],
},
'loeschfristen': {
title: 'Loeschfristen',
description: 'Aufbewahrungsrichtlinien mit VVT-Verknuepfung und Vendor-Zuordnung',
explanation: 'Loeschfristen legen fest, wie lange personenbezogene Daten gespeichert werden duerfen. Die 3-Stufen-Logik (Zweckende, Aufbewahrungspflicht, Legal Hold) stellt sicher, dass alle gesetzlichen Anforderungen beruecksichtigt werden. Policies koennen mit VVT-Verarbeitungstaetigkeiten und Auftragsverarbeitern aus dem Vendor Register verknuepft werden.',
description: 'Definieren Sie Aufbewahrungsrichtlinien fuer Ihre Daten',
explanation: 'Loeschfristen legen fest, wie lange personenbezogene Daten gespeichert werden duerfen. Die 3-Stufen-Logik (Zweckende, Aufbewahrungspflicht, Legal Hold) stellt sicher, dass alle gesetzlichen Anforderungen beruecksichtigt werden.',
tips: [
{
icon: 'warning' as const,
@@ -666,11 +646,6 @@ export const STEP_EXPLANATIONS = {
title: 'Backup-Behandlung',
description: 'Auch Backups muessen ins Loeschkonzept einbezogen werden. Daten koennen nach primaerer Loeschung noch in Backup-Systemen existieren.',
},
{
icon: 'success' as const,
title: 'Vendor-Verknuepfung',
description: 'Loeschfrist-Policies koennen mit Auftragsverarbeitern verknuepft werden. So ist dokumentiert, welche Vendors Loeschpflichten fuer bestimmte Datenkategorien haben.',
},
],
},
'consent': {
@@ -741,33 +716,6 @@ export const STEP_EXPLANATIONS = {
},
],
},
'vendor-compliance': {
title: 'Vendor Compliance',
description: 'Auftragsverarbeiter-Management mit Cross-Modul-Integration',
explanation: 'Vendor Compliance verwaltet alle Auftragsverarbeiter (Art. 28 DSGVO) und Drittanbieter. Fuer jeden Vendor werden AVVs, Drittlandtransfers, TOMs und Subunternehmer geprueft. Das Modul ist zentral mit vier weiteren Modulen integriert: VVT-Processor-Tab liest Vendors direkt aus der API, Obligations und Loeschfristen verknuepfen Vendors ueber linked_vendor_ids, TOM zeigt Vendor-Controls als Querverweis.',
tips: [
{
icon: 'warning' as const,
title: 'Art. 28 DSGVO',
description: 'Jede Auftragsverarbeitung erfordert einen schriftlichen Vertrag (AVV). Pruefen Sie: Weisungsgebundenheit, TOMs, Subunternehmer-Genehmigung, Loeschpflicht und Audit-Recht.',
},
{
icon: 'info' as const,
title: 'Cross-Modul-Integration',
description: 'Vendors erscheinen automatisch im VVT-Processor-Tab, koennen in Obligations und Loeschfristen verknuepft werden, und ihre TOM-Controls werden im TOM-Modul als Querverweis angezeigt.',
},
{
icon: 'lightbulb' as const,
title: 'Drittlandtransfer',
description: 'Bei Datenverarbeitung ausserhalb der EU/EWR sind Standardvertragsklauseln (SCCs) oder andere Garantien nach Art. 44-49 DSGVO erforderlich.',
},
{
icon: 'success' as const,
title: 'Controls Library',
description: '6 TOM-Domain Controls (VND-TOM-01 bis VND-TOM-06) pruefen Verschluesselung, Zugriffskontrolle, Verfuegbarkeit und Ueberpruefungsverfahren bei Ihren Auftragsverarbeitern.',
},
],
},
'document-generator': {
title: 'Dokumentengenerator',
description: 'Generieren Sie rechtliche Dokumente aus lizenzkonformen Vorlagen',
@@ -904,28 +852,18 @@ export const STEP_EXPLANATIONS = {
},
'whistleblower': {
title: 'Hinweisgebersystem',
description: 'Interne Meldestelle gemaess Hinweisgeberschutzgesetz (HinSchG) — seit 17. Dezember 2023 Pflicht fuer alle Unternehmen ab 50 Beschaeftigten',
explanation: 'Das Hinweisgebersystem implementiert eine HinSchG-konforme interne Meldestelle fuer die sichere, auch anonyme Meldung von Rechtsverstoessen. Es setzt die EU-Whistleblowing-Richtlinie (2019/1937) in deutsches Recht um. Beschaeftigungsgeber mit mindestens 50 Beschaeftigten sind zur Einrichtung verpflichtet (§ 12 HinSchG). Das System unterstuetzt den gesamten Meldeprozess: Einreichung, Eingangsbestaetigung (7-Tage-Frist), Sachverhaltspruefung, Folgemaßnahmen und Rueckmeldung (3-Monate-Frist).',
description: 'Meldestelle gemaess Hinweisgeberschutzgesetz (HinSchG)',
explanation: 'Das Hinweisgebersystem bietet eine sichere, anonyme Meldestelle fuer Compliance-Verstoesse gemaess dem Hinweisgeberschutzgesetz (HinSchG). Unternehmen ab 50 Mitarbeitern sind zur Einrichtung verpflichtet.',
tips: [
{
icon: 'warning' as const,
title: 'Pflicht ab 50 Beschaeftigten',
description: 'Seit 17.12.2023 gilt die Pflicht fuer ALLE Unternehmen ab 50 Beschaeftigten (§ 12 HinSchG). Verstoesse koennen mit Bussgeldern bis zu 50.000 EUR geahndet werden (§ 40 HinSchG).',
title: 'Pflicht ab 50 MA',
description: 'Seit Juli 2023 muessen Unternehmen ab 50 Mitarbeitern eine interne Meldestelle einrichten (HinSchG §12).',
},
{
icon: 'info' as const,
title: 'Anonymitaet & Vertraulichkeit',
description: 'Die Identitaet des Hinweisgebers ist streng vertraulich zu behandeln (§ 8 HinSchG). Anonyme Meldungen sollen bearbeitet werden. Repressalien sind verboten und loesen Schadensersatzpflicht aus (§ 36, § 37 HinSchG).',
},
{
icon: 'lightbulb' as const,
title: 'Gesetzliche Fristen',
description: 'Eingangsbestaetigung innerhalb von 7 Tagen (§ 17 Abs. 1 S. 2). Rueckmeldung ueber ergriffene Folgemaßnahmen innerhalb von 3 Monaten nach Eingangsbestaetigung (§ 17 Abs. 2). Die Dokumentation muss 3 Jahre aufbewahrt werden (§ 11 HinSchG).',
},
{
icon: 'warning' as const,
title: 'Sachlicher Anwendungsbereich',
description: 'Erfasst werden Verstoesse gegen EU-Recht und nationales Recht, u.a. Strafrecht, Datenschutz (DSGVO/BDSG), Arbeitsschutz, Umweltschutz, Geldwaesche, Produktsicherheit und Verbraucherschutz (§ 2 HinSchG).',
title: 'Anonymitaet',
description: 'Die Identitaet des Hinweisgebers muss geschuetzt werden. Repressalien gegen Hinweisgeber sind verboten.',
},
],
},

View File

@@ -1,554 +0,0 @@
'use client'
import React, { useState, useEffect, useCallback } from 'react'
// =============================================================================
// TYPES
// =============================================================================
interface DecisionTreeQuestion {
id: string
axis: 'high_risk' | 'gpai'
question: string
description: string
article_ref: string
skip_if?: string
}
interface DecisionTreeDefinition {
id: string
name: string
version: string
questions: DecisionTreeQuestion[]
}
interface DecisionTreeAnswer {
question_id: string
value: boolean
note?: string
}
interface GPAIClassification {
is_gpai: boolean
is_systemic_risk: boolean
gpai_category: 'none' | 'standard' | 'systemic'
applicable_articles: string[]
obligations: string[]
}
interface DecisionTreeResult {
id: string
tenant_id: string
system_name: string
system_description?: string
answers: Record<string, DecisionTreeAnswer>
high_risk_result: string
gpai_result: GPAIClassification
combined_obligations: string[]
applicable_articles: string[]
created_at: string
}
// =============================================================================
// CONSTANTS
// =============================================================================
const RISK_LEVEL_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
unacceptable: { label: 'Unzulässig', color: 'text-red-700', bg: 'bg-red-50', border: 'border-red-200' },
high_risk: { label: 'Hochrisiko', color: 'text-orange-700', bg: 'bg-orange-50', border: 'border-orange-200' },
limited_risk: { label: 'Begrenztes Risiko', color: 'text-yellow-700', bg: 'bg-yellow-50', border: 'border-yellow-200' },
minimal_risk: { label: 'Minimales Risiko', color: 'text-green-700', bg: 'bg-green-50', border: 'border-green-200' },
not_applicable: { label: 'Nicht anwendbar', color: 'text-gray-500', bg: 'bg-gray-50', border: 'border-gray-200' },
}
const GPAI_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
none: { label: 'Kein GPAI', color: 'text-gray-500', bg: 'bg-gray-50', border: 'border-gray-200' },
standard: { label: 'GPAI Standard', color: 'text-blue-700', bg: 'bg-blue-50', border: 'border-blue-200' },
systemic: { label: 'GPAI Systemisches Risiko', color: 'text-purple-700', bg: 'bg-purple-50', border: 'border-purple-200' },
}
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export default function DecisionTreeWizard() {
const [definition, setDefinition] = useState<DecisionTreeDefinition | null>(null)
const [answers, setAnswers] = useState<Record<string, DecisionTreeAnswer>>({})
const [currentIdx, setCurrentIdx] = useState(0)
const [systemName, setSystemName] = useState('')
const [systemDescription, setSystemDescription] = useState('')
const [result, setResult] = useState<DecisionTreeResult | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [phase, setPhase] = useState<'intro' | 'questions' | 'result'>('intro')
// Load decision tree definition
useEffect(() => {
const load = async () => {
try {
const res = await fetch('/api/sdk/v1/ucca/decision-tree')
if (res.ok) {
const data = await res.json()
setDefinition(data)
} else {
setError('Entscheidungsbaum konnte nicht geladen werden')
}
} catch {
setError('Verbindung zum Backend fehlgeschlagen')
} finally {
setLoading(false)
}
}
load()
}, [])
// Get visible questions (respecting skip logic)
const getVisibleQuestions = useCallback((): DecisionTreeQuestion[] => {
if (!definition) return []
return definition.questions.filter(q => {
if (!q.skip_if) return true
// Skip this question if the gate question was answered "no"
const gateAnswer = answers[q.skip_if]
if (gateAnswer && !gateAnswer.value) return false
return true
})
}, [definition, answers])
const visibleQuestions = getVisibleQuestions()
const currentQuestion = visibleQuestions[currentIdx]
const totalVisible = visibleQuestions.length
const highRiskQuestions = visibleQuestions.filter(q => q.axis === 'high_risk')
const gpaiQuestions = visibleQuestions.filter(q => q.axis === 'gpai')
const handleAnswer = (value: boolean) => {
if (!currentQuestion) return
setAnswers(prev => ({
...prev,
[currentQuestion.id]: {
question_id: currentQuestion.id,
value,
},
}))
// Auto-advance
if (currentIdx < totalVisible - 1) {
setCurrentIdx(prev => prev + 1)
}
}
const handleBack = () => {
if (currentIdx > 0) {
setCurrentIdx(prev => prev - 1)
}
}
const handleSubmit = async () => {
setSaving(true)
setError(null)
try {
const res = await fetch('/api/sdk/v1/ucca/decision-tree/evaluate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
system_name: systemName,
system_description: systemDescription,
answers,
}),
})
if (res.ok) {
const data = await res.json()
setResult(data)
setPhase('result')
} else {
const err = await res.json().catch(() => ({ error: 'Auswertung fehlgeschlagen' }))
setError(err.error || 'Auswertung fehlgeschlagen')
}
} catch {
setError('Verbindung zum Backend fehlgeschlagen')
} finally {
setSaving(false)
}
}
const handleReset = () => {
setAnswers({})
setCurrentIdx(0)
setSystemName('')
setSystemDescription('')
setResult(null)
setPhase('intro')
setError(null)
}
const allAnswered = visibleQuestions.every(q => answers[q.id] !== undefined)
if (loading) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-10 h-10 border-2 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-500">Entscheidungsbaum wird geladen...</p>
</div>
)
}
if (error && !definition) {
return (
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
<p className="text-red-700">{error}</p>
<p className="text-red-500 text-sm mt-2">Bitte stellen Sie sicher, dass der AI Compliance SDK Service läuft.</p>
</div>
)
}
// =========================================================================
// INTRO PHASE
// =========================================================================
if (phase === 'intro') {
return (
<div className="space-y-6">
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-2">AI Act Entscheidungsbaum</h3>
<p className="text-sm text-gray-500 mb-6">
Klassifizieren Sie Ihr KI-System anhand von 12 Fragen auf zwei Achsen:
<strong> High-Risk</strong> (Anhang III) und <strong>GPAI</strong> (Art. 5156).
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div className="p-4 bg-orange-50 border border-orange-200 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<svg className="w-5 h-5 text-orange-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126z" />
</svg>
<span className="font-medium text-orange-700">Achse 1: High-Risk</span>
</div>
<p className="text-sm text-orange-600">7 Fragen zu Anhang III Kategorien (Biometrie, kritische Infrastruktur, Bildung, Beschäftigung, etc.)</p>
</div>
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<svg className="w-5 h-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
</svg>
<span className="font-medium text-blue-700">Achse 2: GPAI</span>
</div>
<p className="text-sm text-blue-600">5 Fragen zu General-Purpose AI (Foundation Models, systemisches Risiko, Art. 5156)</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name des KI-Systems *</label>
<input
type="text"
value={systemName}
onChange={e => setSystemName(e.target.value)}
placeholder="z.B. Dokumenten-Analyse-KI, Chatbot-Service, Code-Assistent"
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 (optional)</label>
<textarea
value={systemDescription}
onChange={e => setSystemDescription(e.target.value)}
placeholder="Kurze Beschreibung des KI-Systems und seines Einsatzzwecks..."
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>
<div className="mt-6 flex justify-end">
<button
onClick={() => setPhase('questions')}
disabled={!systemName.trim()}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
systemName.trim()
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Klassifizierung starten
</button>
</div>
</div>
</div>
)
}
// =========================================================================
// RESULT PHASE
// =========================================================================
if (phase === 'result' && result) {
const riskConfig = RISK_LEVEL_CONFIG[result.high_risk_result] || RISK_LEVEL_CONFIG.not_applicable
const gpaiConfig = GPAI_CONFIG[result.gpai_result.gpai_category] || GPAI_CONFIG.none
return (
<div className="space-y-6">
{/* Header */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Klassifizierungsergebnis: {result.system_name}</h3>
<button
onClick={handleReset}
className="px-4 py-2 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
>
Neue Klassifizierung
</button>
</div>
{/* Two-Axis Result Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div className={`p-5 rounded-xl border-2 ${riskConfig.border} ${riskConfig.bg}`}>
<div className="text-sm font-medium text-gray-500 mb-1">Achse 1: High-Risk (Anhang III)</div>
<div className={`text-xl font-bold ${riskConfig.color}`}>{riskConfig.label}</div>
</div>
<div className={`p-5 rounded-xl border-2 ${gpaiConfig.border} ${gpaiConfig.bg}`}>
<div className="text-sm font-medium text-gray-500 mb-1">Achse 2: GPAI (Art. 5156)</div>
<div className={`text-xl font-bold ${gpaiConfig.color}`}>{gpaiConfig.label}</div>
{result.gpai_result.is_systemic_risk && (
<div className="mt-1 text-xs text-purple-600 font-medium">Systemisches Risiko</div>
)}
</div>
</div>
</div>
{/* Applicable Articles */}
{result.applicable_articles && result.applicable_articles.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="text-sm font-semibold text-gray-900 mb-3">Anwendbare Artikel</h4>
<div className="flex flex-wrap gap-2">
{result.applicable_articles.map(art => (
<span key={art} className="px-3 py-1 text-xs bg-indigo-50 text-indigo-700 rounded-full border border-indigo-200">
{art}
</span>
))}
</div>
</div>
)}
{/* Combined Obligations */}
{result.combined_obligations && result.combined_obligations.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="text-sm font-semibold text-gray-900 mb-3">
Pflichten ({result.combined_obligations.length})
</h4>
<div className="space-y-2">
{result.combined_obligations.map((obl, i) => (
<div key={i} className="flex items-start gap-2 text-sm">
<svg className="w-4 h-4 text-purple-500 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-gray-700">{obl}</span>
</div>
))}
</div>
</div>
)}
{/* GPAI-specific obligations */}
{result.gpai_result.is_gpai && result.gpai_result.obligations.length > 0 && (
<div className="bg-blue-50 rounded-xl border border-blue-200 p-6">
<h4 className="text-sm font-semibold text-blue-900 mb-3">
GPAI-spezifische Pflichten ({result.gpai_result.obligations.length})
</h4>
<div className="space-y-2">
{result.gpai_result.obligations.map((obl, i) => (
<div key={i} className="flex items-start gap-2 text-sm">
<svg className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
<span className="text-blue-800">{obl}</span>
</div>
))}
</div>
</div>
)}
{/* Answer Summary */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="text-sm font-semibold text-gray-900 mb-3">Ihre Antworten</h4>
<div className="space-y-2">
{definition?.questions.map(q => {
const answer = result.answers[q.id]
if (!answer) return null
return (
<div key={q.id} className="flex items-center gap-3 text-sm py-1.5 border-b border-gray-100 last:border-0">
<span className="text-xs font-mono text-gray-400 w-8">{q.id}</span>
<span className="flex-1 text-gray-600">{q.question}</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
answer.value ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
}`}>
{answer.value ? 'Ja' : 'Nein'}
</span>
</div>
)
})}
</div>
</div>
</div>
)
}
// =========================================================================
// QUESTIONS PHASE
// =========================================================================
return (
<div className="space-y-6">
{/* Progress */}
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-gray-700">
{systemName} Frage {currentIdx + 1} von {totalVisible}
</span>
<span className={`px-2 py-1 text-xs rounded-full font-medium ${
currentQuestion?.axis === 'high_risk'
? 'bg-orange-100 text-orange-700'
: 'bg-blue-100 text-blue-700'
}`}>
{currentQuestion?.axis === 'high_risk' ? 'High-Risk' : 'GPAI'}
</span>
</div>
{/* Dual progress bar */}
<div className="flex gap-2">
<div className="flex-1">
<div className="text-[10px] text-orange-600 mb-1 font-medium">
Achse 1: High-Risk ({highRiskQuestions.filter(q => answers[q.id] !== undefined).length}/{highRiskQuestions.length})
</div>
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-orange-500 rounded-full transition-all"
style={{ width: `${highRiskQuestions.length ? (highRiskQuestions.filter(q => answers[q.id] !== undefined).length / highRiskQuestions.length) * 100 : 0}%` }}
/>
</div>
</div>
<div className="flex-1">
<div className="text-[10px] text-blue-600 mb-1 font-medium">
Achse 2: GPAI ({gpaiQuestions.filter(q => answers[q.id] !== undefined).length}/{gpaiQuestions.length})
</div>
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all"
style={{ width: `${gpaiQuestions.length ? (gpaiQuestions.filter(q => answers[q.id] !== undefined).length / gpaiQuestions.length) * 100 : 0}%` }}
/>
</div>
</div>
</div>
</div>
{/* Error */}
{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>
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">&times;</button>
</div>
)}
{/* Current Question */}
{currentQuestion && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-start gap-3 mb-4">
<span className="px-2 py-1 text-xs font-mono bg-gray-100 text-gray-500 rounded">{currentQuestion.id}</span>
<span className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded">{currentQuestion.article_ref}</span>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">{currentQuestion.question}</h3>
<p className="text-sm text-gray-500 mb-6">{currentQuestion.description}</p>
{/* Answer buttons */}
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => handleAnswer(true)}
className={`p-4 rounded-xl border-2 transition-all text-center font-medium ${
answers[currentQuestion.id]?.value === true
? 'border-green-500 bg-green-50 text-green-700'
: 'border-gray-200 hover:border-green-300 hover:bg-green-50/50 text-gray-700'
}`}
>
<svg className="w-8 h-8 mx-auto mb-2 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Ja
</button>
<button
onClick={() => handleAnswer(false)}
className={`p-4 rounded-xl border-2 transition-all text-center font-medium ${
answers[currentQuestion.id]?.value === false
? 'border-gray-500 bg-gray-50 text-gray-700'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50/50 text-gray-700'
}`}
>
<svg className="w-8 h-8 mx-auto mb-2 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Nein
</button>
</div>
</div>
)}
{/* Navigation */}
<div className="flex items-center justify-between">
<button
onClick={currentIdx === 0 ? () => setPhase('intro') : handleBack}
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
Zurück
</button>
<div className="flex items-center gap-1">
{visibleQuestions.map((q, i) => (
<button
key={q.id}
onClick={() => setCurrentIdx(i)}
className={`w-2.5 h-2.5 rounded-full transition-colors ${
i === currentIdx
? q.axis === 'high_risk' ? 'bg-orange-500' : 'bg-blue-500'
: answers[q.id] !== undefined
? 'bg-green-400'
: 'bg-gray-200'
}`}
title={`${q.id}: ${q.question}`}
/>
))}
</div>
{allAnswered ? (
<button
onClick={handleSubmit}
disabled={saving}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
saving
? 'bg-purple-300 text-white cursor-wait'
: 'bg-purple-600 text-white hover:bg-purple-700'
}`}
>
{saving ? (
<span className="flex items-center gap-2">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Auswertung...
</span>
) : (
'Auswerten'
)}
</button>
) : (
<button
onClick={() => setCurrentIdx(prev => Math.min(prev + 1, totalVisible - 1))}
disabled={currentIdx >= totalVisible - 1}
className="px-4 py-2 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors disabled:opacity-30"
>
Weiter
</button>
)}
</div>
</div>
)
}

View File

@@ -1,272 +0,0 @@
'use client'
import React, { useCallback, useEffect, useRef } from 'react'
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Table from '@tiptap/extension-table'
import TableRow from '@tiptap/extension-table-row'
import TableHeader from '@tiptap/extension-table-header'
import TableCell from '@tiptap/extension-table-cell'
import Image from '@tiptap/extension-image'
interface TechFileEditorProps {
content: string
onSave: (content: string) => void
readOnly?: boolean
}
function normalizeContent(content: string): string {
if (!content) return '<p></p>'
const trimmed = content.trim()
// If it looks like JSON array or has no HTML tags, wrap in <p>
if (trimmed.startsWith('[') || !/<[a-z][\s\S]*>/i.test(trimmed)) {
return `<p>${trimmed.replace(/\n/g, '</p><p>')}</p>`
}
return trimmed
}
interface ToolbarButtonProps {
onClick: () => void
isActive?: boolean
disabled?: boolean
title: string
children: React.ReactNode
}
function ToolbarButton({ onClick, isActive, disabled, title, children }: ToolbarButtonProps) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={`p-1.5 rounded text-sm font-medium transition-colors ${
isActive
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300'
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'
} disabled:opacity-40 disabled:cursor-not-allowed`}
>
{children}
</button>
)
}
export function TechFileEditor({ content, onSave, readOnly = false }: TechFileEditorProps) {
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const onSaveRef = useRef(onSave)
onSaveRef.current = onSave
const debouncedSave = useCallback((html: string) => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
debounceTimer.current = setTimeout(() => {
onSaveRef.current(html)
}, 3000)
}, [])
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [2, 3, 4] },
}),
Table.configure({
resizable: true,
HTMLAttributes: { class: 'border-collapse border border-gray-300' },
}),
TableRow,
TableHeader,
TableCell,
Image.configure({
HTMLAttributes: { class: 'max-w-full rounded' },
}),
],
content: normalizeContent(content),
editable: !readOnly,
onUpdate: ({ editor: ed }: { editor: Editor }) => {
if (!readOnly) {
debouncedSave(ed.getHTML())
}
},
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none dark:prose-invert focus:outline-none min-h-[300px] px-4 py-3',
},
},
})
// Update content when parent prop changes
useEffect(() => {
if (editor && content) {
const normalized = normalizeContent(content)
const currentHTML = editor.getHTML()
if (normalized !== currentHTML) {
editor.commands.setContent(normalized)
}
}
}, [content, editor])
// Update editable state when readOnly changes
useEffect(() => {
if (editor) {
editor.setEditable(!readOnly)
}
}, [readOnly, editor])
// Cleanup debounce timer
useEffect(() => {
return () => {
if (debounceTimer.current) {
clearTimeout(debounceTimer.current)
}
}
}, [])
if (!editor) {
return (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 min-h-[300px] flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600" />
</div>
)
}
return (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{/* Toolbar */}
{!readOnly && (
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1.5 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
{/* Text formatting */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
title="Fett (Ctrl+B)"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
title="Kursiv (Ctrl+I)"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z" />
</svg>
</ToolbarButton>
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Headings */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
isActive={editor.isActive('heading', { level: 2 })}
title="Ueberschrift 2"
>
<span className="text-xs font-bold">H2</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
isActive={editor.isActive('heading', { level: 3 })}
title="Ueberschrift 3"
>
<span className="text-xs font-bold">H3</span>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 4 }).run()}
isActive={editor.isActive('heading', { level: 4 })}
title="Ueberschrift 4"
>
<span className="text-xs font-bold">H4</span>
</ToolbarButton>
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Lists */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
title="Aufzaehlung"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
title="Nummerierte Liste"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" />
</svg>
</ToolbarButton>
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Table */}
<ToolbarButton
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
title="Tabelle einfuegen (3x3)"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="3" y1="15" x2="21" y2="15" />
<line x1="9" y1="3" x2="9" y2="21" />
<line x1="15" y1="3" x2="15" y2="21" />
</svg>
</ToolbarButton>
{/* Blockquote */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')}
title="Zitat"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
</svg>
</ToolbarButton>
{/* Code Block */}
<ToolbarButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
isActive={editor.isActive('codeBlock')}
title="Code-Block"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="16,18 22,12 16,6" />
<polyline points="8,6 2,12 8,18" />
</svg>
</ToolbarButton>
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
{/* Undo / Redo */}
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Rueckgaengig (Ctrl+Z)"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z" />
</svg>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Wiederholen (Ctrl+Shift+Z)"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.4 10.6C16.55 8.99 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z" />
</svg>
</ToolbarButton>
</div>
)}
{/* Editor Content */}
<EditorContent editor={editor} />
</div>
)
}

View File

@@ -1,414 +0,0 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import type { Obligation, ObligationComplianceCheckResult } from '@/lib/sdk/obligations-compliance'
import {
buildObligationDocumentHtml,
createDefaultObligationDocumentOrgHeader,
type ObligationDocumentOrgHeader,
type ObligationDocumentRevision,
} from '@/lib/sdk/obligations-document'
// =============================================================================
// TYPES
// =============================================================================
interface ObligationDocumentTabProps {
obligations: Obligation[]
complianceResult: ObligationComplianceCheckResult | null
}
// =============================================================================
// COMPONENT
// =============================================================================
function ObligationDocumentTab({ obligations, complianceResult }: ObligationDocumentTabProps) {
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const [orgHeader, setOrgHeader] = useState<ObligationDocumentOrgHeader>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('bp_obligation_document_orgheader')
if (saved) {
try { return JSON.parse(saved) } catch { /* ignore */ }
}
}
return createDefaultObligationDocumentOrgHeader()
})
const [revisions, setRevisions] = useState<ObligationDocumentRevision[]>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('bp_obligation_document_revisions')
if (saved) {
try { return JSON.parse(saved) } catch { /* ignore */ }
}
}
return []
})
// ---------------------------------------------------------------------------
// localStorage persistence
// ---------------------------------------------------------------------------
useEffect(() => {
localStorage.setItem('bp_obligation_document_orgheader', JSON.stringify(orgHeader))
}, [orgHeader])
useEffect(() => {
localStorage.setItem('bp_obligation_document_revisions', JSON.stringify(revisions))
}, [revisions])
// ---------------------------------------------------------------------------
// Computed values
// ---------------------------------------------------------------------------
const obligationCount = obligations.length
const completedCount = useMemo(() => {
return obligations.filter(o => o.status === 'completed').length
}, [obligations])
const distinctSources = useMemo(() => {
const sources = new Set(obligations.map(o => o.source || 'Sonstig'))
return sources.size
}, [obligations])
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
const handlePrintDocument = useCallback(() => {
const html = buildObligationDocumentHtml(
obligations,
orgHeader,
complianceResult,
revisions,
)
const printWindow = window.open('', '_blank')
if (printWindow) {
printWindow.document.write(html)
printWindow.document.close()
setTimeout(() => printWindow.print(), 300)
}
}, [obligations, orgHeader, complianceResult, revisions])
const handleDownloadDocumentHtml = useCallback(() => {
const html = buildObligationDocumentHtml(
obligations,
orgHeader,
complianceResult,
revisions,
)
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `Pflichtenregister-${orgHeader.organizationName || 'Organisation'}-${new Date().toISOString().split('T')[0]}.html`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, [obligations, orgHeader, complianceResult, revisions])
const handleAddRevision = useCallback(() => {
setRevisions(prev => [...prev, {
version: String(prev.length + 1) + '.0',
date: new Date().toISOString().split('T')[0],
author: '',
changes: '',
}])
}, [])
const handleUpdateRevision = useCallback((index: number, field: keyof ObligationDocumentRevision, value: string) => {
setRevisions(prev => prev.map((r, i) => i === index ? { ...r, [field]: value } : r))
}, [])
const handleRemoveRevision = useCallback((index: number) => {
setRevisions(prev => prev.filter((_, i) => i !== index))
}, [])
const updateOrgHeader = useCallback((field: keyof ObligationDocumentOrgHeader, value: string) => {
setOrgHeader(prev => ({ ...prev, [field]: value }))
}, [])
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<div className="space-y-6">
{/* 1. Action Bar */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Pflichtenregister</h3>
<p className="text-sm text-gray-500 mt-1">
Auditfaehiges Dokument mit {obligationCount} Pflichten generieren
</p>
</div>
<div className="flex gap-3">
<button
onClick={handleDownloadDocumentHtml}
disabled={obligationCount === 0}
className="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
HTML herunterladen
</button>
<button
onClick={handlePrintDocument}
disabled={obligationCount === 0}
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 text-sm font-medium transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
Als PDF drucken
</button>
</div>
</div>
</div>
{/* 2. Org Header Form */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="text-base font-semibold text-gray-900 mb-4">Organisationsdaten</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Organisation</label>
<input
type="text"
value={orgHeader.organizationName}
onChange={e => updateOrgHeader('organizationName', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Branche</label>
<input
type="text"
value={orgHeader.industry}
onChange={e => updateOrgHeader('industry', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Datenschutzbeauftragter</label>
<input
type="text"
value={orgHeader.dpoName}
onChange={e => updateOrgHeader('dpoName', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">DSB-Kontakt</label>
<input
type="text"
value={orgHeader.dpoContact}
onChange={e => updateOrgHeader('dpoContact', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verantwortlicher</label>
<input
type="text"
value={orgHeader.responsiblePerson}
onChange={e => updateOrgHeader('responsiblePerson', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsabteilung</label>
<input
type="text"
value={orgHeader.legalDepartment}
onChange={e => updateOrgHeader('legalDepartment', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Dokumentversion</label>
<input
type="text"
value={orgHeader.documentVersion}
onChange={e => updateOrgHeader('documentVersion', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Pruefintervall</label>
<input
type="text"
value={orgHeader.reviewInterval}
onChange={e => updateOrgHeader('reviewInterval', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Letzte Pruefung</label>
<input
type="date"
value={orgHeader.lastReviewDate}
onChange={e => updateOrgHeader('lastReviewDate', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Naechste Pruefung</label>
<input
type="date"
value={orgHeader.nextReviewDate}
onChange={e => updateOrgHeader('nextReviewDate', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
</div>
</div>
{/* 3. Revisions Manager */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h4 className="text-base font-semibold text-gray-900">Aenderungshistorie</h4>
<button
onClick={handleAddRevision}
className="text-sm text-purple-600 hover:text-purple-700 font-medium"
>
+ Version hinzufuegen
</button>
</div>
{revisions.length > 0 ? (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-2 text-gray-600 font-medium">Version</th>
<th className="text-left py-2 text-gray-600 font-medium">Datum</th>
<th className="text-left py-2 text-gray-600 font-medium">Autor</th>
<th className="text-left py-2 text-gray-600 font-medium">Aenderungen</th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
{revisions.map((revision, index) => (
<tr key={index} className="border-b border-gray-100">
<td className="py-2 pr-2">
<input
type="text"
value={revision.version}
onChange={e => handleUpdateRevision(index, 'version', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</td>
<td className="py-2 pr-2">
<input
type="date"
value={revision.date}
onChange={e => handleUpdateRevision(index, 'date', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</td>
<td className="py-2 pr-2">
<input
type="text"
value={revision.author}
onChange={e => handleUpdateRevision(index, 'author', e.target.value)}
placeholder="Name"
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</td>
<td className="py-2 pr-2">
<input
type="text"
value={revision.changes}
onChange={e => handleUpdateRevision(index, 'changes', e.target.value)}
placeholder="Beschreibung der Aenderungen"
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</td>
<td className="py-2 text-right">
<button
onClick={() => handleRemoveRevision(index)}
className="text-sm text-red-600 hover:text-red-700 font-medium"
>
Entfernen
</button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="text-sm text-gray-500 italic">
Noch keine Revisionen. Die erste Version wird automatisch im Dokument eingetragen.
</p>
)}
</div>
{/* 4. Document Preview */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="text-base font-semibold text-gray-900 mb-4">Dokument-Vorschau</h4>
{obligationCount === 0 ? (
<div className="text-center py-8">
<p className="text-gray-500">Erfassen Sie Pflichten, um das Pflichtenregister zu generieren.</p>
</div>
) : (
<div className="space-y-4">
{/* Cover preview */}
<div className="bg-purple-50 rounded-lg p-4 text-center">
<p className="text-purple-700 font-semibold text-lg">Pflichtenregister</p>
<p className="text-purple-600 text-sm">
Regulatorische Pflichten {orgHeader.organizationName || 'Organisation'}
</p>
<p className="text-purple-500 text-xs mt-1">
Version {orgHeader.documentVersion} | Stand: {new Date().toLocaleDateString('de-DE')}
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-gray-900">{obligationCount}</p>
<p className="text-xs text-gray-500">Pflichten</p>
</div>
<div className="bg-green-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-green-700">{completedCount}</p>
<p className="text-xs text-gray-500">Abgeschlossen</p>
</div>
<div className="bg-purple-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-purple-700">{distinctSources}</p>
<p className="text-xs text-gray-500">Regulierungen</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-gray-900">12</p>
<p className="text-xs text-gray-500">Sektionen</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-gray-900">
{complianceResult ? complianceResult.score : '—'}
</p>
<p className="text-xs text-gray-500">Compliance-Score</p>
</div>
</div>
{/* 12 Sections list */}
<div>
<p className="text-sm font-medium text-gray-700 mb-2">12 Dokument-Sektionen:</p>
<ol className="list-decimal list-inside text-sm text-gray-600 space-y-1">
<li>Ziel und Zweck</li>
<li>Geltungsbereich</li>
<li>Methodik</li>
<li>Regulatorische Grundlagen</li>
<li>Pflichtenuebersicht</li>
<li>Detaillierte Pflichten</li>
<li>Verantwortlichkeiten</li>
<li>Fristen und Termine</li>
<li>Nachweisverzeichnis</li>
<li>Compliance-Status</li>
<li>Aenderungshistorie</li>
</ol>
</div>
</div>
)}
</div>
</div>
)
}
export { ObligationDocumentTab }

View File

@@ -1,449 +0,0 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import type { TOMGeneratorState } from '@/lib/sdk/tom-generator/types'
import type { TOMComplianceCheckResult } from '@/lib/sdk/tom-compliance'
import {
buildTOMDocumentHtml,
createDefaultTOMDocumentOrgHeader,
type TOMDocumentOrgHeader,
type TOMDocumentRevision,
} from '@/lib/sdk/tom-document'
// =============================================================================
// TYPES
// =============================================================================
interface TOMDocumentTabProps {
state: TOMGeneratorState
complianceResult: TOMComplianceCheckResult | null
}
// =============================================================================
// COMPONENT
// =============================================================================
function TOMDocumentTab({ state, complianceResult }: TOMDocumentTabProps) {
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const [orgHeader, setOrgHeader] = useState<TOMDocumentOrgHeader>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('bp_tom_document_orgheader')
if (saved) {
try { return JSON.parse(saved) } catch { /* ignore */ }
}
}
return createDefaultTOMDocumentOrgHeader()
})
const [revisions, setRevisions] = useState<TOMDocumentRevision[]>(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('bp_tom_document_revisions')
if (saved) {
try { return JSON.parse(saved) } catch { /* ignore */ }
}
}
return []
})
// ---------------------------------------------------------------------------
// localStorage persistence
// ---------------------------------------------------------------------------
useEffect(() => {
localStorage.setItem('bp_tom_document_orgheader', JSON.stringify(orgHeader))
}, [orgHeader])
useEffect(() => {
localStorage.setItem('bp_tom_document_revisions', JSON.stringify(revisions))
}, [revisions])
// ---------------------------------------------------------------------------
// Computed values
// ---------------------------------------------------------------------------
const tomCount = useMemo(() => {
if (!state?.derivedTOMs) return 0
return Array.isArray(state.derivedTOMs) ? state.derivedTOMs.length : 0
}, [state?.derivedTOMs])
const applicableTOMs = useMemo(() => {
if (!state?.derivedTOMs || !Array.isArray(state.derivedTOMs)) return []
return state.derivedTOMs.filter(t => t.applicability !== 'NOT_APPLICABLE')
}, [state?.derivedTOMs])
const implementedCount = useMemo(() => {
return applicableTOMs.filter(t => t.implementationStatus === 'IMPLEMENTED').length
}, [applicableTOMs])
const [canonicalCount, setCanonicalCount] = useState(0)
useEffect(() => {
if (tomCount === 0) return
fetch('/api/sdk/v1/compliance/tom-mappings/stats')
.then(r => r.ok ? r.json() : null)
.then(data => { if (data?.sync_state?.canonical_controls_matched) setCanonicalCount(data.sync_state.canonical_controls_matched) })
.catch(() => {})
}, [tomCount])
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
const handlePrintTOMDocument = useCallback(() => {
const html = buildTOMDocumentHtml(
state?.derivedTOMs || [],
orgHeader,
state?.companyProfile || null,
state?.riskProfile || null,
complianceResult,
revisions,
)
const printWindow = window.open('', '_blank')
if (printWindow) {
printWindow.document.write(html)
printWindow.document.close()
setTimeout(() => printWindow.print(), 300)
}
}, [state, orgHeader, complianceResult, revisions])
const handleDownloadTOMDocumentHtml = useCallback(() => {
const html = buildTOMDocumentHtml(
state?.derivedTOMs || [],
orgHeader,
state?.companyProfile || null,
state?.riskProfile || null,
complianceResult,
revisions,
)
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `TOM-Dokumentation-${orgHeader.organizationName || 'Organisation'}-${new Date().toISOString().split('T')[0]}.html`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, [state, orgHeader, complianceResult, revisions])
const handleAddRevision = useCallback(() => {
setRevisions(prev => [...prev, {
version: String(prev.length + 1) + '.0',
date: new Date().toISOString().split('T')[0],
author: '',
changes: '',
}])
}, [])
const handleUpdateRevision = useCallback((index: number, field: keyof TOMDocumentRevision, value: string) => {
setRevisions(prev => prev.map((r, i) => i === index ? { ...r, [field]: value } : r))
}, [])
const handleRemoveRevision = useCallback((index: number) => {
setRevisions(prev => prev.filter((_, i) => i !== index))
}, [])
const updateOrgHeader = useCallback((field: keyof TOMDocumentOrgHeader, value: string | string[]) => {
setOrgHeader(prev => ({ ...prev, [field]: value }))
}, [])
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<div className="space-y-6">
{/* 1. Action Bar */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">TOM-Dokument (Art. 32 DSGVO)</h3>
<p className="text-sm text-gray-500 mt-1">
Auditfaehiges Dokument mit {applicableTOMs.length} Massnahmen generieren
</p>
</div>
<div className="flex gap-3">
<button
onClick={handleDownloadTOMDocumentHtml}
disabled={tomCount === 0}
className="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
HTML herunterladen
</button>
<button
onClick={handlePrintTOMDocument}
disabled={tomCount === 0}
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 text-sm font-medium transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
Als PDF drucken
</button>
</div>
</div>
</div>
{/* 2. Org Header Form */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="text-base font-semibold text-gray-900 mb-4">Organisationsdaten</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Organisation</label>
<input
type="text"
value={orgHeader.organizationName}
onChange={e => updateOrgHeader('organizationName', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Branche</label>
<input
type="text"
value={orgHeader.industry}
onChange={e => updateOrgHeader('industry', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Datenschutzbeauftragter</label>
<input
type="text"
value={orgHeader.dpoName}
onChange={e => updateOrgHeader('dpoName', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">DSB-Kontakt</label>
<input
type="text"
value={orgHeader.dpoContact}
onChange={e => updateOrgHeader('dpoContact', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verantwortlicher</label>
<input
type="text"
value={orgHeader.responsiblePerson}
onChange={e => updateOrgHeader('responsiblePerson', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">IT-Sicherheitskontakt</label>
<input
type="text"
value={orgHeader.itSecurityContact}
onChange={e => updateOrgHeader('itSecurityContact', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Mitarbeiteranzahl</label>
<input
type="text"
value={orgHeader.employeeCount}
onChange={e => updateOrgHeader('employeeCount', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Standorte</label>
<input
type="text"
value={orgHeader.locations.join(', ')}
onChange={e => updateOrgHeader('locations', e.target.value.split(',').map(s => s.trim()))}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Dokumentversion</label>
<input
type="text"
value={orgHeader.documentVersion}
onChange={e => updateOrgHeader('documentVersion', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Pruefintervall</label>
<input
type="text"
value={orgHeader.reviewInterval}
onChange={e => updateOrgHeader('reviewInterval', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Letzte Pruefung</label>
<input
type="date"
value={orgHeader.lastReviewDate}
onChange={e => updateOrgHeader('lastReviewDate', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Naechste Pruefung</label>
<input
type="date"
value={orgHeader.nextReviewDate}
onChange={e => updateOrgHeader('nextReviewDate', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</div>
</div>
</div>
{/* 3. Revisions Manager */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h4 className="text-base font-semibold text-gray-900">Aenderungshistorie</h4>
<button
onClick={handleAddRevision}
className="text-sm text-purple-600 hover:text-purple-700 font-medium"
>
+ Version hinzufuegen
</button>
</div>
{revisions.length > 0 ? (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-2 text-gray-600 font-medium">Version</th>
<th className="text-left py-2 text-gray-600 font-medium">Datum</th>
<th className="text-left py-2 text-gray-600 font-medium">Autor</th>
<th className="text-left py-2 text-gray-600 font-medium">Aenderungen</th>
<th className="py-2"></th>
</tr>
</thead>
<tbody>
{revisions.map((revision, index) => (
<tr key={index} className="border-b border-gray-100">
<td className="py-2 pr-2">
<input
type="text"
value={revision.version}
onChange={e => handleUpdateRevision(index, 'version', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</td>
<td className="py-2 pr-2">
<input
type="date"
value={revision.date}
onChange={e => handleUpdateRevision(index, 'date', e.target.value)}
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</td>
<td className="py-2 pr-2">
<input
type="text"
value={revision.author}
onChange={e => handleUpdateRevision(index, 'author', e.target.value)}
placeholder="Name"
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</td>
<td className="py-2 pr-2">
<input
type="text"
value={revision.changes}
onChange={e => handleUpdateRevision(index, 'changes', e.target.value)}
placeholder="Beschreibung der Aenderungen"
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
/>
</td>
<td className="py-2 text-right">
<button
onClick={() => handleRemoveRevision(index)}
className="text-sm text-red-600 hover:text-red-700 font-medium"
>
Entfernen
</button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="text-sm text-gray-500 italic">
Noch keine Revisionen. Die erste Version wird automatisch im Dokument eingetragen.
</p>
)}
</div>
{/* 4. Document Preview */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="text-base font-semibold text-gray-900 mb-4">Dokument-Vorschau</h4>
{tomCount === 0 ? (
<div className="text-center py-8">
<p className="text-gray-500">Starten Sie den TOM-Generator, um Massnahmen abzuleiten.</p>
</div>
) : (
<div className="space-y-4">
{/* Cover preview */}
<div className="bg-purple-50 rounded-lg p-4 text-center">
<p className="text-purple-700 font-semibold text-lg">TOM-Dokumentation</p>
<p className="text-purple-600 text-sm">
Art. 32 DSGVO {orgHeader.organizationName || 'Organisation'}
</p>
<p className="text-purple-500 text-xs mt-1">
Version {orgHeader.documentVersion} | Stand: {new Date().toLocaleDateString('de-DE')}
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-gray-900">{applicableTOMs.length}</p>
<p className="text-xs text-gray-500">Massnahmen</p>
</div>
<div className="bg-green-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-green-700">{implementedCount}</p>
<p className="text-xs text-gray-500">Umgesetzt</p>
</div>
<div className="bg-purple-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-purple-700">{canonicalCount || '-'}</p>
<p className="text-xs text-gray-500">Belegende Controls</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-gray-900">12</p>
<p className="text-xs text-gray-500">Sektionen</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-gray-900">
{complianceResult ? complianceResult.score : '-'}
</p>
<p className="text-xs text-gray-500">Compliance-Score</p>
</div>
</div>
{/* 12 Sections list */}
<div>
<p className="text-sm font-medium text-gray-700 mb-2">12 Dokument-Sektionen:</p>
<ol className="list-decimal list-inside text-sm text-gray-600 space-y-1">
<li>Ziel und Zweck</li>
<li>Geltungsbereich</li>
<li>Grundprinzipien Art. 32</li>
<li>Schutzbedarf und Risikoanalyse</li>
<li>Massnahmen-Uebersicht</li>
<li>Detaillierte Massnahmen</li>
<li>SDM Gewaehrleistungsziele</li>
<li>Verantwortlichkeiten</li>
<li>Pruef- und Revisionszyklus</li>
<li>Compliance-Status</li>
<li>Aenderungshistorie</li>
</ol>
</div>
</div>
)}
</div>
</div>
)
}
export { TOMDocumentTab }

View File

@@ -1,18 +1,9 @@
'use client'
import { useMemo, useState, useEffect, useCallback } from 'react'
import { useMemo, useState, useEffect } from 'react'
import { DerivedTOM, TOMGeneratorState } from '@/lib/sdk/tom-generator/types'
import { getControlById } from '@/lib/sdk/tom-generator/controls/loader'
interface CanonicalMapping {
id: string
canonical_control_code: string
canonical_title: string | null
canonical_severity: string | null
canonical_objective: string | null
mapping_type: string
}
interface TOMEditorTabProps {
state: TOMGeneratorState
selectedTOMId: string | null
@@ -55,17 +46,6 @@ export function TOMEditorTab({ state, selectedTOMId, onUpdateTOM, onBack }: TOME
const [notes, setNotes] = useState('')
const [linkedEvidence, setLinkedEvidence] = useState<string[]>([])
const [selectedEvidenceId, setSelectedEvidenceId] = useState('')
const [canonicalMappings, setCanonicalMappings] = useState<CanonicalMapping[]>([])
const [showCanonical, setShowCanonical] = useState(false)
// Load canonical controls for this TOM's category
useEffect(() => {
if (!control?.category) { setCanonicalMappings([]); return }
fetch(`/api/sdk/v1/compliance/tom-mappings/by-tom/${encodeURIComponent(control.category)}`)
.then(r => r.ok ? r.json() : null)
.then(data => { if (data?.mappings) setCanonicalMappings(data.mappings) })
.catch(() => setCanonicalMappings([]))
}, [control?.category])
useEffect(() => {
if (tom) {
@@ -361,62 +341,6 @@ export function TOMEditorTab({ state, selectedTOMId, onUpdateTOM, onBack }: TOME
</div>
)}
{/* Canonical Controls (Belegende Security-Controls) */}
{canonicalMappings.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold text-gray-700">
Belegende Security-Controls ({canonicalMappings.length})
</h3>
<button
onClick={() => setShowCanonical(!showCanonical)}
className="text-xs text-purple-600 hover:text-purple-700 font-medium"
>
{showCanonical ? 'Einklappen' : 'Alle anzeigen'}
</button>
</div>
<div className="space-y-2">
{(showCanonical ? canonicalMappings : canonicalMappings.slice(0, 5)).map(m => (
<div key={m.id} className="flex items-start gap-3 bg-gray-50 rounded-lg px-3 py-2">
<div className="flex-shrink-0">
<span className="text-xs font-mono bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded">
{m.canonical_control_code}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-700 font-medium truncate">{m.canonical_title || m.canonical_control_code}</p>
{m.canonical_objective && showCanonical && (
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{m.canonical_objective}</p>
)}
</div>
<div className="flex-shrink-0 flex items-center gap-1.5">
{m.canonical_severity && (
<span className={`text-xs px-1.5 py-0.5 rounded-full font-medium ${
m.canonical_severity === 'critical' ? 'bg-red-100 text-red-700' :
m.canonical_severity === 'high' ? 'bg-orange-100 text-orange-700' :
m.canonical_severity === 'medium' ? 'bg-yellow-100 text-yellow-700' :
'bg-gray-100 text-gray-600'
}`}>
{m.canonical_severity}
</span>
)}
<span className={`text-xs px-1.5 py-0.5 rounded-full ${
m.mapping_type === 'manual' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-500'
}`}>
{m.mapping_type === 'manual' ? 'manuell' : 'auto'}
</span>
</div>
</div>
))}
</div>
{!showCanonical && canonicalMappings.length > 5 && (
<p className="text-xs text-gray-400 mt-2">
+ {canonicalMappings.length - 5} weitere Controls
</p>
)}
</div>
)}
{/* Framework Mappings */}
{control?.mappings && control.mappings.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">

View File

@@ -1,6 +1,6 @@
'use client'
import { useMemo, useState, useEffect, useCallback } from 'react'
import { useMemo, useState } from 'react'
import { DerivedTOM, TOMGeneratorState } from '@/lib/sdk/tom-generator/types'
import { getControlById, getControlsByCategory, getAllCategories } from '@/lib/sdk/tom-generator/controls/loader'
import { SDM_GOAL_LABELS, getSDMCoverageStats, SDMGewaehrleistungsziel } from '@/lib/sdk/tom-generator/sdm-mapping'
@@ -11,18 +11,6 @@ interface TOMOverviewTabProps {
onStartGenerator: () => void
}
interface MappingStats {
sync_state: {
profile_hash: string | null
total_mappings: number
canonical_controls_matched: number
tom_controls_covered: number
last_synced_at: string | null
}
category_breakdown: { tom_category: string; total_mappings: number; unique_controls: number }[]
total_canonical_controls_available: number
}
const STATUS_BADGES: Record<string, { label: string; className: string }> = {
IMPLEMENTED: { label: 'Implementiert', className: 'bg-green-100 text-green-700' },
PARTIAL: { label: 'Teilweise', className: 'bg-yellow-100 text-yellow-700' },
@@ -46,41 +34,9 @@ export function TOMOverviewTab({ state, onSelectTOM, onStartGenerator }: TOMOver
const [typeFilter, setTypeFilter] = useState<string>('ALL')
const [statusFilter, setStatusFilter] = useState<string>('ALL')
const [applicabilityFilter, setApplicabilityFilter] = useState<string>('ALL')
const [mappingStats, setMappingStats] = useState<MappingStats | null>(null)
const [syncing, setSyncing] = useState(false)
const categories = useMemo(() => getAllCategories(), [])
// Load mapping stats
useEffect(() => {
if (state.derivedTOMs.length === 0) return
fetch('/api/sdk/v1/compliance/tom-mappings/stats')
.then(r => r.ok ? r.json() : null)
.then(data => { if (data) setMappingStats(data) })
.catch(() => {})
}, [state.derivedTOMs.length])
const handleSyncControls = useCallback(async () => {
setSyncing(true)
try {
const resp = await fetch('/api/sdk/v1/compliance/tom-mappings/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
industry: state.companyProfile?.industry || null,
company_size: state.companyProfile?.size || null,
force: false,
}),
})
if (resp.ok) {
// Reload stats after sync
const statsResp = await fetch('/api/sdk/v1/compliance/tom-mappings/stats')
if (statsResp.ok) setMappingStats(await statsResp.json())
}
} catch { /* ignore */ }
setSyncing(false)
}, [state.companyProfile])
const stats = useMemo(() => {
const toms = state.derivedTOMs
return {
@@ -203,59 +159,6 @@ export function TOMOverviewTab({ state, onSelectTOM, onStartGenerator }: TOMOver
</div>
</div>
{/* Canonical Control Library Coverage */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-semibold text-gray-700">Canonical Control Library</h3>
<p className="text-xs text-gray-500 mt-0.5">
Belegende Security-Controls aus OWASP, NIST, ENISA
</p>
</div>
<button
onClick={handleSyncControls}
disabled={syncing}
className="bg-purple-600 text-white hover:bg-purple-700 disabled:bg-gray-300 rounded-lg px-4 py-2 text-xs font-medium transition-colors"
>
{syncing ? 'Synchronisiere...' : 'Controls synchronisieren'}
</button>
</div>
{mappingStats ? (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div className="bg-gray-50 rounded-lg p-3 text-center">
<div className="text-xl font-bold text-gray-900">{mappingStats.sync_state.total_mappings}</div>
<div className="text-xs text-gray-500">Zugeordnete Controls</div>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<div className="text-xl font-bold text-purple-600">{mappingStats.sync_state.canonical_controls_matched}</div>
<div className="text-xs text-gray-500">Einzigartige Controls</div>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<div className="text-xl font-bold text-gray-900">{mappingStats.sync_state.tom_controls_covered}/13</div>
<div className="text-xs text-gray-500">Kategorien abgedeckt</div>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<div className="text-xl font-bold text-gray-900">{mappingStats.total_canonical_controls_available}</div>
<div className="text-xs text-gray-500">Verfuegbare Controls</div>
</div>
</div>
) : (
<div className="text-center py-4">
<p className="text-sm text-gray-400">
Noch keine Controls synchronisiert. Klicken Sie &quot;Controls synchronisieren&quot;, um relevante
Security-Controls aus der Canonical Control Library zuzuordnen.
</p>
</div>
)}
{mappingStats?.sync_state?.last_synced_at && (
<p className="text-xs text-gray-400 mt-2">
Letzte Synchronisation: {new Date(mappingStats.sync_state.last_synced_at).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
})}
</p>
)}
</div>
{/* Filter Controls */}
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">

View File

@@ -1,4 +1,3 @@
export { TOMOverviewTab } from './TOMOverviewTab'
export { TOMEditorTab } from './TOMEditorTab'
export { TOMGapExportTab } from './TOMGapExportTab'
export { TOMDocumentTab } from './TOMDocumentTab'

View File

@@ -10,8 +10,6 @@ interface AssessmentResult {
dsfa_recommended: boolean
art22_risk: boolean
training_allowed: string
betrvg_conflict_score?: number
betrvg_consultation_required?: boolean
summary: string
recommendation: string
alternative_approach?: string
@@ -78,21 +76,6 @@ export function AssessmentResultCard({ result }: AssessmentResultCardProps) {
Art. 22 Risiko
</span>
)}
{result.betrvg_conflict_score != null && result.betrvg_conflict_score > 0 && (
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
result.betrvg_conflict_score >= 75 ? 'bg-red-100 text-red-700' :
result.betrvg_conflict_score >= 50 ? 'bg-orange-100 text-orange-700' :
result.betrvg_conflict_score >= 25 ? 'bg-yellow-100 text-yellow-700' :
'bg-green-100 text-green-700'
}`}>
BR-Konflikt: {result.betrvg_conflict_score}/100
</span>
)}
{result.betrvg_consultation_required && (
<span className="px-3 py-1 rounded-full text-sm bg-purple-100 text-purple-700">
BR-Konsultation erforderlich
</span>
)}
</div>
<p className="text-gray-700">{result.summary}</p>
<p className="text-sm text-gray-500 mt-2">{result.recommendation}</p>

View File

@@ -1,321 +0,0 @@
'use client'
import { useRef, useState, useEffect, useCallback } from 'react'
import type {
InteractiveVideoManifest,
CheckpointEntry,
CheckpointQuizResult,
} from '@/lib/sdk/training/types'
import { submitCheckpointQuiz } from '@/lib/sdk/training/api'
interface Props {
manifest: InteractiveVideoManifest
assignmentId: string
onAllCheckpointsPassed?: () => void
}
export default function InteractiveVideoPlayer({ manifest, assignmentId, onAllCheckpointsPassed }: Props) {
const videoRef = useRef<HTMLVideoElement>(null)
const [currentCheckpoint, setCurrentCheckpoint] = useState<CheckpointEntry | null>(null)
const [showOverlay, setShowOverlay] = useState(false)
const [answers, setAnswers] = useState<Record<number, number>>({})
const [quizResult, setQuizResult] = useState<CheckpointQuizResult | null>(null)
const [submitting, setSubmitting] = useState(false)
const [passedCheckpoints, setPassedCheckpoints] = useState<Set<string>>(new Set())
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
// Initialize passed checkpoints from manifest progress
useEffect(() => {
const passed = new Set<string>()
for (const cp of manifest.checkpoints) {
if (cp.progress?.passed) {
passed.add(cp.checkpoint_id)
}
}
setPassedCheckpoints(passed)
}, [manifest])
// Find next unpassed checkpoint
const getNextUnpassedCheckpoint = useCallback((): CheckpointEntry | null => {
for (const cp of manifest.checkpoints) {
if (!passedCheckpoints.has(cp.checkpoint_id)) {
return cp
}
}
return null
}, [manifest.checkpoints, passedCheckpoints])
// Time update handler — check for checkpoint triggers
const handleTimeUpdate = useCallback(() => {
if (!videoRef.current || showOverlay) return
const time = videoRef.current.currentTime
setCurrentTime(time)
for (const cp of manifest.checkpoints) {
if (passedCheckpoints.has(cp.checkpoint_id)) continue
// Trigger checkpoint when video reaches its timestamp (within 0.5s)
if (time >= cp.timestamp_seconds && time < cp.timestamp_seconds + 1.0) {
videoRef.current.pause()
setCurrentCheckpoint(cp)
setShowOverlay(true)
setAnswers({})
setQuizResult(null)
break
}
}
}, [manifest.checkpoints, passedCheckpoints, showOverlay])
// Seek protection — prevent skipping past unpassed checkpoints
const handleSeeking = useCallback(() => {
if (!videoRef.current) return
const seekTarget = videoRef.current.currentTime
const nextUnpassed = getNextUnpassedCheckpoint()
if (nextUnpassed && seekTarget > nextUnpassed.timestamp_seconds) {
videoRef.current.currentTime = nextUnpassed.timestamp_seconds - 0.5
}
}, [getNextUnpassedCheckpoint])
// Submit checkpoint quiz
async function handleSubmitQuiz() {
if (!currentCheckpoint) return
setSubmitting(true)
try {
const answerList = currentCheckpoint.questions.map((_, i) => answers[i] ?? -1)
const result = await submitCheckpointQuiz(
currentCheckpoint.checkpoint_id,
assignmentId,
answerList,
)
setQuizResult(result)
if (result.passed) {
setPassedCheckpoints(prev => {
const next = new Set(prev)
next.add(currentCheckpoint.checkpoint_id)
return next
})
}
} catch (e) {
console.error('Checkpoint quiz submission failed:', e)
} finally {
setSubmitting(false)
}
}
// Continue video after passing checkpoint
function handleContinue() {
setShowOverlay(false)
setCurrentCheckpoint(null)
setQuizResult(null)
setAnswers({})
if (videoRef.current) {
videoRef.current.play()
}
// Check if all checkpoints passed
const allPassed = manifest.checkpoints.every(cp => passedCheckpoints.has(cp.checkpoint_id))
if (allPassed && onAllCheckpointsPassed) {
onAllCheckpointsPassed()
}
}
// Retry quiz
function handleRetry() {
setQuizResult(null)
setAnswers({})
}
// Resume to last unpassed checkpoint
useEffect(() => {
if (!videoRef.current || !manifest.checkpoints.length) return
const nextUnpassed = getNextUnpassedCheckpoint()
if (nextUnpassed && nextUnpassed.timestamp_seconds > 0) {
// Start a bit before the checkpoint
const startTime = Math.max(0, nextUnpassed.timestamp_seconds - 2)
videoRef.current.currentTime = startTime
}
}, []) // Only on mount
const handleLoadedMetadata = useCallback(() => {
if (videoRef.current) {
setDuration(videoRef.current.duration)
}
}, [])
// Progress bar percentage
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0
return (
<div className="relative bg-black rounded-lg overflow-hidden">
{/* Video element */}
<video
ref={videoRef}
className="w-full"
src={manifest.stream_url}
onTimeUpdate={handleTimeUpdate}
onSeeking={handleSeeking}
onLoadedMetadata={handleLoadedMetadata}
controls={!showOverlay}
/>
{/* Custom progress bar with checkpoint markers */}
<div className="relative h-2 bg-gray-700">
{/* Progress fill */}
<div
className="h-full bg-indigo-500 transition-all"
style={{ width: `${progressPercent}%` }}
/>
{/* Checkpoint markers */}
{manifest.checkpoints.map(cp => {
const pos = duration > 0 ? (cp.timestamp_seconds / duration) * 100 : 0
const isPassed = passedCheckpoints.has(cp.checkpoint_id)
return (
<div
key={cp.checkpoint_id}
className={`absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full border-2 border-white ${
isPassed ? 'bg-green-500' : 'bg-red-500'
}`}
style={{ left: `${pos}%` }}
title={`${cp.title} (${isPassed ? 'Bestanden' : 'Ausstehend'})`}
/>
)
})}
</div>
{/* Checkpoint overlay */}
{showOverlay && currentCheckpoint && (
<div className="absolute inset-0 bg-black/80 flex items-center justify-center p-6 overflow-y-auto">
<div className="bg-white rounded-xl p-6 max-w-2xl w-full max-h-[90%] overflow-y-auto">
{/* Header */}
<div className="flex items-center gap-3 mb-4 pb-3 border-b border-gray-200">
<div className="w-8 h-8 bg-red-100 rounded-full flex items-center justify-center">
<span className="text-red-600 font-bold text-sm">
{currentCheckpoint.index + 1}
</span>
</div>
<div>
<h3 className="font-semibold text-gray-900">Checkpoint: {currentCheckpoint.title}</h3>
<p className="text-xs text-gray-500">
Beantworten Sie die Fragen, um fortzufahren
</p>
</div>
</div>
{quizResult ? (
/* Quiz result */
<div>
<div className={`text-center p-6 rounded-lg mb-4 ${
quizResult.passed ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'
}`}>
<div className="text-3xl mb-2">{quizResult.passed ? '\u2705' : '\u274C'}</div>
<h4 className="text-lg font-bold mb-1">
{quizResult.passed ? 'Checkpoint bestanden!' : 'Nicht bestanden'}
</h4>
<p className="text-sm text-gray-600">
Ergebnis: {Math.round(quizResult.score)}%
</p>
</div>
{/* Feedback */}
<div className="space-y-3 mb-4">
{quizResult.feedback.map((fb, i) => (
<div key={i} className={`p-3 rounded-lg text-sm ${
fb.correct ? 'bg-green-50 border-l-4 border-green-400' : 'bg-red-50 border-l-4 border-red-400'
}`}>
<p className="font-medium">{fb.question}</p>
{!fb.correct && (
<p className="text-gray-600 mt-1">{fb.explanation}</p>
)}
</div>
))}
</div>
{quizResult.passed ? (
<button
onClick={handleContinue}
className="w-full px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
Video fortsetzen
</button>
) : (
<button
onClick={handleRetry}
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
>
Erneut versuchen
</button>
)}
</div>
) : (
/* Quiz questions */
<div>
<div className="space-y-4">
{currentCheckpoint.questions.map((q, qIdx) => (
<div key={qIdx} className="bg-gray-50 rounded-lg p-4">
<p className="font-medium text-gray-900 mb-3 text-sm">
<span className="text-indigo-600 mr-1">Frage {qIdx + 1}.</span>
{q.question}
</p>
<div className="space-y-2">
{q.options.map((opt, oIdx) => (
<label
key={oIdx}
className={`flex items-center gap-3 p-2.5 rounded-lg border cursor-pointer transition-colors text-sm ${
answers[qIdx] === oIdx
? 'border-indigo-500 bg-indigo-50'
: 'border-gray-200 hover:bg-white'
}`}
>
<input
type="radio"
name={`checkpoint-q-${qIdx}`}
checked={answers[qIdx] === oIdx}
onChange={() => setAnswers(prev => ({ ...prev, [qIdx]: oIdx }))}
className="text-indigo-600"
/>
<span className="text-gray-700">{opt}</span>
</label>
))}
</div>
</div>
))}
</div>
<button
onClick={handleSubmitQuiz}
disabled={submitting || Object.keys(answers).length < currentCheckpoint.questions.length}
className="w-full mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
>
{submitting ? 'Wird ausgewertet...' : `Antworten absenden (${Object.keys(answers).length}/${currentCheckpoint.questions.length})`}
</button>
</div>
)}
</div>
</div>
)}
{/* Checkpoint status bar */}
<div className="bg-gray-800 px-4 py-2 flex items-center gap-2 text-xs text-gray-300">
<span>Checkpoints:</span>
{manifest.checkpoints.map(cp => (
<span
key={cp.checkpoint_id}
className={`px-2 py-0.5 rounded-full ${
passedCheckpoints.has(cp.checkpoint_id)
? 'bg-green-700 text-green-100'
: 'bg-gray-600 text-gray-300'
}`}
>
{cp.title}
</span>
))}
{manifest.checkpoints.length > 0 && (
<span className="ml-auto">
{passedCheckpoints.size}/{manifest.checkpoints.length} bestanden
</span>
)}
</div>
</div>
)
}

View File

@@ -1,510 +0,0 @@
/**
* Integration Tests: Company Profile → Compliance Scope → VVT Generator
*
* Tests the complete data pipeline from Company Profile master data
* through the Compliance Scope Engine to VVT activity generation.
*/
import { describe, it, expect } from 'vitest'
import {
prefillFromCompanyProfile,
exportToVVTAnswers,
getAutoFilledScoringAnswers,
SCOPE_QUESTION_BLOCKS,
} from '../compliance-scope-profiling'
import {
generateActivities,
PROFILING_QUESTIONS,
DEPARTMENT_DATA_CATEGORIES,
SCOPE_PREFILLED_VVT_QUESTIONS,
} from '../vvt-profiling'
import type { ScopeProfilingAnswer } from '../compliance-scope-types'
// Helper
function ans(questionId: string, value: unknown): ScopeProfilingAnswer {
return { questionId, value } as ScopeProfilingAnswer
}
// =============================================================================
// 1. Company Profile → Scope Prefill
// =============================================================================
describe('CompanyProfile → Scope prefill', () => {
it('prefills org_has_dsb when dpoName is set', () => {
const profile = { dpoName: 'Max Mustermann' } as any
const answers = prefillFromCompanyProfile(profile)
expect(answers.find((a) => a.questionId === 'org_has_dsb')?.value).toBe(true)
})
it('does NOT prefill org_has_dsb when dpoName is empty', () => {
const profile = { dpoName: '' } as any
const answers = prefillFromCompanyProfile(profile)
expect(answers.find((a) => a.questionId === 'org_has_dsb')).toBeUndefined()
})
it('maps offerings to prod_type correctly', () => {
const profile = { offerings: ['WebApp', 'SaaS', 'API'] } as any
const answers = prefillFromCompanyProfile(profile)
const prodType = answers.find((a) => a.questionId === 'prod_type')
expect(prodType?.value).toEqual(expect.arrayContaining(['webapp', 'saas', 'api']))
})
it('detects webshop in offerings', () => {
const profile = { offerings: ['Webshop'] } as any
const answers = prefillFromCompanyProfile(profile)
expect(answers.find((a) => a.questionId === 'prod_webshop')?.value).toBe(true)
})
it('returns empty array when profile has no relevant data', () => {
const profile = {} as any
const answers = prefillFromCompanyProfile(profile)
expect(answers).toEqual([])
})
})
describe('CompanyProfile → Scope scoring answers', () => {
it('maps employeeCount to org_employee_count', () => {
const profile = { employeeCount: '50-249' } as any
const answers = getAutoFilledScoringAnswers(profile)
expect(answers.find((a) => a.questionId === 'org_employee_count')?.value).toBe('50-249')
})
it('maps industry to org_industry', () => {
const profile = { industry: ['IT & Software', 'Finanzdienstleistungen'] } as any
const answers = getAutoFilledScoringAnswers(profile)
expect(answers.find((a) => a.questionId === 'org_industry')?.value).toBe(
'IT & Software, Finanzdienstleistungen'
)
})
it('maps annualRevenue to org_annual_revenue', () => {
const profile = { annualRevenue: '1-10M' } as any
const answers = getAutoFilledScoringAnswers(profile)
expect(answers.find((a) => a.questionId === 'org_annual_revenue')?.value).toBe('1-10M')
})
it('maps businessModel to org_business_model', () => {
const profile = { businessModel: 'B2B' } as any
const answers = getAutoFilledScoringAnswers(profile)
expect(answers.find((a) => a.questionId === 'org_business_model')?.value).toBe('B2B')
})
})
// =============================================================================
// 2. Scope → VVT Answer Mapping (exportToVVTAnswers)
// =============================================================================
describe('Scope → VVT answer export', () => {
it('maps scope questions with mapsToVVTQuestion property', () => {
// Block 9: dk_dept_hr maps to dept_hr_categories
const scopeAnswers: ScopeProfilingAnswer[] = [
ans('dk_dept_hr', ['NAME', 'SALARY_DATA', 'HEALTH_DATA']),
]
const vvtAnswers = exportToVVTAnswers(scopeAnswers)
expect(vvtAnswers.dept_hr_categories).toEqual(['NAME', 'SALARY_DATA', 'HEALTH_DATA'])
})
it('maps multiple department data categories', () => {
const scopeAnswers: ScopeProfilingAnswer[] = [
ans('dk_dept_hr', ['NAME', 'BANK_ACCOUNT']),
ans('dk_dept_finance', ['INVOICE_DATA', 'TAX_ID']),
ans('dk_dept_marketing', ['EMAIL', 'TRACKING_DATA']),
]
const vvtAnswers = exportToVVTAnswers(scopeAnswers)
expect(vvtAnswers.dept_hr_categories).toEqual(['NAME', 'BANK_ACCOUNT'])
expect(vvtAnswers.dept_finance_categories).toEqual(['INVOICE_DATA', 'TAX_ID'])
expect(vvtAnswers.dept_marketing_categories).toEqual(['EMAIL', 'TRACKING_DATA'])
})
it('ignores scope questions without mapsToVVTQuestion', () => {
const scopeAnswers: ScopeProfilingAnswer[] = [
ans('vvt_has_vvt', true), // No mapsToVVTQuestion property
]
const vvtAnswers = exportToVVTAnswers(scopeAnswers)
expect(Object.keys(vvtAnswers)).toHaveLength(0)
})
it('handles empty scope answers', () => {
const vvtAnswers = exportToVVTAnswers([])
expect(vvtAnswers).toEqual({})
})
})
// =============================================================================
// 3. Scope → VVT Profiling Prefill
// Note: prefillFromScopeAnswers() uses dynamic require('./compliance-scope-profiling')
// which doesn't resolve in vitest. We test the same pipeline by calling
// exportToVVTAnswers() directly (which is what prefillFromScopeAnswers wraps).
// =============================================================================
describe('Scope → VVT Profiling Prefill (via exportToVVTAnswers)', () => {
it('converts scope answers to VVT ProfilingAnswers format', () => {
const scopeAnswers: ScopeProfilingAnswer[] = [
ans('dk_dept_hr', ['NAME', 'HEALTH_DATA']),
ans('dk_dept_finance', ['BANK_ACCOUNT']),
]
const exported = exportToVVTAnswers(scopeAnswers)
// Same transformation as prefillFromScopeAnswers
const profiling: Record<string, unknown> = {}
for (const [key, value] of Object.entries(exported)) {
if (value !== undefined && value !== null) profiling[key] = value
}
expect(profiling.dept_hr_categories).toEqual(['NAME', 'HEALTH_DATA'])
expect(profiling.dept_finance_categories).toEqual(['BANK_ACCOUNT'])
})
it('filters out null/undefined values', () => {
const scopeAnswers: ScopeProfilingAnswer[] = [ans('dk_dept_hr', null)]
const exported = exportToVVTAnswers(scopeAnswers)
const profiling: Record<string, unknown> = {}
for (const [key, value] of Object.entries(exported)) {
if (value !== undefined && value !== null) profiling[key] = value
}
expect(profiling.dept_hr_categories).toBeUndefined()
})
})
// =============================================================================
// 4. VVT Generator — generateActivities
// =============================================================================
describe('generateActivities', () => {
it('always generates 4 IT baseline activities', () => {
const result = generateActivities({})
const names = result.generatedActivities.map((a) => a.name)
expect(result.generatedActivities.length).toBeGreaterThanOrEqual(4)
// IT baselines are always added
const itTemplates = result.generatedActivities.filter(
(a) => a.businessFunction === 'it_operations'
)
expect(itTemplates.length).toBeGreaterThanOrEqual(4)
})
it('triggers HR templates when dept_hr=true', () => {
const result = generateActivities({ dept_hr: true })
const hrActivities = result.generatedActivities.filter(
(a) => a.businessFunction === 'hr'
)
expect(hrActivities.length).toBeGreaterThanOrEqual(3) // mitarbeiter, gehalt, zeiterfassung
})
it('triggers finance templates when dept_finance=true', () => {
const result = generateActivities({ dept_finance: true })
const financeActivities = result.generatedActivities.filter(
(a) => a.businessFunction === 'finance'
)
expect(financeActivities.length).toBeGreaterThanOrEqual(2) // buchhaltung, zahlungsverkehr
})
it('enriches activities with US cloud third-country transfer', () => {
const result = generateActivities({ dept_hr: true, transfer_cloud_us: true })
// Every activity should have a US third-country transfer
for (const activity of result.generatedActivities) {
expect(activity.thirdCountryTransfers.some((t) => t.country === 'US')).toBe(true)
}
})
it('adds HEALTH_DATA to HR activities when data_health=true', () => {
const result = generateActivities({ dept_hr: true, data_health: true })
const hrActivities = result.generatedActivities.filter(
(a) => a.businessFunction === 'hr'
)
expect(hrActivities.length).toBeGreaterThan(0)
for (const hr of hrActivities) {
expect(hr.personalDataCategories).toContain('HEALTH_DATA')
}
})
it('calculates Art. 30 Abs. 5 exemption correctly', () => {
// < 250 employees, no special categories → exempt
const result1 = generateActivities({ org_employees: 50 })
expect(result1.art30Abs5Exempt).toBe(true)
// >= 250 employees → not exempt
const result2 = generateActivities({ org_employees: 500 })
expect(result2.art30Abs5Exempt).toBe(false)
// < 250 but with special categories → not exempt
const result3 = generateActivities({ org_employees: 50, data_health: true })
expect(result3.art30Abs5Exempt).toBe(false)
})
it('generates unique VVT IDs for all activities', () => {
const result = generateActivities({
dept_hr: true,
dept_finance: true,
dept_sales: true,
dept_marketing: true,
})
const ids = result.generatedActivities.map((a) => a.vvtId)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(ids.length)
})
it('calculates coverage score > 0 for template-generated activities', () => {
const result = generateActivities({ dept_hr: true })
expect(result.coverageScore).toBeGreaterThan(0)
})
})
// =============================================================================
// 5. Full Pipeline: Company Profile → Scope → VVT
// =============================================================================
describe('Full Pipeline: CompanyProfile → Scope → VVT Generation', () => {
// Helper: replicate what prefillFromScopeAnswers does (avoiding dynamic require)
function scopeToProfilingAnswers(
scopeAnswers: ScopeProfilingAnswer[]
): Record<string, string | string[] | number | boolean> {
const exported = exportToVVTAnswers(scopeAnswers)
const profiling: Record<string, string | string[] | number | boolean> = {}
for (const [key, value] of Object.entries(exported)) {
if (value !== undefined && value !== null) {
profiling[key] = value as string | string[] | number | boolean
}
}
return profiling
}
it('complete flow: profile with DSB → scope prefill → VVT generation', () => {
// Step 1: Company Profile
const profile = {
dpoName: 'Dr. Datenschutz',
employeeCount: '50-249',
industry: ['IT & Software'],
offerings: ['WebApp', 'SaaS'],
} as any
// Step 2: Prefill scope from profile
const profileAnswers = prefillFromCompanyProfile(profile)
const scoringAnswers = getAutoFilledScoringAnswers(profile)
// Simulate user answering scope questions + auto-prefilled from profile
const userAnswers: ScopeProfilingAnswer[] = [
// Block 8: departments
ans('vvt_departments', ['personal', 'finanzen', 'it']),
// Block 9: data categories per department
ans('dk_dept_hr', ['NAME', 'ADDRESS', 'SALARY_DATA', 'HEALTH_DATA']),
ans('dk_dept_finance', ['NAME', 'BANK_ACCOUNT', 'INVOICE_DATA', 'TAX_ID']),
ans('dk_dept_it', ['USER_ACCOUNTS', 'LOG_DATA', 'DEVICE_DATA']),
// Block 2: data types
ans('data_art9', true),
ans('data_minors', false),
]
const allScopeAnswers = [...profileAnswers, ...scoringAnswers, ...userAnswers]
// Step 3: Export to VVT format
const vvtAnswers = exportToVVTAnswers(allScopeAnswers)
expect(vvtAnswers.dept_hr_categories).toEqual([
'NAME',
'ADDRESS',
'SALARY_DATA',
'HEALTH_DATA',
])
expect(vvtAnswers.dept_finance_categories).toEqual([
'NAME',
'BANK_ACCOUNT',
'INVOICE_DATA',
'TAX_ID',
])
// Step 4: Prefill VVT profiling from scope (via direct export)
const profilingAnswers = scopeToProfilingAnswers(allScopeAnswers)
// Verify data survived the transformation
expect(profilingAnswers.dept_hr_categories).toEqual([
'NAME',
'ADDRESS',
'SALARY_DATA',
'HEALTH_DATA',
])
// Step 5: Generate VVT activities
// Add department triggers that match Block 8 selections
profilingAnswers.dept_hr = true
profilingAnswers.dept_finance = true
const result = generateActivities(profilingAnswers)
// Verify activities were generated
expect(result.generatedActivities.length).toBeGreaterThan(4) // 4 IT baseline + HR + Finance
// Verify HR activities exist
const hrActivities = result.generatedActivities.filter(
(a) => a.businessFunction === 'hr'
)
expect(hrActivities.length).toBeGreaterThanOrEqual(3)
// Verify finance activities exist
const financeActivities = result.generatedActivities.filter(
(a) => a.businessFunction === 'finance'
)
expect(financeActivities.length).toBeGreaterThanOrEqual(2)
})
it('end-to-end: departments selected in scope generate correct VVT activities', () => {
// Simulate a complete scope session with department selections
const scopeAnswers: ScopeProfilingAnswer[] = [
// Block 2: data_art9 maps to data_health in VVT
ans('data_art9', true),
// Block 4: tech_third_country maps to transfer_cloud_us
ans('tech_third_country', true),
// Block 8: departments
ans('vvt_departments', ['personal', 'marketing', 'kundenservice']),
// Block 9: per-department data categories
ans('dk_dept_hr', ['NAME', 'HEALTH_DATA', 'RELIGIOUS_BELIEFS']),
ans('dk_dept_marketing', ['EMAIL', 'TRACKING_DATA', 'CONSENT_DATA']),
ans('dk_dept_support', ['NAME', 'TICKET_DATA', 'COMMUNICATION_DATA']),
]
// Transform to VVT answers
const vvtAnswers = exportToVVTAnswers(scopeAnswers)
// Verify Block 9 data categories are mapped correctly
expect(vvtAnswers.dept_hr_categories).toEqual(['NAME', 'HEALTH_DATA', 'RELIGIOUS_BELIEFS'])
expect(vvtAnswers.dept_marketing_categories).toEqual([
'EMAIL',
'TRACKING_DATA',
'CONSENT_DATA',
])
expect(vvtAnswers.dept_support_categories).toEqual([
'NAME',
'TICKET_DATA',
'COMMUNICATION_DATA',
])
// Verify the full pipeline using direct export
const profilingAnswers = scopeToProfilingAnswers(scopeAnswers)
expect(profilingAnswers.dept_hr_categories).toBeDefined()
expect(profilingAnswers.dept_marketing_categories).toBeDefined()
expect(profilingAnswers.dept_support_categories).toBeDefined()
})
})
// =============================================================================
// 6. DEPARTMENT_DATA_CATEGORIES Integrity
// =============================================================================
describe('DEPARTMENT_DATA_CATEGORIES consistency', () => {
it('all 12 departments are defined', () => {
const expected = [
'dept_hr',
'dept_recruiting',
'dept_finance',
'dept_sales',
'dept_marketing',
'dept_support',
'dept_it',
'dept_recht',
'dept_produktion',
'dept_logistik',
'dept_einkauf',
'dept_facility',
]
for (const dept of expected) {
expect(DEPARTMENT_DATA_CATEGORIES[dept]).toBeDefined()
expect(DEPARTMENT_DATA_CATEGORIES[dept].categories.length).toBeGreaterThan(0)
}
})
it('every department has a label and icon', () => {
for (const [, dept] of Object.entries(DEPARTMENT_DATA_CATEGORIES)) {
expect(dept.label).toBeTruthy()
expect(dept.icon).toBeTruthy()
}
})
it('every category has id and label', () => {
for (const [, dept] of Object.entries(DEPARTMENT_DATA_CATEGORIES)) {
for (const cat of dept.categories) {
expect(cat.id).toBeTruthy()
expect(cat.label).toBeTruthy()
expect(cat.info).toBeTruthy()
}
}
})
it('Art. 9 categories are correctly flagged', () => {
const art9Categories = [
{ dept: 'dept_hr', id: 'HEALTH_DATA' },
{ dept: 'dept_hr', id: 'RELIGIOUS_BELIEFS' },
{ dept: 'dept_recruiting', id: 'HEALTH_DATA' },
{ dept: 'dept_recht', id: 'CRIMINAL_DATA' },
{ dept: 'dept_produktion', id: 'HEALTH_DATA' },
{ dept: 'dept_facility', id: 'HEALTH_DATA' },
]
for (const { dept, id } of art9Categories) {
const cat = DEPARTMENT_DATA_CATEGORIES[dept].categories.find((c) => c.id === id)
expect(cat?.isArt9).toBe(true)
}
})
})
// =============================================================================
// 7. Block 9 ↔ VVT Mapping Integrity
// =============================================================================
describe('Block 9 Scope ↔ VVT question mapping', () => {
it('every Block 9 question has mapsToVVTQuestion', () => {
const block9 = SCOPE_QUESTION_BLOCKS.find((b) => b.id === 'datenkategorien_detail')
expect(block9).toBeDefined()
for (const q of block9!.questions) {
expect(q.mapsToVVTQuestion).toBeTruthy()
expect(q.mapsToVVTQuestion).toMatch(/^dept_\w+_categories$/)
}
})
it('Block 9 question options match DEPARTMENT_DATA_CATEGORIES', () => {
const block9 = SCOPE_QUESTION_BLOCKS.find((b) => b.id === 'datenkategorien_detail')
expect(block9).toBeDefined()
// dk_dept_hr should have same options as DEPARTMENT_DATA_CATEGORIES.dept_hr
const hrQuestion = block9!.questions.find((q) => q.id === 'dk_dept_hr')
expect(hrQuestion).toBeDefined()
const expectedIds = DEPARTMENT_DATA_CATEGORIES.dept_hr.categories.map((c) => c.id)
const actualIds = hrQuestion!.options!.map((o) => o.value)
expect(actualIds).toEqual(expectedIds)
})
it('SCOPE_PREFILLED_VVT_QUESTIONS lists all cross-module questions', () => {
expect(SCOPE_PREFILLED_VVT_QUESTIONS).toContain('org_industry')
expect(SCOPE_PREFILLED_VVT_QUESTIONS).toContain('dept_hr')
expect(SCOPE_PREFILLED_VVT_QUESTIONS).toContain('data_health')
expect(SCOPE_PREFILLED_VVT_QUESTIONS).toContain('transfer_cloud_us')
expect(SCOPE_PREFILLED_VVT_QUESTIONS.length).toBeGreaterThanOrEqual(15)
})
})
// =============================================================================
// 8. Edge Cases
// =============================================================================
describe('Edge cases', () => {
it('generateActivities with no answers still produces IT baselines', () => {
const result = generateActivities({})
expect(result.generatedActivities.length).toBe(4) // 4 IT baselines
expect(result.art30Abs5Exempt).toBe(true) // 0 employees, no special categories
})
it('same template triggered by multiple questions is only generated once', () => {
const result = generateActivities({
dept_sales: true, // triggers sales-kundenverwaltung
sys_crm: true, // also triggers sales-kundenverwaltung
})
const salesKunden = result.generatedActivities.filter((a) =>
a.name.toLowerCase().includes('kundenverwaltung')
)
// Should be deduplicated (Set-based triggeredIds)
expect(salesKunden.length).toBe(1)
})
it('empty department category selections produce valid but empty mappings', () => {
const scopeAnswers: ScopeProfilingAnswer[] = [ans('dk_dept_hr', [])]
const vvtAnswers = exportToVVTAnswers(scopeAnswers)
expect(vvtAnswers.dept_hr_categories).toEqual([])
})
})

View File

@@ -1,9 +1,9 @@
/**
* Loeschfristen Baseline-Katalog
*
* 25 vordefinierte Aufbewahrungsfristen-Templates fuer gaengige
* 18 vordefinierte Aufbewahrungsfristen-Templates fuer gaengige
* Datenobjekte in deutschen Unternehmen. Basierend auf AO, HGB,
* UStG, BGB, ArbZG, AGG, BDSG, BSIG und ArbMedVV.
* UStG, BGB, ArbZG, AGG, BDSG und BSIG.
*
* Werden genutzt, um neue Loeschfrist-Policies schnell aus
* bewaehrten Vorlagen zu erstellen.
@@ -48,7 +48,7 @@ export interface BaselineTemplate {
}
// =============================================================================
// BASELINE TEMPLATES (25 Vorlagen)
// BASELINE TEMPLATES (18 Vorlagen)
// =============================================================================
export const BASELINE_TEMPLATES: BaselineTemplate[] = [
@@ -519,188 +519,6 @@ export const BASELINE_TEMPLATES: BaselineTemplate[] = [
reviewInterval: 'ANNUAL',
tags: ['datenschutz', 'consent'],
},
// ==================== 19. E-Mail-Archivierung ====================
{
templateId: 'email-archivierung',
dataObjectName: 'E-Mail-Archivierung',
description:
'Archivierte geschaeftliche E-Mails inkl. Anhaenge, die als Handelsbriefe oder steuerrelevante Korrespondenz einzustufen sind.',
affectedGroups: ['Mitarbeiter', 'Kunden', 'Lieferanten'],
dataCategories: ['E-Mail-Korrespondenz', 'Anhaenge', 'Metadaten'],
primaryPurpose:
'Erfuellung der handelsrechtlichen Aufbewahrungspflicht fuer geschaeftliche Korrespondenz, die als Handelsbrief einzuordnen ist.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'HGB_257',
retentionDriverDetail:
'Aufbewahrungspflicht gemaess 257 HGB fuer empfangene und versandte Handelsbriefe (6 Jahre) bzw. buchhalterisch relevante E-Mails (10 Jahre).',
retentionDuration: 6,
retentionUnit: 'YEARS',
retentionDescription: '6 Jahre nach Versand/Empfang der E-Mail',
startEvent: 'Versand- bzw. Empfangsdatum der E-Mail',
deletionMethod: 'AUTO_DELETE',
deletionMethodDetail:
'Automatische Loeschung durch das E-Mail-Archivierungssystem nach Ablauf der konfigurierten Aufbewahrungsfrist. Vor Loeschung wird geprueft, ob die E-Mail in laufenden Verfahren benoetigt wird.',
responsibleRole: 'IT-Abteilung',
reviewInterval: 'ANNUAL',
tags: ['kommunikation', 'hgb'],
},
// ==================== 20. Zutrittsprotokolle ====================
{
templateId: 'zutrittsprotokolle',
dataObjectName: 'Zutrittsprotokolle',
description:
'Protokolle des Zutrittskontrollsystems inkl. Zeitstempel, Kartennummer, Zutrittsort und Zugangsentscheidung (gewaehrt/verweigert).',
affectedGroups: ['Mitarbeiter', 'Besucher'],
dataCategories: ['Zutrittsdaten', 'Zeitstempel', 'Kartennummern', 'Standortdaten'],
primaryPurpose:
'Sicherstellung der physischen Sicherheit, Nachvollziehbarkeit von Zutritten und Unterstuetzung bei der Aufklaerung von Sicherheitsvorfaellen.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'BSIG',
retentionDriverDetail:
'Aufbewahrung gemaess BSI-Grundschutz-Empfehlung fuer Zutrittsprotokolle zur Analyse von Sicherheitsvorfaellen (90 Tage).',
retentionDuration: 90,
retentionUnit: 'DAYS',
retentionDescription: '90 Tage nach Zeitpunkt des Zutritts',
startEvent: 'Zeitpunkt des protokollierten Zutrittsereignisses',
deletionMethod: 'AUTO_DELETE',
deletionMethodDetail:
'Automatische Rotation und Loeschung der Zutrittsprotokolle durch das Zutrittskontrollsystem nach Ablauf der 90-Tage-Frist.',
responsibleRole: 'Facility Management',
reviewInterval: 'QUARTERLY',
tags: ['sicherheit', 'zutritt'],
},
// ==================== 21. Schulungsnachweise ====================
{
templateId: 'schulungsnachweise',
dataObjectName: 'Schulungsnachweise',
description:
'Teilnahmebestaetigungen, Zertifikate und Protokolle von Mitarbeiterschulungen (Datenschutz, Arbeitssicherheit, Compliance).',
affectedGroups: ['Mitarbeiter'],
dataCategories: ['Schulungsdaten', 'Zertifikate', 'Teilnahmelisten'],
primaryPurpose:
'Nachweis der Durchfuehrung gesetzlich vorgeschriebener Schulungen und Dokumentation der Mitarbeiterqualifikation.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'CUSTOM',
retentionDriverDetail:
'Aufbewahrung fuer 3 Jahre nach Ende des Beschaeftigungsverhaeltnisses als Nachweis der ordnungsgemaessen Schulungsdurchfuehrung.',
retentionDuration: 3,
retentionUnit: 'YEARS',
retentionDescription: '3 Jahre nach Ende des Beschaeftigungsverhaeltnisses',
startEvent: 'Ende des Beschaeftigungsverhaeltnisses des geschulten Mitarbeiters',
deletionMethod: 'MANUAL_REVIEW_DELETE',
deletionMethodDetail:
'Manuelle Pruefung durch die HR-Abteilung vor Loeschung, da Schulungsnachweise als Compliance-Nachweis in Audits relevant sein koennen.',
responsibleRole: 'HR-Abteilung',
reviewInterval: 'ANNUAL',
tags: ['hr', 'schulung'],
},
// ==================== 22. Betriebsarzt-Dokumentation ====================
{
templateId: 'betriebsarzt-doku',
dataObjectName: 'Betriebsarzt-Dokumentation',
description:
'Ergebnisse arbeitsmedizinischer Vorsorgeuntersuchungen, Eignungsuntersuchungen und arbeitsmedizinische Empfehlungen.',
affectedGroups: ['Mitarbeiter'],
dataCategories: ['Gesundheitsdaten', 'Vorsorgeuntersuchungen', 'Eignungsbefunde'],
primaryPurpose:
'Erfuellung der Dokumentationspflicht fuer arbeitsmedizinische Vorsorge gemaess ArbMedVV und Nachweisfuehrung gegenueber Berufsgenossenschaften.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'CUSTOM',
retentionDriverDetail:
'Aufbewahrungspflicht gemaess ArbMedVV (Verordnung zur arbeitsmedizinischen Vorsorge) und Berufsgenossenschaftliche Grundsaetze: bis zu 40 Jahre bei Exposition gegenueber krebserzeugenden Gefahrstoffen.',
retentionDuration: 40,
retentionUnit: 'YEARS',
retentionDescription: '40 Jahre nach letzter Exposition (bei Gefahrstoffen), sonst 10 Jahre nach Ende der Taetigkeit',
startEvent: 'Ende der expositionsrelevanten Taetigkeit bzw. Ende des Beschaeftigungsverhaeltnisses',
deletionMethod: 'PHYSICAL_DESTROY',
deletionMethodDetail:
'Physische Vernichtung der Papierunterlagen durch zertifizierten Aktenvernichtungsdienstleister (DIN 66399, Sicherheitsstufe P-5). Digitale Daten werden kryptographisch geloescht.',
responsibleRole: 'Betriebsarzt / Arbeitsmedizinischer Dienst',
reviewInterval: 'ANNUAL',
tags: ['hr', 'gesundheit'],
},
// ==================== 23. Kundenreklamationen ====================
{
templateId: 'kundenreklamationen',
dataObjectName: 'Kundenreklamationen',
description:
'Reklamationsvorgaenge inkl. Beschwerdeinhalt, Kommunikationsverlauf, Massnahmen und Ergebnis der Reklamationsbearbeitung.',
affectedGroups: ['Kunden'],
dataCategories: ['Reklamationsdaten', 'Kommunikation', 'Massnahmenprotokolle'],
primaryPurpose:
'Dokumentation und Bearbeitung von Kundenreklamationen, Qualitaetssicherung und Absicherung gegen Gewaehrleistungsansprueche.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'BGB_195',
retentionDriverDetail:
'Aufbewahrung fuer die Dauer der regelmaessigen Verjaehrungsfrist gemaess 195 BGB (3 Jahre) zur Absicherung gegen Gewaehrleistungs- und Schadensersatzansprueche.',
retentionDuration: 3,
retentionUnit: 'YEARS',
retentionDescription: '3 Jahre nach Abschluss des Reklamationsvorgangs',
startEvent: 'Abschluss des Reklamationsvorgangs (letzte Massnahme)',
deletionMethod: 'ANONYMIZATION',
deletionMethodDetail:
'Anonymisierung der personenbezogenen Daten nach Ablauf der Frist. Anonymisierte Reklamationsstatistiken bleiben fuer die Qualitaetssicherung erhalten.',
responsibleRole: 'Qualitaetsmanagement',
reviewInterval: 'ANNUAL',
tags: ['kunden', 'qualitaet'],
},
// ==================== 24. Lieferantenbewertungen ====================
{
templateId: 'lieferantenbewertungen',
dataObjectName: 'Lieferantenbewertungen',
description:
'Bewertungen und Auditergebnisse von Lieferanten und Auftragsverarbeitern inkl. Qualitaets-, Compliance- und Datenschutz-Bewertungen.',
affectedGroups: ['Lieferanten', 'Auftragsverarbeiter'],
dataCategories: ['Bewertungsdaten', 'Auditberichte', 'Vertragsdaten'],
primaryPurpose:
'Dokumentation der Sorgfaltspflicht bei der Auswahl und Ueberwachung von Auftragsverarbeitern gemaess Art. 28 DSGVO und Qualitaetssicherung in der Lieferkette.',
deletionTrigger: 'RETENTION_DRIVER',
retentionDriver: 'HGB_257',
retentionDriverDetail:
'Aufbewahrung gemaess 257 HGB als handelsrelevante Unterlagen sowie zur Nachweisfuehrung der Sorgfaltspflicht bei der Auftragsverarbeitung.',
retentionDuration: 6,
retentionUnit: 'YEARS',
retentionDescription: '6 Jahre nach Ende der Geschaeftsbeziehung',
startEvent: 'Ende der Geschaeftsbeziehung mit dem Lieferanten/Auftragsverarbeiter',
deletionMethod: 'MANUAL_REVIEW_DELETE',
deletionMethodDetail:
'Manuelle Pruefung durch den Einkauf/Compliance-Abteilung vor Loeschung, um sicherzustellen, dass keine Nachweispflichten aus laufenden Vertraegen oder Audits bestehen.',
responsibleRole: 'Einkauf / Compliance',
reviewInterval: 'ANNUAL',
tags: ['lieferanten', 'einkauf'],
},
// ==================== 25. Social-Media-Marketingdaten ====================
{
templateId: 'social-media-daten',
dataObjectName: 'Social-Media-Marketingdaten',
description:
'Personenbezogene Daten aus Social-Media-Kampagnen inkl. Nutzerinteraktionen, Custom Audiences, Retargeting-Listen und Kampagnen-Analytics.',
affectedGroups: ['Kunden', 'Interessenten', 'Website-Besucher'],
dataCategories: ['Interaktionsdaten', 'Zielgruppendaten', 'Tracking-Daten', 'Profilmerkmale'],
primaryPurpose:
'Durchfuehrung zielgerichteter Marketing-Kampagnen auf Social-Media-Plattformen und Analyse der Kampagneneffektivitaet.',
deletionTrigger: 'PURPOSE_END',
retentionDriver: null,
retentionDriverDetail:
'Keine gesetzliche Aufbewahrungspflicht. Daten werden bis zum Widerruf der Einwilligung bzw. bis zum Ende der Kampagne gespeichert (Art. 6 Abs. 1 lit. a DSGVO).',
retentionDuration: null,
retentionUnit: null,
retentionDescription: 'Bis zum Widerruf der Einwilligung oder Ende des Kampagnenzwecks',
startEvent: 'Widerruf der Einwilligung oder Ende der Marketing-Kampagne',
deletionMethod: 'AUTO_DELETE',
deletionMethodDetail:
'Automatische Loeschung der personenbezogenen Daten in den Social-Media-Werbekonten und internen Systemen nach Zweckwegfall. Custom Audiences werden bei Plattformanbietern geloescht.',
responsibleRole: 'Marketing',
reviewInterval: 'SEMI_ANNUAL',
tags: ['marketing', 'social'],
},
]
// =============================================================================

View File

@@ -6,10 +6,8 @@
import {
LoeschfristPolicy,
PolicyStatus,
RetentionDriverType,
isPolicyOverdue,
getActiveLegalHolds,
RETENTION_DRIVER_META,
} from './loeschfristen-types'
// =============================================================================
@@ -24,10 +22,6 @@ export type ComplianceIssueType =
| 'LEGAL_HOLD_CONFLICT'
| 'STALE_DRAFT'
| 'UNCOVERED_VVT_CATEGORY'
| 'MISSING_DELETION_METHOD'
| 'MISSING_STORAGE_LOCATIONS'
| 'EXCESSIVE_RETENTION'
| 'MISSING_DATA_CATEGORIES'
export type ComplianceIssueSeverity = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
@@ -225,108 +219,6 @@ function checkStaleDraft(policy: LoeschfristPolicy): ComplianceIssue | null {
return null
}
/**
* Check 8: MISSING_DELETION_METHOD (MEDIUM)
* Active policy without a deletion method detail description.
*/
function checkMissingDeletionMethod(policy: LoeschfristPolicy): ComplianceIssue | null {
if (policy.status === 'ACTIVE' && !policy.deletionMethodDetail.trim()) {
return createIssue(
policy,
'MISSING_DELETION_METHOD',
'MEDIUM',
'Keine Loeschmethode beschrieben',
`Die aktive Policy "${policy.dataObjectName}" hat keine detaillierte Beschreibung der Loeschmethode. Fuer ein auditfaehiges Loeschkonzept muss dokumentiert sein, wie die Loeschung technisch durchgefuehrt wird.`,
'Ergaenzen Sie eine detaillierte Beschreibung der Loeschmethode (z.B. automatisches Loeschen durch Datenbank-Job, manuelle Pruefung durch Fachabteilung, kryptographische Loeschung).'
)
}
return null
}
/**
* Check 9: MISSING_STORAGE_LOCATIONS (MEDIUM)
* Active policy without any documented storage locations.
*/
function checkMissingStorageLocations(policy: LoeschfristPolicy): ComplianceIssue | null {
if (policy.status === 'ACTIVE' && policy.storageLocations.length === 0) {
return createIssue(
policy,
'MISSING_STORAGE_LOCATIONS',
'MEDIUM',
'Keine Speicherorte dokumentiert',
`Die aktive Policy "${policy.dataObjectName}" hat keine Speicherorte hinterlegt. Ohne Speicherort-Dokumentation ist unklar, wo die Daten gespeichert sind und wo die Loeschung durchgefuehrt werden muss.`,
'Dokumentieren Sie mindestens einen Speicherort (z.B. Datenbank, Cloud-Speicher, E-Mail-System, Papierarchiv).'
)
}
return null
}
/**
* Check 10: EXCESSIVE_RETENTION (HIGH)
* Retention duration exceeds 2x the legal default for the driver.
*/
function checkExcessiveRetention(policy: LoeschfristPolicy): ComplianceIssue | null {
if (
policy.retentionDriver &&
policy.retentionDriver !== 'CUSTOM' &&
policy.retentionDuration !== null &&
policy.retentionUnit !== null
) {
const meta = RETENTION_DRIVER_META[policy.retentionDriver]
if (meta.defaultDuration !== null && meta.defaultUnit !== null) {
// Normalize both to days for comparison
const policyDays = toDays(policy.retentionDuration, policy.retentionUnit)
const legalDays = toDays(meta.defaultDuration, meta.defaultUnit)
if (legalDays > 0 && policyDays > legalDays * 2) {
return createIssue(
policy,
'EXCESSIVE_RETENTION',
'HIGH',
'Ueberschreitung der gesetzlichen Aufbewahrungsfrist',
`Die Policy "${policy.dataObjectName}" hat eine Aufbewahrungsdauer von ${policy.retentionDuration} ${policy.retentionUnit === 'YEARS' ? 'Jahren' : policy.retentionUnit === 'MONTHS' ? 'Monaten' : 'Tagen'}, die mehr als das Doppelte der gesetzlichen Frist (${meta.defaultDuration} ${meta.defaultUnit === 'YEARS' ? 'Jahre' : meta.defaultUnit === 'MONTHS' ? 'Monate' : 'Tage'} nach ${meta.statute}) betraegt. Ueberlange Speicherung widerspricht dem Grundsatz der Speicherbegrenzung (Art. 5 Abs. 1 lit. e DSGVO).`,
'Pruefen Sie, ob die verlaengerte Aufbewahrungsdauer gerechtfertigt ist. Falls nicht, reduzieren Sie sie auf die gesetzliche Mindestfrist.'
)
}
}
}
return null
}
/**
* Check 11: MISSING_DATA_CATEGORIES (LOW)
* Non-draft policy without any data categories assigned.
*/
function checkMissingDataCategories(policy: LoeschfristPolicy): ComplianceIssue | null {
if (policy.status !== 'DRAFT' && policy.dataCategories.length === 0) {
return createIssue(
policy,
'MISSING_DATA_CATEGORIES',
'LOW',
'Keine Datenkategorien zugeordnet',
`Die Policy "${policy.dataObjectName}" (Status: ${policy.status}) hat keine Datenkategorien zugeordnet. Ohne Datenkategorien ist unklar, welche personenbezogenen Daten von dieser Loeschregel betroffen sind.`,
'Ordnen Sie mindestens eine Datenkategorie zu (z.B. Stammdaten, Kontaktdaten, Finanzdaten, Gesundheitsdaten).'
)
}
return null
}
/**
* Helper: convert retention duration to days for comparison.
*/
function toDays(duration: number, unit: string): number {
switch (unit) {
case 'DAYS': return duration
case 'MONTHS': return duration * 30
case 'YEARS': return duration * 365
default: return duration
}
}
// =============================================================================
// MAIN COMPLIANCE CHECK
// =============================================================================
@@ -356,10 +248,6 @@ export function runComplianceCheck(
checkNoResponsible(policy),
checkLegalHoldConflict(policy),
checkStaleDraft(policy),
checkMissingDeletionMethod(policy),
checkMissingStorageLocations(policy),
checkExcessiveRetention(policy),
checkMissingDataCategories(policy),
]
for (const issue of checks) {

View File

@@ -1,879 +0,0 @@
// =============================================================================
// Loeschfristen Module - Loeschkonzept Document Generator
// Generates a printable, audit-ready HTML document according to DSGVO Art. 5/17/30
// =============================================================================
import type {
LoeschfristPolicy,
RetentionDriverType,
} from './loeschfristen-types'
import {
RETENTION_DRIVER_META,
DELETION_METHOD_LABELS,
STATUS_LABELS,
TRIGGER_LABELS,
REVIEW_INTERVAL_LABELS,
formatRetentionDuration,
getEffectiveDeletionTrigger,
getActiveLegalHolds,
} from './loeschfristen-types'
import type { ComplianceCheckResult, ComplianceIssueSeverity } from './loeschfristen-compliance'
// =============================================================================
// TYPES
// =============================================================================
export interface LoeschkonzeptOrgHeader {
organizationName: string
industry: string
dpoName: string
dpoContact: string
responsiblePerson: string
locations: string[]
employeeCount: string
loeschkonzeptVersion: string
lastReviewDate: string
nextReviewDate: string
reviewInterval: string
}
export interface LoeschkonzeptRevision {
version: string
date: string
author: string
changes: string
}
// =============================================================================
// DEFAULTS
// =============================================================================
export function createDefaultLoeschkonzeptOrgHeader(): LoeschkonzeptOrgHeader {
const now = new Date()
const nextYear = new Date()
nextYear.setFullYear(nextYear.getFullYear() + 1)
return {
organizationName: '',
industry: '',
dpoName: '',
dpoContact: '',
responsiblePerson: '',
locations: [],
employeeCount: '',
loeschkonzeptVersion: '1.0',
lastReviewDate: now.toISOString().split('T')[0],
nextReviewDate: nextYear.toISOString().split('T')[0],
reviewInterval: 'Jaehrlich',
}
}
// =============================================================================
// SEVERITY LABELS (for Compliance Status section)
// =============================================================================
const SEVERITY_LABELS_DE: Record<ComplianceIssueSeverity, string> = {
CRITICAL: 'Kritisch',
HIGH: 'Hoch',
MEDIUM: 'Mittel',
LOW: 'Niedrig',
}
const SEVERITY_COLORS: Record<ComplianceIssueSeverity, string> = {
CRITICAL: '#dc2626',
HIGH: '#ea580c',
MEDIUM: '#d97706',
LOW: '#6b7280',
}
// =============================================================================
// HTML DOCUMENT BUILDER
// =============================================================================
export function buildLoeschkonzeptHtml(
policies: LoeschfristPolicy[],
orgHeader: LoeschkonzeptOrgHeader,
vvtActivities: Array<{ id: string; vvt_id?: string; vvtId?: string; name?: string; activity_name?: string }>,
complianceResult: ComplianceCheckResult | null,
revisions: LoeschkonzeptRevision[]
): string {
const today = new Date().toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
const activePolicies = policies.filter(p => p.status !== 'ARCHIVED')
const orgName = orgHeader.organizationName || 'Organisation'
// Collect unique storage locations across all policies
const allStorageLocations = new Set<string>()
for (const p of activePolicies) {
for (const loc of p.storageLocations) {
allStorageLocations.add(loc.name || loc.type)
}
}
// Collect unique responsible roles
const roleMap = new Map<string, string[]>()
for (const p of activePolicies) {
const role = p.responsibleRole || p.responsiblePerson || 'Nicht zugewiesen'
if (!roleMap.has(role)) roleMap.set(role, [])
roleMap.get(role)!.push(p.dataObjectName || p.policyId)
}
// Collect active legal holds
const allActiveLegalHolds: Array<{ policy: string; hold: LoeschfristPolicy['legalHolds'][0] }> = []
for (const p of activePolicies) {
for (const h of getActiveLegalHolds(p)) {
allActiveLegalHolds.push({ policy: p.dataObjectName || p.policyId, hold: h })
}
}
// Build VVT cross-reference data
const vvtRefs: Array<{ policyName: string; policyId: string; vvtId: string; vvtName: string }> = []
for (const p of activePolicies) {
for (const linkedId of p.linkedVVTActivityIds) {
const activity = vvtActivities.find(a => a.id === linkedId)
if (activity) {
vvtRefs.push({
policyName: p.dataObjectName || p.policyId,
policyId: p.policyId,
vvtId: activity.vvt_id || activity.vvtId || linkedId.substring(0, 8),
vvtName: activity.activity_name || activity.name || 'Unbenannte Verarbeitungstaetigkeit',
})
}
}
}
// Build vendor cross-reference data
const vendorRefs: Array<{ policyName: string; policyId: string; vendorId: string; duration: string }> = []
for (const p of activePolicies) {
if (p.linkedVendorIds && p.linkedVendorIds.length > 0) {
for (const vendorId of p.linkedVendorIds) {
vendorRefs.push({
policyName: p.dataObjectName || p.policyId,
policyId: p.policyId,
vendorId,
duration: formatRetentionDuration(p.retentionDuration, p.retentionUnit),
})
}
}
}
// =========================================================================
// HTML Template
// =========================================================================
let html = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Loeschkonzept — ${escHtml(orgName)}</title>
<style>
@page { size: A4; margin: 20mm 18mm 22mm 18mm; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 10pt;
line-height: 1.5;
color: #1e293b;
}
/* Cover */
.cover {
min-height: 90vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
page-break-after: always;
}
.cover h1 {
font-size: 28pt;
color: #5b21b6;
margin-bottom: 8px;
font-weight: 700;
}
.cover .subtitle {
font-size: 14pt;
color: #7c3aed;
margin-bottom: 40px;
}
.cover .org-info {
background: #f5f3ff;
border: 1px solid #ddd6fe;
border-radius: 8px;
padding: 24px 40px;
text-align: left;
width: 400px;
margin-bottom: 24px;
}
.cover .org-info div { margin-bottom: 6px; }
.cover .org-info .label { font-weight: 600; color: #5b21b6; display: inline-block; min-width: 160px; }
.cover .legal-ref {
font-size: 9pt;
color: #64748b;
margin-top: 20px;
}
/* TOC */
.toc {
page-break-after: always;
padding-top: 40px;
}
.toc h2 {
font-size: 18pt;
color: #5b21b6;
margin-bottom: 20px;
border-bottom: 2px solid #5b21b6;
padding-bottom: 8px;
}
.toc-entry {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px dotted #cbd5e1;
font-size: 10pt;
}
.toc-entry .toc-num { font-weight: 600; color: #5b21b6; min-width: 40px; }
/* Sections */
.section {
page-break-inside: avoid;
margin-bottom: 24px;
}
.section-header {
font-size: 14pt;
color: #5b21b6;
font-weight: 700;
margin: 30px 0 12px 0;
border-bottom: 2px solid #ddd6fe;
padding-bottom: 6px;
}
.section-body { margin-bottom: 16px; }
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin: 10px 0 16px 0;
font-size: 9pt;
}
th, td {
border: 1px solid #e2e8f0;
padding: 6px 8px;
text-align: left;
vertical-align: top;
}
th {
background: #f5f3ff;
color: #5b21b6;
font-weight: 600;
font-size: 8.5pt;
text-transform: uppercase;
letter-spacing: 0.3px;
}
tr:nth-child(even) td { background: #faf5ff; }
/* Detail cards */
.policy-detail {
page-break-inside: avoid;
border: 1px solid #e2e8f0;
border-radius: 6px;
margin-bottom: 16px;
overflow: hidden;
}
.policy-detail-header {
background: #f5f3ff;
padding: 8px 12px;
font-weight: 700;
color: #5b21b6;
border-bottom: 1px solid #ddd6fe;
display: flex;
justify-content: space-between;
}
.policy-detail-body { padding: 0; }
.policy-detail-body table { margin: 0; }
.policy-detail-body th { width: 200px; }
/* Badges */
.badge {
display: inline-block;
padding: 1px 8px;
border-radius: 9999px;
font-size: 8pt;
font-weight: 600;
}
.badge-active { background: #dcfce7; color: #166534; }
.badge-draft { background: #f3f4f6; color: #374151; }
.badge-review { background: #fef9c3; color: #854d0e; }
.badge-critical { background: #fecaca; color: #991b1b; }
.badge-high { background: #fed7aa; color: #9a3412; }
.badge-medium { background: #fef3c7; color: #92400e; }
.badge-low { background: #f3f4f6; color: #4b5563; }
/* Principles */
.principle {
margin-bottom: 10px;
padding-left: 20px;
position: relative;
}
.principle::before {
content: '';
position: absolute;
left: 0;
top: 6px;
width: 10px;
height: 10px;
background: #7c3aed;
border-radius: 50%;
}
.principle strong { color: #5b21b6; }
/* Score */
.score-box {
display: inline-block;
padding: 4px 16px;
border-radius: 8px;
font-size: 18pt;
font-weight: 700;
margin-right: 12px;
}
.score-excellent { background: #dcfce7; color: #166534; }
.score-good { background: #dbeafe; color: #1e40af; }
.score-needs-work { background: #fef3c7; color: #92400e; }
.score-poor { background: #fecaca; color: #991b1b; }
/* Footer */
.page-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 8px 18mm;
font-size: 7.5pt;
color: #94a3b8;
display: flex;
justify-content: space-between;
border-top: 1px solid #e2e8f0;
}
/* Print */
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.no-print { display: none !important; }
.page-break { page-break-after: always; }
}
</style>
</head>
<body>
`
// =========================================================================
// Section 0: Cover Page
// =========================================================================
html += `
<div class="cover">
<h1>Loeschkonzept</h1>
<div class="subtitle">gemaess Art. 5 Abs. 1 lit. e, Art. 17, Art. 30 DSGVO</div>
<div class="org-info">
<div><span class="label">Organisation:</span> ${escHtml(orgName)}</div>
${orgHeader.industry ? `<div><span class="label">Branche:</span> ${escHtml(orgHeader.industry)}</div>` : ''}
${orgHeader.dpoName ? `<div><span class="label">Datenschutzbeauftragter:</span> ${escHtml(orgHeader.dpoName)}</div>` : ''}
${orgHeader.dpoContact ? `<div><span class="label">DSB-Kontakt:</span> ${escHtml(orgHeader.dpoContact)}</div>` : ''}
${orgHeader.responsiblePerson ? `<div><span class="label">Verantwortlicher:</span> ${escHtml(orgHeader.responsiblePerson)}</div>` : ''}
${orgHeader.employeeCount ? `<div><span class="label">Mitarbeiter:</span> ${escHtml(orgHeader.employeeCount)}</div>` : ''}
${orgHeader.locations.length > 0 ? `<div><span class="label">Standorte:</span> ${escHtml(orgHeader.locations.join(', '))}</div>` : ''}
</div>
<div class="legal-ref">
Version ${escHtml(orgHeader.loeschkonzeptVersion)} | Stand: ${today}<br/>
Letzte Pruefung: ${formatDateDE(orgHeader.lastReviewDate)} | Naechste Pruefung: ${formatDateDE(orgHeader.nextReviewDate)}<br/>
Pruefintervall: ${escHtml(orgHeader.reviewInterval)}
</div>
</div>
`
// =========================================================================
// Table of Contents
// =========================================================================
const sections = [
'Ziel und Zweck',
'Geltungsbereich',
'Grundprinzipien der Datenspeicherung',
'Loeschregeln-Uebersicht',
'Detaillierte Loeschregeln',
'VVT-Verknuepfung',
'Auftragsverarbeiter mit Loeschpflichten',
'Legal Hold Verfahren',
'Verantwortlichkeiten',
'Pruef- und Revisionszyklus',
'Compliance-Status',
'Aenderungshistorie',
]
html += `
<div class="toc">
<h2>Inhaltsverzeichnis</h2>
${sections.map((s, i) => `<div class="toc-entry"><span><span class="toc-num">${i + 1}.</span> ${escHtml(s)}</span></div>`).join('\n ')}
</div>
`
// =========================================================================
// Section 1: Ziel und Zweck
// =========================================================================
html += `
<div class="section">
<div class="section-header">1. Ziel und Zweck</div>
<div class="section-body">
<p>Dieses Loeschkonzept definiert die systematischen Regeln und Verfahren fuer die Loeschung
personenbezogener Daten bei <strong>${escHtml(orgName)}</strong>. Es dient der Umsetzung
folgender DSGVO-Anforderungen:</p>
<table>
<tr><th>Rechtsgrundlage</th><th>Inhalt</th></tr>
<tr><td><strong>Art. 5 Abs. 1 lit. e DSGVO</strong></td><td>Grundsatz der Speicherbegrenzung — personenbezogene Daten duerfen nur so lange gespeichert werden, wie es fuer die Zwecke der Verarbeitung erforderlich ist.</td></tr>
<tr><td><strong>Art. 17 DSGVO</strong></td><td>Recht auf Loeschung (&bdquo;Recht auf Vergessenwerden&ldquo;) — Betroffene haben das Recht, die Loeschung ihrer Daten zu verlangen.</td></tr>
<tr><td><strong>Art. 30 DSGVO</strong></td><td>Verzeichnis von Verarbeitungstaetigkeiten — vorgesehene Fristen fuer die Loeschung der verschiedenen Datenkategorien muessen dokumentiert werden.</td></tr>
</table>
<p>Das Loeschkonzept ist fester Bestandteil des Datenschutz-Managementsystems und wird
regelmaessig ueberprueft und aktualisiert.</p>
</div>
</div>
`
// =========================================================================
// Section 2: Geltungsbereich
// =========================================================================
const storageListHtml = allStorageLocations.size > 0
? Array.from(allStorageLocations).map(s => `<li>${escHtml(s)}</li>`).join('')
: '<li><em>Keine Speicherorte dokumentiert</em></li>'
html += `
<div class="section">
<div class="section-header">2. Geltungsbereich</div>
<div class="section-body">
<p>Dieses Loeschkonzept gilt fuer alle personenbezogenen Daten, die von <strong>${escHtml(orgName)}</strong>
verarbeitet werden. Es umfasst <strong>${activePolicies.length}</strong> Loeschregeln fuer folgende Systeme und Speicherorte:</p>
<ul style="margin: 8px 0 8px 24px;">
${storageListHtml}
</ul>
<p>Saemtliche Verarbeitungstaetigkeiten, die im Verzeichnis von Verarbeitungstaetigkeiten (VVT)
erfasst sind, werden durch dieses Loeschkonzept abgedeckt.</p>
</div>
</div>
`
// =========================================================================
// Section 3: Grundprinzipien
// =========================================================================
html += `
<div class="section">
<div class="section-header">3. Grundprinzipien der Datenspeicherung</div>
<div class="section-body">
<div class="principle"><strong>Speicherbegrenzung:</strong> Personenbezogene Daten werden nur so lange gespeichert, wie es fuer den jeweiligen Verarbeitungszweck erforderlich ist (Art. 5 Abs. 1 lit. e DSGVO).</div>
<div class="principle"><strong>3-Level-Loeschlogik:</strong> Die Loeschung folgt einer dreistufigen Priorisierung: (1) Zweckende, (2) gesetzliche Aufbewahrungspflichten, (3) Legal Hold — jeweils mit der laengsten Frist als massgeblich.</div>
<div class="principle"><strong>Dokumentationspflicht:</strong> Jede Loeschregel ist dokumentiert mit Rechtsgrundlage, Frist, Loeschmethode und Verantwortlichkeit.</div>
<div class="principle"><strong>Regelmaessige Ueberpruefung:</strong> Alle Loeschregeln werden im definierten Intervall ueberprueft und bei Bedarf angepasst.</div>
<div class="principle"><strong>Datenschutz durch Technikgestaltung:</strong> Loeschmechanismen werden moeglichst automatisiert, um menschliche Fehler zu minimieren (Art. 25 DSGVO).</div>
</div>
</div>
`
// =========================================================================
// Section 4: Loeschregeln-Uebersicht
// =========================================================================
html += `
<div class="section page-break">
<div class="section-header">4. Loeschregeln-Uebersicht</div>
<div class="section-body">
<p>Die folgende Tabelle zeigt eine Uebersicht aller ${activePolicies.length} aktiven Loeschregeln:</p>
<table>
<tr>
<th>LF-Nr.</th>
<th>Datenobjekt</th>
<th>Loeschtrigger</th>
<th>Aufbewahrungsfrist</th>
<th>Loeschmethode</th>
<th>Status</th>
</tr>
`
for (const p of activePolicies) {
const trigger = TRIGGER_LABELS[getEffectiveDeletionTrigger(p)]
const duration = formatRetentionDuration(p.retentionDuration, p.retentionUnit)
const method = DELETION_METHOD_LABELS[p.deletionMethod]
const statusLabel = STATUS_LABELS[p.status]
const statusClass = p.status === 'ACTIVE' ? 'badge-active' : p.status === 'REVIEW_NEEDED' ? 'badge-review' : 'badge-draft'
html += ` <tr>
<td>${escHtml(p.policyId)}</td>
<td>${escHtml(p.dataObjectName)}</td>
<td>${escHtml(trigger)}</td>
<td>${escHtml(duration)}</td>
<td>${escHtml(method)}</td>
<td><span class="badge ${statusClass}">${escHtml(statusLabel)}</span></td>
</tr>
`
}
html += ` </table>
</div>
</div>
`
// =========================================================================
// Section 5: Detaillierte Loeschregeln
// =========================================================================
html += `
<div class="section">
<div class="section-header">5. Detaillierte Loeschregeln</div>
<div class="section-body">
`
for (const p of activePolicies) {
const trigger = TRIGGER_LABELS[getEffectiveDeletionTrigger(p)]
const duration = formatRetentionDuration(p.retentionDuration, p.retentionUnit)
const method = DELETION_METHOD_LABELS[p.deletionMethod]
const statusLabel = STATUS_LABELS[p.status]
const driverLabel = p.retentionDriver ? RETENTION_DRIVER_META[p.retentionDriver]?.label || p.retentionDriver : '-'
const driverStatute = p.retentionDriver ? RETENTION_DRIVER_META[p.retentionDriver]?.statute || '' : ''
const locations = p.storageLocations.map(l => l.name || l.type).join(', ') || '-'
const responsible = [p.responsiblePerson, p.responsibleRole].filter(s => s.trim()).join(' / ') || '-'
const activeHolds = getActiveLegalHolds(p)
html += `
<div class="policy-detail">
<div class="policy-detail-header">
<span>${escHtml(p.policyId)}${escHtml(p.dataObjectName)}</span>
<span class="badge ${p.status === 'ACTIVE' ? 'badge-active' : 'badge-draft'}">${escHtml(statusLabel)}</span>
</div>
<div class="policy-detail-body">
<table>
<tr><th>Beschreibung</th><td>${escHtml(p.description || '-')}</td></tr>
<tr><th>Betroffenengruppen</th><td>${escHtml(p.affectedGroups.join(', ') || '-')}</td></tr>
<tr><th>Datenkategorien</th><td>${escHtml(p.dataCategories.join(', ') || '-')}</td></tr>
<tr><th>Verarbeitungszweck</th><td>${escHtml(p.primaryPurpose || '-')}</td></tr>
<tr><th>Loeschtrigger</th><td>${escHtml(trigger)}</td></tr>
<tr><th>Aufbewahrungstreiber</th><td>${escHtml(driverLabel)}${driverStatute ? ` (${escHtml(driverStatute)})` : ''}</td></tr>
<tr><th>Aufbewahrungsfrist</th><td>${escHtml(duration)}</td></tr>
<tr><th>Startereignis</th><td>${escHtml(p.startEvent || '-')}</td></tr>
<tr><th>Loeschmethode</th><td>${escHtml(method)}</td></tr>
<tr><th>Loeschmethode (Detail)</th><td>${escHtml(p.deletionMethodDetail || '-')}</td></tr>
<tr><th>Speicherorte</th><td>${escHtml(locations)}</td></tr>
<tr><th>Verantwortlich</th><td>${escHtml(responsible)}</td></tr>
<tr><th>Pruefintervall</th><td>${escHtml(REVIEW_INTERVAL_LABELS[p.reviewInterval] || p.reviewInterval)}</td></tr>
${activeHolds.length > 0 ? `<tr><th>Aktive Legal Holds</th><td>${activeHolds.map(h => `${escHtml(h.reason)} (seit ${formatDateDE(h.startDate)})`).join('<br/>')}</td></tr>` : ''}
</table>
</div>
</div>
`
}
html += ` </div>
</div>
`
// =========================================================================
// Section 6: VVT-Verknuepfung
// =========================================================================
html += `
<div class="section page-break">
<div class="section-header">6. VVT-Verknuepfung</div>
<div class="section-body">
<p>Die folgende Tabelle zeigt die Verknuepfung zwischen Loeschregeln und Verarbeitungstaetigkeiten
im VVT (Art. 30 DSGVO):</p>
`
if (vvtRefs.length > 0) {
html += ` <table>
<tr><th>Loeschregel</th><th>LF-Nr.</th><th>VVT-Nr.</th><th>Verarbeitungstaetigkeit</th></tr>
`
for (const ref of vvtRefs) {
html += ` <tr>
<td>${escHtml(ref.policyName)}</td>
<td>${escHtml(ref.policyId)}</td>
<td>${escHtml(ref.vvtId)}</td>
<td>${escHtml(ref.vvtName)}</td>
</tr>
`
}
html += ` </table>
`
} else {
html += ` <p><em>Noch keine VVT-Verknuepfungen dokumentiert. Verknuepfen Sie Ihre Loeschregeln
mit den entsprechenden Verarbeitungstaetigkeiten im Editor-Tab.</em></p>
`
}
html += ` </div>
</div>
`
// =========================================================================
// Section 7: Auftragsverarbeiter mit Loeschpflichten
// =========================================================================
html += `
<div class="section">
<div class="section-header">7. Auftragsverarbeiter mit Loeschpflichten</div>
<div class="section-body">
<p>Die folgende Tabelle zeigt Loeschregeln, die mit Auftragsverarbeitern verknuepft sind.
Diese Verknuepfungen stellen sicher, dass auch bei extern verarbeiteten Daten die Loeschpflichten
eingehalten werden (Art. 28 DSGVO).</p>
`
if (vendorRefs.length > 0) {
html += ` <table>
<tr><th>Loeschregel</th><th>LF-Nr.</th><th>Auftragsverarbeiter (ID)</th><th>Aufbewahrungsfrist</th></tr>
`
for (const ref of vendorRefs) {
html += ` <tr>
<td>${escHtml(ref.policyName)}</td>
<td>${escHtml(ref.policyId)}</td>
<td>${escHtml(ref.vendorId)}</td>
<td>${escHtml(ref.duration)}</td>
</tr>
`
}
html += ` </table>
`
} else {
html += ` <p><em>Noch keine Auftragsverarbeiter mit Loeschregeln verknuepft. Verknuepfen Sie Ihre
Loeschregeln mit den entsprechenden Auftragsverarbeitern im Editor-Tab.</em></p>
`
}
html += ` </div>
</div>
`
// =========================================================================
// Section 8: Legal Hold Verfahren
// =========================================================================
html += `
<div class="section">
<div class="section-header">8. Legal Hold Verfahren</div>
<div class="section-body">
<p>Ein Legal Hold (Aufbewahrungspflicht aufgrund rechtlicher Verfahren) setzt die regulaere
Loeschung aus. Betroffene Daten duerfen trotz abgelaufener Loeschfrist nicht geloescht werden,
bis der Legal Hold aufgehoben wird.</p>
<p><strong>Verfahrensschritte:</strong></p>
<ol style="margin: 8px 0 8px 24px;">
<li>Rechtsabteilung/DSB identifiziert betroffene Datenkategorien</li>
<li>Legal Hold wird im System aktiviert (Status: Aktiv)</li>
<li>Automatische Loeschung wird fuer betroffene Policies ausgesetzt</li>
<li>Regelmaessige Pruefung, ob der Legal Hold noch erforderlich ist</li>
<li>Nach Aufhebung: Regulaere Loeschfristen greifen wieder</li>
</ol>
`
if (allActiveLegalHolds.length > 0) {
html += ` <p><strong>Aktuell aktive Legal Holds (${allActiveLegalHolds.length}):</strong></p>
<table>
<tr><th>Datenobjekt</th><th>Grund</th><th>Rechtsgrundlage</th><th>Seit</th><th>Voraussichtlich bis</th></tr>
`
for (const { policy, hold } of allActiveLegalHolds) {
html += ` <tr>
<td>${escHtml(policy)}</td>
<td>${escHtml(hold.reason)}</td>
<td>${escHtml(hold.legalBasis)}</td>
<td>${formatDateDE(hold.startDate)}</td>
<td>${hold.expectedEndDate ? formatDateDE(hold.expectedEndDate) : 'Unbefristet'}</td>
</tr>
`
}
html += ` </table>
`
} else {
html += ` <p><em>Derzeit sind keine aktiven Legal Holds vorhanden.</em></p>
`
}
html += ` </div>
</div>
`
// =========================================================================
// Section 9: Verantwortlichkeiten
// =========================================================================
html += `
<div class="section">
<div class="section-header">9. Verantwortlichkeiten</div>
<div class="section-body">
<p>Die folgende Rollenmatrix zeigt, welche Organisationseinheiten fuer welche Datenobjekte
die Loeschverantwortung tragen:</p>
<table>
<tr><th>Rolle / Verantwortlich</th><th>Datenobjekte</th><th>Anzahl</th></tr>
`
for (const [role, objects] of roleMap.entries()) {
html += ` <tr>
<td>${escHtml(role)}</td>
<td>${objects.map(o => escHtml(o)).join(', ')}</td>
<td>${objects.length}</td>
</tr>
`
}
html += ` </table>
</div>
</div>
`
// =========================================================================
// Section 10: Pruef- und Revisionszyklus
// =========================================================================
html += `
<div class="section">
<div class="section-header">10. Pruef- und Revisionszyklus</div>
<div class="section-body">
<table>
<tr><th>Eigenschaft</th><th>Wert</th></tr>
<tr><td>Aktuelles Pruefintervall</td><td>${escHtml(orgHeader.reviewInterval)}</td></tr>
<tr><td>Letzte Pruefung</td><td>${formatDateDE(orgHeader.lastReviewDate)}</td></tr>
<tr><td>Naechste Pruefung</td><td>${formatDateDE(orgHeader.nextReviewDate)}</td></tr>
<tr><td>Aktuelle Version</td><td>${escHtml(orgHeader.loeschkonzeptVersion)}</td></tr>
</table>
<p style="margin-top: 8px;">Bei jeder Pruefung wird das Loeschkonzept auf folgende Punkte ueberprueft:</p>
<ul style="margin: 8px 0 8px 24px;">
<li>Vollstaendigkeit aller Loeschregeln (neue Verarbeitungen erfasst?)</li>
<li>Aktualitaet der gesetzlichen Aufbewahrungsfristen</li>
<li>Wirksamkeit der technischen Loeschmechanismen</li>
<li>Einhaltung der definierten Loeschfristen</li>
<li>Angemessenheit der Verantwortlichkeiten</li>
</ul>
</div>
</div>
`
// =========================================================================
// Section 11: Compliance-Status
// =========================================================================
html += `
<div class="section page-break">
<div class="section-header">11. Compliance-Status</div>
<div class="section-body">
`
if (complianceResult) {
const scoreClass = complianceResult.score >= 90 ? 'score-excellent'
: complianceResult.score >= 75 ? 'score-good'
: complianceResult.score >= 50 ? 'score-needs-work'
: 'score-poor'
const scoreLabel = complianceResult.score >= 90 ? 'Ausgezeichnet'
: complianceResult.score >= 75 ? 'Gut'
: complianceResult.score >= 50 ? 'Verbesserungswuerdig'
: 'Mangelhaft'
html += ` <p><span class="score-box ${scoreClass}">${complianceResult.score}/100</span> ${escHtml(scoreLabel)}</p>
<table style="margin-top: 12px;">
<tr><th>Kennzahl</th><th>Wert</th></tr>
<tr><td>Gepruefte Policies</td><td>${complianceResult.stats.total}</td></tr>
<tr><td>Bestanden</td><td>${complianceResult.stats.passed}</td></tr>
<tr><td>Beanstandungen</td><td>${complianceResult.stats.failed}</td></tr>
</table>
`
if (complianceResult.issues.length > 0) {
html += ` <p style="margin-top: 12px;"><strong>Befunde nach Schweregrad:</strong></p>
<table>
<tr><th>Schweregrad</th><th>Anzahl</th><th>Befunde</th></tr>
`
const severityOrder: ComplianceIssueSeverity[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
for (const sev of severityOrder) {
const count = complianceResult.stats.bySeverity[sev]
if (count === 0) continue
const issuesForSev = complianceResult.issues.filter(i => i.severity === sev)
html += ` <tr>
<td><span class="badge badge-${sev.toLowerCase()}" style="color: ${SEVERITY_COLORS[sev]}">${SEVERITY_LABELS_DE[sev]}</span></td>
<td>${count}</td>
<td>${issuesForSev.map(i => escHtml(i.title)).join('; ')}</td>
</tr>
`
}
html += ` </table>
`
} else {
html += ` <p style="margin-top: 8px;"><em>Keine Beanstandungen. Alle Policies sind konform.</em></p>
`
}
} else {
html += ` <p><em>Compliance-Check wurde noch nicht ausgefuehrt. Fuehren Sie den Check im
Export-Tab durch, um den Status in das Dokument aufzunehmen.</em></p>
`
}
html += ` </div>
</div>
`
// =========================================================================
// Section 12: Aenderungshistorie
// =========================================================================
html += `
<div class="section">
<div class="section-header">12. Aenderungshistorie</div>
<div class="section-body">
<table>
<tr><th>Version</th><th>Datum</th><th>Autor</th><th>Aenderungen</th></tr>
`
if (revisions.length > 0) {
for (const rev of revisions) {
html += ` <tr>
<td>${escHtml(rev.version)}</td>
<td>${formatDateDE(rev.date)}</td>
<td>${escHtml(rev.author)}</td>
<td>${escHtml(rev.changes)}</td>
</tr>
`
}
} else {
html += ` <tr>
<td>${escHtml(orgHeader.loeschkonzeptVersion)}</td>
<td>${today}</td>
<td>${escHtml(orgHeader.dpoName || orgHeader.responsiblePerson || '-')}</td>
<td>Erstversion des Loeschkonzepts</td>
</tr>
`
}
html += ` </table>
</div>
</div>
`
// =========================================================================
// Footer
// =========================================================================
html += `
<div class="page-footer">
<span>Loeschkonzept — ${escHtml(orgName)}</span>
<span>Stand: ${today} | Version ${escHtml(orgHeader.loeschkonzeptVersion)}</span>
</div>
</body>
</html>`
return html
}
// =============================================================================
// INTERNAL HELPERS
// =============================================================================
function escHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function formatDateDE(dateStr: string | null | undefined): string {
if (!dateStr) return '-'
try {
const date = new Date(dateStr)
if (isNaN(date.getTime())) return '-'
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
} catch {
return '-'
}
}

View File

@@ -1,6 +1,6 @@
// =============================================================================
// Loeschfristen Module - Profiling Wizard
// 4-Step Profiling (16 Fragen) zur Generierung von Baseline-Loeschrichtlinien
// 4-Step Profiling (15 Fragen) zur Generierung von Baseline-Loeschrichtlinien
// =============================================================================
import type { LoeschfristPolicy, StorageLocation } from './loeschfristen-types'
@@ -42,7 +42,7 @@ export interface ProfilingResult {
}
// =============================================================================
// PROFILING STEPS (4 Steps, 16 Questions)
// PROFILING STEPS (4 Steps, 15 Questions)
// =============================================================================
export const PROFILING_STEPS: ProfilingStep[] = [
@@ -163,7 +163,7 @@ export const PROFILING_STEPS: ProfilingStep[] = [
},
// =========================================================================
// Step 3: Systeme (4 Fragen)
// Step 3: Systeme (3 Fragen)
// =========================================================================
{
id: 'systems',
@@ -194,14 +194,6 @@ export const PROFILING_STEPS: ProfilingStep[] = [
type: 'boolean',
required: true,
},
{
id: 'sys-zutritt',
step: 'systems',
question: 'Nutzen Sie ein Zutrittskontrollsystem?',
helpText: 'Zutrittskontrollsysteme erzeugen Protokolle, die personenbezogene Daten enthalten und einer Loeschfrist unterliegen.',
type: 'boolean',
required: true,
},
],
},
@@ -348,7 +340,6 @@ export function generatePoliciesFromProfile(answers: ProfilingAnswer[]): Profili
matchedTemplateIds.add('zeiterfassung')
matchedTemplateIds.add('bewerbungsunterlagen')
matchedTemplateIds.add('krankmeldungen')
matchedTemplateIds.add('schulungsnachweise')
}
// -------------------------------------------------------------------------
@@ -367,8 +358,6 @@ export function generatePoliciesFromProfile(answers: ProfilingAnswer[]): Profili
matchedTemplateIds.add('vertraege')
matchedTemplateIds.add('geschaeftsbriefe')
matchedTemplateIds.add('kundenstammdaten')
matchedTemplateIds.add('kundenreklamationen')
matchedTemplateIds.add('lieferantenbewertungen')
}
// -------------------------------------------------------------------------
@@ -378,7 +367,6 @@ export function generatePoliciesFromProfile(answers: ProfilingAnswer[]): Profili
matchedTemplateIds.add('newsletter-einwilligungen')
matchedTemplateIds.add('crm-kontakthistorie')
matchedTemplateIds.add('cookie-consent-logs')
matchedTemplateIds.add('social-media-daten')
}
// -------------------------------------------------------------------------
@@ -396,20 +384,6 @@ export function generatePoliciesFromProfile(answers: ProfilingAnswer[]): Profili
matchedTemplateIds.add('cookie-consent-logs')
}
// -------------------------------------------------------------------------
// Cloud (sys-cloud = true) → E-Mail-Archivierung
// -------------------------------------------------------------------------
if (getBool('sys-cloud')) {
matchedTemplateIds.add('email-archivierung')
}
// -------------------------------------------------------------------------
// Zutritt (sys-zutritt = true)
// -------------------------------------------------------------------------
if (getBool('sys-zutritt')) {
matchedTemplateIds.add('zutrittsprotokolle')
}
// -------------------------------------------------------------------------
// ERP/CRM (sys-erp = true)
// -------------------------------------------------------------------------
@@ -431,7 +405,6 @@ export function generatePoliciesFromProfile(answers: ProfilingAnswer[]): Profili
if (getBool('special-gesundheit')) {
// Ensure krankmeldungen is included even without full HR data
matchedTemplateIds.add('krankmeldungen')
matchedTemplateIds.add('betriebsarzt-doku')
}
// -------------------------------------------------------------------------

View File

@@ -91,7 +91,6 @@ export interface LoeschfristPolicy {
responsiblePerson: string
releaseProcess: string
linkedVVTActivityIds: string[]
linkedVendorIds: string[]
// Status & Review
status: PolicyStatus
lastReviewDate: string
@@ -273,7 +272,6 @@ export function createEmptyPolicy(): LoeschfristPolicy {
responsiblePerson: '',
releaseProcess: '',
linkedVVTActivityIds: [],
linkedVendorIds: [],
status: 'DRAFT',
lastReviewDate: now,
nextReviewDate: nextYear.toISOString(),

View File

@@ -1,395 +0,0 @@
// =============================================================================
// Obligations Module - Compliance Check Engine
// Prueft Pflichten auf Vollstaendigkeit, Konsistenz und Auditfaehigkeit
// =============================================================================
// =============================================================================
// TYPES
// =============================================================================
export interface Obligation {
id: string
title: string
description: string
source: string
source_article: string
deadline: string | null
status: 'pending' | 'in-progress' | 'completed' | 'overdue'
priority: 'critical' | 'high' | 'medium' | 'low'
responsible: string
linked_systems: string[]
linked_vendor_ids?: string[]
assessment_id?: string
rule_code?: string
notes?: string
created_at?: string
updated_at?: string
evidence?: string[]
review_date?: string
category?: string
}
export type ObligationComplianceIssueType =
| 'MISSING_RESPONSIBLE'
| 'OVERDUE_DEADLINE'
| 'MISSING_EVIDENCE'
| 'MISSING_DESCRIPTION'
| 'NO_LEGAL_REFERENCE'
| 'INCOMPLETE_REGULATION'
| 'HIGH_PRIORITY_NOT_STARTED'
| 'STALE_PENDING'
| 'MISSING_LINKED_SYSTEMS'
| 'NO_REVIEW_PROCESS'
| 'CRITICAL_WITHOUT_EVIDENCE'
| 'MISSING_VENDOR_LINK'
export type ObligationComplianceIssueSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'
export interface ObligationComplianceIssue {
type: ObligationComplianceIssueType
severity: ObligationComplianceIssueSeverity
message: string
affectedObligations: string[]
recommendation: string
}
export interface ObligationComplianceCheckResult {
score: number
issues: ObligationComplianceIssue[]
summary: { total: number; critical: number; high: number; medium: number; low: number }
checkedAt: string
}
// =============================================================================
// CONSTANTS
// =============================================================================
export const OBLIGATION_SEVERITY_LABELS_DE: Record<ObligationComplianceIssueSeverity, string> = {
CRITICAL: 'Kritisch',
HIGH: 'Hoch',
MEDIUM: 'Mittel',
LOW: 'Niedrig',
}
export const OBLIGATION_SEVERITY_COLORS: Record<ObligationComplianceIssueSeverity, string> = {
CRITICAL: '#dc2626',
HIGH: '#ea580c',
MEDIUM: '#d97706',
LOW: '#6b7280',
}
// =============================================================================
// HELPERS
// =============================================================================
function daysBetween(date: Date, now: Date): number {
const diffMs = now.getTime() - date.getTime()
return Math.floor(diffMs / (1000 * 60 * 60 * 24))
}
// =============================================================================
// PER-OBLIGATION CHECKS (1-5, 9, 11)
// =============================================================================
/**
* Check 1: MISSING_RESPONSIBLE (MEDIUM)
* Pflicht ohne verantwortliche Person/Abteilung.
*/
function checkMissingResponsible(obligations: Obligation[]): ObligationComplianceIssue | null {
const affected = obligations.filter(o => !o.responsible || o.responsible.trim() === '')
if (affected.length === 0) return null
return {
type: 'MISSING_RESPONSIBLE',
severity: 'MEDIUM',
message: `${affected.length} Pflicht(en) ohne verantwortliche Person oder Abteilung. Ohne klare Zustaendigkeit koennen Pflichten nicht zuverlaessig umgesetzt werden.`,
affectedObligations: affected.map(o => o.id),
recommendation: 'Weisen Sie jeder Pflicht eine verantwortliche Person oder Abteilung zu.',
}
}
/**
* Check 2: OVERDUE_DEADLINE (HIGH)
* Pflicht mit Deadline in der Vergangenheit + Status != completed.
*/
function checkOverdueDeadline(obligations: Obligation[]): ObligationComplianceIssue | null {
const now = new Date()
const affected = obligations.filter(o => {
if (!o.deadline || o.status === 'completed') return false
return new Date(o.deadline) < now
})
if (affected.length === 0) return null
return {
type: 'OVERDUE_DEADLINE',
severity: 'HIGH',
message: `${affected.length} Pflicht(en) mit ueberschrittener Frist. Ueberfaellige Pflichten stellen ein Compliance-Risiko dar und koennen zu Bussgeldern fuehren.`,
affectedObligations: affected.map(o => o.id),
recommendation: 'Bearbeiten Sie ueberfaellige Pflichten umgehend oder passen Sie die Fristen an.',
}
}
/**
* Check 3: MISSING_EVIDENCE (HIGH)
* Completed-Pflicht ohne Evidence.
*/
function checkMissingEvidence(obligations: Obligation[]): ObligationComplianceIssue | null {
const affected = obligations.filter(o =>
o.status === 'completed' && (!o.evidence || o.evidence.length === 0)
)
if (affected.length === 0) return null
return {
type: 'MISSING_EVIDENCE',
severity: 'HIGH',
message: `${affected.length} abgeschlossene Pflicht(en) ohne Nachweis. Ohne Nachweise ist die Erfuellung im Audit nicht belegbar.`,
affectedObligations: affected.map(o => o.id),
recommendation: 'Hinterlegen Sie Nachweisdokumente fuer alle abgeschlossenen Pflichten.',
}
}
/**
* Check 4: MISSING_DESCRIPTION (MEDIUM)
* Pflicht ohne Beschreibung.
*/
function checkMissingDescription(obligations: Obligation[]): ObligationComplianceIssue | null {
const affected = obligations.filter(o => !o.description || o.description.trim() === '')
if (affected.length === 0) return null
return {
type: 'MISSING_DESCRIPTION',
severity: 'MEDIUM',
message: `${affected.length} Pflicht(en) ohne Beschreibung. Eine fehlende Beschreibung erschwert die Nachvollziehbarkeit und Umsetzung.`,
affectedObligations: affected.map(o => o.id),
recommendation: 'Ergaenzen Sie eine Beschreibung fuer jede Pflicht, die den Inhalt und die Anforderungen erlaeutert.',
}
}
/**
* Check 5: NO_LEGAL_REFERENCE (HIGH)
* Pflicht ohne source_article (kein Artikel-Bezug).
*/
function checkNoLegalReference(obligations: Obligation[]): ObligationComplianceIssue | null {
const affected = obligations.filter(o => !o.source_article || o.source_article.trim() === '')
if (affected.length === 0) return null
return {
type: 'NO_LEGAL_REFERENCE',
severity: 'HIGH',
message: `${affected.length} Pflicht(en) ohne Artikel-/Paragraphen-Referenz. Ohne Rechtsbezug ist die Pflicht im Audit nicht nachvollziehbar.`,
affectedObligations: affected.map(o => o.id),
recommendation: 'Ergaenzen Sie die Rechtsgrundlage (z.B. Art. 32 DSGVO) fuer jede Pflicht.',
}
}
/**
* Check 9: MISSING_LINKED_SYSTEMS (MEDIUM)
* Pflicht ohne verknuepfte Systeme/Verarbeitungen.
*/
function checkMissingLinkedSystems(obligations: Obligation[]): ObligationComplianceIssue | null {
const affected = obligations.filter(o => !o.linked_systems || o.linked_systems.length === 0)
if (affected.length === 0) return null
return {
type: 'MISSING_LINKED_SYSTEMS',
severity: 'MEDIUM',
message: `${affected.length} Pflicht(en) ohne verknuepfte Systeme oder Verarbeitungstaetigkeiten. Ohne Systemzuordnung fehlt der operative Bezug.`,
affectedObligations: affected.map(o => o.id),
recommendation: 'Ordnen Sie jeder Pflicht die betroffenen IT-Systeme oder Verarbeitungstaetigkeiten zu.',
}
}
/**
* Check 11: CRITICAL_WITHOUT_EVIDENCE (CRITICAL)
* Critical-Pflicht ohne Evidence.
*/
function checkCriticalWithoutEvidence(obligations: Obligation[]): ObligationComplianceIssue | null {
const affected = obligations.filter(o =>
o.priority === 'critical' && (!o.evidence || o.evidence.length === 0)
)
if (affected.length === 0) return null
return {
type: 'CRITICAL_WITHOUT_EVIDENCE',
severity: 'CRITICAL',
message: `${affected.length} kritische Pflicht(en) ohne Nachweis. Kritische Pflichten erfordern zwingend eine Dokumentation der Erfuellung.`,
affectedObligations: affected.map(o => o.id),
recommendation: 'Hinterlegen Sie umgehend Nachweise fuer alle kritischen Pflichten.',
}
}
/**
* Check 12: MISSING_VENDOR_LINK (MEDIUM)
* Art.-28-Pflicht ohne verknuepften Auftragsverarbeiter.
*/
function checkMissingVendorLink(obligations: Obligation[]): ObligationComplianceIssue | null {
const affected = obligations.filter(o =>
o.source_article?.includes('Art. 28') &&
(!o.linked_vendor_ids || o.linked_vendor_ids.length === 0)
)
if (affected.length === 0) return null
return {
type: 'MISSING_VENDOR_LINK',
severity: 'MEDIUM',
message: `${affected.length} Art.-28-Pflicht(en) ohne verknuepften Auftragsverarbeiter.`,
affectedObligations: affected.map(o => o.id),
recommendation: 'Verknuepfen Sie Art.-28-Pflichten mit den betroffenen Auftragsverarbeitern im Vendor Register.',
}
}
// =============================================================================
// AGGREGATE CHECKS (6-8, 10)
// =============================================================================
/**
* Check 6: INCOMPLETE_REGULATION (HIGH)
* Regulierung, bei der alle Pflichten pending/overdue sind.
*/
function checkIncompleteRegulation(obligations: Obligation[]): ObligationComplianceIssue | null {
const bySource = new Map<string, Obligation[]>()
for (const o of obligations) {
const src = o.source || 'Unbekannt'
if (!bySource.has(src)) bySource.set(src, [])
bySource.get(src)!.push(o)
}
const incompleteRegs: string[] = []
const affectedIds: string[] = []
for (const [source, obls] of bySource.entries()) {
if (obls.length < 2) continue // Skip single-obligation regulations
const allStalled = obls.every(o => o.status === 'pending' || o.status === 'overdue')
if (allStalled) {
incompleteRegs.push(source)
affectedIds.push(...obls.map(o => o.id))
}
}
if (incompleteRegs.length === 0) return null
return {
type: 'INCOMPLETE_REGULATION',
severity: 'HIGH',
message: `${incompleteRegs.length} Regulierung(en) vollstaendig ohne Umsetzung: ${incompleteRegs.join(', ')}. Alle Pflichten sind ausstehend oder ueberfaellig.`,
affectedObligations: affectedIds,
recommendation: 'Beginnen Sie mit der Umsetzung der wichtigsten Pflichten in den betroffenen Regulierungen.',
}
}
/**
* Check 7: HIGH_PRIORITY_NOT_STARTED (CRITICAL)
* Critical/High-Pflicht seit > 30 Tagen pending.
*/
function checkHighPriorityNotStarted(obligations: Obligation[]): ObligationComplianceIssue | null {
const now = new Date()
const affected = obligations.filter(o => {
if (o.status !== 'pending') return false
if (o.priority !== 'critical' && o.priority !== 'high') return false
if (!o.created_at) return false
return daysBetween(new Date(o.created_at), now) > 30
})
if (affected.length === 0) return null
return {
type: 'HIGH_PRIORITY_NOT_STARTED',
severity: 'CRITICAL',
message: `${affected.length} hochprioritaere Pflicht(en) seit ueber 30 Tagen nicht begonnen. Dies deutet auf organisatorische Blockaden oder fehlende Priorisierung hin.`,
affectedObligations: affected.map(o => o.id),
recommendation: 'Starten Sie umgehend mit der Bearbeitung dieser kritischen/hohen Pflichten und erstellen Sie einen Umsetzungsplan.',
}
}
/**
* Check 8: STALE_PENDING (LOW)
* Pflicht seit > 90 Tagen pending.
*/
function checkStalePending(obligations: Obligation[]): ObligationComplianceIssue | null {
const now = new Date()
const affected = obligations.filter(o => {
if (o.status !== 'pending') return false
if (!o.created_at) return false
return daysBetween(new Date(o.created_at), now) > 90
})
if (affected.length === 0) return null
return {
type: 'STALE_PENDING',
severity: 'LOW',
message: `${affected.length} Pflicht(en) seit ueber 90 Tagen ausstehend. Langfristig unbearbeitete Pflichten sollten priorisiert oder als nicht relevant markiert werden.`,
affectedObligations: affected.map(o => o.id),
recommendation: 'Pruefen Sie, ob die Pflichten weiterhin relevant sind, und setzen Sie Prioritaeten fuer die Umsetzung.',
}
}
/**
* Check 10: NO_REVIEW_PROCESS (MEDIUM)
* Keine einzige Pflicht hat review_date.
*/
function checkNoReviewProcess(obligations: Obligation[]): ObligationComplianceIssue | null {
if (obligations.length === 0) return null
const hasAnyReview = obligations.some(o => o.review_date)
if (hasAnyReview) return null
return {
type: 'NO_REVIEW_PROCESS',
severity: 'MEDIUM',
message: 'Keine Pflicht hat ein Pruefungsdatum (review_date). Ohne regelmaessige Ueberpruefung ist die Aktualitaet des Pflichtenregisters nicht gewaehrleistet.',
affectedObligations: [],
recommendation: 'Fuehren Sie ein Pruefintervall ein und setzen Sie review_date fuer alle Pflichten.',
}
}
// =============================================================================
// MAIN COMPLIANCE CHECK
// =============================================================================
/**
* Fuehrt einen vollstaendigen Compliance-Check ueber alle Pflichten durch.
*/
export function runObligationComplianceCheck(obligations: Obligation[]): ObligationComplianceCheckResult {
const issues: ObligationComplianceIssue[] = []
const checks = [
checkMissingResponsible(obligations),
checkOverdueDeadline(obligations),
checkMissingEvidence(obligations),
checkMissingDescription(obligations),
checkNoLegalReference(obligations),
checkIncompleteRegulation(obligations),
checkHighPriorityNotStarted(obligations),
checkStalePending(obligations),
checkMissingLinkedSystems(obligations),
checkNoReviewProcess(obligations),
checkCriticalWithoutEvidence(obligations),
checkMissingVendorLink(obligations),
]
for (const issue of checks) {
if (issue !== null) {
issues.push(issue)
}
}
// Calculate score
const summary = { total: issues.length, critical: 0, high: 0, medium: 0, low: 0 }
for (const issue of issues) {
switch (issue.severity) {
case 'CRITICAL': summary.critical++; break
case 'HIGH': summary.high++; break
case 'MEDIUM': summary.medium++; break
case 'LOW': summary.low++; break
}
}
const rawScore = 100 - (summary.critical * 15 + summary.high * 10 + summary.medium * 5 + summary.low * 2)
const score = Math.max(0, rawScore)
return {
score,
issues,
summary,
checkedAt: new Date().toISOString(),
}
}

View File

@@ -1,915 +0,0 @@
// =============================================================================
// Obligations Module - Pflichtenregister Document Generator
// Generates a printable, audit-ready HTML document for the obligation register
// =============================================================================
import type { Obligation, ObligationComplianceCheckResult, ObligationComplianceIssueSeverity } from './obligations-compliance'
import { OBLIGATION_SEVERITY_LABELS_DE, OBLIGATION_SEVERITY_COLORS } from './obligations-compliance'
// =============================================================================
// TYPES
// =============================================================================
export interface ObligationDocumentOrgHeader {
organizationName: string
industry: string
dpoName: string
dpoContact: string
responsiblePerson: string
legalDepartment: string
documentVersion: string
lastReviewDate: string
nextReviewDate: string
reviewInterval: string
}
export interface ObligationDocumentRevision {
version: string
date: string
author: string
changes: string
}
// =============================================================================
// DEFAULTS
// =============================================================================
export function createDefaultObligationDocumentOrgHeader(): ObligationDocumentOrgHeader {
const now = new Date()
const nextYear = new Date()
nextYear.setFullYear(nextYear.getFullYear() + 1)
return {
organizationName: '',
industry: '',
dpoName: '',
dpoContact: '',
responsiblePerson: '',
legalDepartment: '',
documentVersion: '1.0',
lastReviewDate: now.toISOString().split('T')[0],
nextReviewDate: nextYear.toISOString().split('T')[0],
reviewInterval: 'Jaehrlich',
}
}
// =============================================================================
// STATUS & PRIORITY LABELS
// =============================================================================
const STATUS_LABELS_DE: Record<string, string> = {
'pending': 'Ausstehend',
'in-progress': 'In Bearbeitung',
'completed': 'Abgeschlossen',
'overdue': 'Ueberfaellig',
}
const STATUS_BADGE_CLASSES: Record<string, string> = {
'pending': 'badge-draft',
'in-progress': 'badge-review',
'completed': 'badge-active',
'overdue': 'badge-critical',
}
const PRIORITY_LABELS_DE: Record<string, string> = {
critical: 'Kritisch',
high: 'Hoch',
medium: 'Mittel',
low: 'Niedrig',
}
const PRIORITY_BADGE_CLASSES: Record<string, string> = {
critical: 'badge-critical',
high: 'badge-high',
medium: 'badge-medium',
low: 'badge-low',
}
// =============================================================================
// HTML DOCUMENT BUILDER
// =============================================================================
export function buildObligationDocumentHtml(
obligations: Obligation[],
orgHeader: ObligationDocumentOrgHeader,
complianceResult: ObligationComplianceCheckResult | null,
revisions: ObligationDocumentRevision[]
): string {
const today = new Date().toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
const orgName = orgHeader.organizationName || 'Organisation'
// Group obligations by source (regulation)
const bySource = new Map<string, Obligation[]>()
for (const o of obligations) {
const src = o.source || 'Sonstig'
if (!bySource.has(src)) bySource.set(src, [])
bySource.get(src)!.push(o)
}
// Build role map
const roleMap = new Map<string, Obligation[]>()
for (const o of obligations) {
const role = o.responsible || 'Nicht zugewiesen'
if (!roleMap.has(role)) roleMap.set(role, [])
roleMap.get(role)!.push(o)
}
// Distinct sources
const distinctSources = Array.from(bySource.keys()).sort()
// =========================================================================
// HTML Template
// =========================================================================
let html = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Pflichtenregister — ${escHtml(orgName)}</title>
<style>
@page { size: A4; margin: 20mm 18mm 22mm 18mm; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 10pt;
line-height: 1.5;
color: #1e293b;
}
/* Cover */
.cover {
min-height: 90vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
page-break-after: always;
}
.cover h1 {
font-size: 28pt;
color: #5b21b6;
margin-bottom: 8px;
font-weight: 700;
}
.cover .subtitle {
font-size: 14pt;
color: #7c3aed;
margin-bottom: 40px;
}
.cover .org-info {
background: #f5f3ff;
border: 1px solid #ddd6fe;
border-radius: 8px;
padding: 24px 40px;
text-align: left;
width: 400px;
margin-bottom: 24px;
}
.cover .org-info div { margin-bottom: 6px; }
.cover .org-info .label { font-weight: 600; color: #5b21b6; display: inline-block; min-width: 160px; }
.cover .legal-ref {
font-size: 9pt;
color: #64748b;
margin-top: 20px;
}
/* TOC */
.toc {
page-break-after: always;
padding-top: 40px;
}
.toc h2 {
font-size: 18pt;
color: #5b21b6;
margin-bottom: 20px;
border-bottom: 2px solid #5b21b6;
padding-bottom: 8px;
}
.toc-entry {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px dotted #cbd5e1;
font-size: 10pt;
}
.toc-entry .toc-num { font-weight: 600; color: #5b21b6; min-width: 40px; }
/* Sections */
.section {
page-break-inside: avoid;
margin-bottom: 24px;
}
.section-header {
font-size: 14pt;
color: #5b21b6;
font-weight: 700;
margin: 30px 0 12px 0;
border-bottom: 2px solid #ddd6fe;
padding-bottom: 6px;
}
.section-body { margin-bottom: 16px; }
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin: 10px 0 16px 0;
font-size: 9pt;
}
th, td {
border: 1px solid #e2e8f0;
padding: 6px 8px;
text-align: left;
vertical-align: top;
}
th {
background: #f5f3ff;
color: #5b21b6;
font-weight: 600;
font-size: 8.5pt;
text-transform: uppercase;
letter-spacing: 0.3px;
}
tr:nth-child(even) td { background: #faf5ff; }
/* Detail cards */
.policy-detail {
page-break-inside: avoid;
border: 1px solid #e2e8f0;
border-radius: 6px;
margin-bottom: 16px;
overflow: hidden;
}
.policy-detail-header {
background: #f5f3ff;
padding: 8px 12px;
font-weight: 700;
color: #5b21b6;
border-bottom: 1px solid #ddd6fe;
display: flex;
justify-content: space-between;
}
.policy-detail-body { padding: 0; }
.policy-detail-body table { margin: 0; }
.policy-detail-body th { width: 200px; }
/* Badges */
.badge {
display: inline-block;
padding: 1px 8px;
border-radius: 9999px;
font-size: 8pt;
font-weight: 600;
}
.badge-active { background: #dcfce7; color: #166534; }
.badge-draft { background: #f3f4f6; color: #374151; }
.badge-review { background: #fef9c3; color: #854d0e; }
.badge-critical { background: #fecaca; color: #991b1b; }
.badge-high { background: #fed7aa; color: #9a3412; }
.badge-medium { background: #fef3c7; color: #92400e; }
.badge-low { background: #f3f4f6; color: #4b5563; }
/* Principles */
.principle {
margin-bottom: 10px;
padding-left: 20px;
position: relative;
}
.principle::before {
content: '';
position: absolute;
left: 0;
top: 6px;
width: 10px;
height: 10px;
background: #7c3aed;
border-radius: 50%;
}
.principle strong { color: #5b21b6; }
/* Score */
.score-box {
display: inline-block;
padding: 4px 16px;
border-radius: 8px;
font-size: 18pt;
font-weight: 700;
margin-right: 12px;
}
.score-excellent { background: #dcfce7; color: #166534; }
.score-good { background: #dbeafe; color: #1e40af; }
.score-needs-work { background: #fef3c7; color: #92400e; }
.score-poor { background: #fecaca; color: #991b1b; }
/* Footer */
.page-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 8px 18mm;
font-size: 7.5pt;
color: #94a3b8;
display: flex;
justify-content: space-between;
border-top: 1px solid #e2e8f0;
}
/* Print */
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.no-print { display: none !important; }
.page-break { page-break-after: always; }
}
</style>
</head>
<body>
`
// =========================================================================
// Section 0: Cover Page
// =========================================================================
html += `
<div class="cover">
<h1>Pflichtenregister</h1>
<div class="subtitle">Regulatorische Pflichten — DSGVO, AI Act, NIS2 und weitere</div>
<div class="org-info">
<div><span class="label">Organisation:</span> ${escHtml(orgName)}</div>
${orgHeader.industry ? `<div><span class="label">Branche:</span> ${escHtml(orgHeader.industry)}</div>` : ''}
${orgHeader.dpoName ? `<div><span class="label">DSB:</span> ${escHtml(orgHeader.dpoName)}</div>` : ''}
${orgHeader.dpoContact ? `<div><span class="label">DSB-Kontakt:</span> ${escHtml(orgHeader.dpoContact)}</div>` : ''}
${orgHeader.responsiblePerson ? `<div><span class="label">Verantwortlicher:</span> ${escHtml(orgHeader.responsiblePerson)}</div>` : ''}
${orgHeader.legalDepartment ? `<div><span class="label">Rechtsabteilung:</span> ${escHtml(orgHeader.legalDepartment)}</div>` : ''}
</div>
<div class="legal-ref">
Version ${escHtml(orgHeader.documentVersion)} | Stand: ${today}<br/>
Letzte Pruefung: ${formatDateDE(orgHeader.lastReviewDate)} | Naechste Pruefung: ${formatDateDE(orgHeader.nextReviewDate)}<br/>
Pruefintervall: ${escHtml(orgHeader.reviewInterval)}
</div>
</div>
`
// =========================================================================
// Table of Contents
// =========================================================================
const sections = [
'Ziel und Zweck',
'Geltungsbereich',
'Methodik',
'Regulatorische Grundlagen',
'Pflichtenuebersicht',
'Detaillierte Pflichten',
'Verantwortlichkeiten',
'Fristen und Termine',
'Nachweisverzeichnis',
'Compliance-Status',
'Aenderungshistorie',
]
html += `
<div class="toc">
<h2>Inhaltsverzeichnis</h2>
${sections.map((s, i) => `<div class="toc-entry"><span><span class="toc-num">${i + 1}.</span> ${escHtml(s)}</span></div>`).join('\n ')}
</div>
`
// =========================================================================
// Section 1: Ziel und Zweck
// =========================================================================
html += `
<div class="section">
<div class="section-header">1. Ziel und Zweck</div>
<div class="section-body">
<p>Dieses Pflichtenregister dokumentiert alle regulatorischen Pflichten, denen
<strong>${escHtml(orgName)}</strong> unterliegt. Es dient der systematischen Erfassung,
Ueberwachung und Nachverfolgung aller Compliance-Anforderungen aus den anwendbaren
Regulierungen.</p>
<p style="margin-top: 8px;">Das Register erfuellt folgende Zwecke:</p>
<ul style="margin: 8px 0 8px 24px;">
<li>Vollstaendige Erfassung aller anwendbaren regulatorischen Pflichten</li>
<li>Zuordnung von Verantwortlichkeiten und Fristen</li>
<li>Nachverfolgung des Umsetzungsstatus</li>
<li>Dokumentation von Nachweisen fuer Audits</li>
<li>Identifikation von Compliance-Luecken und Handlungsbedarf</li>
</ul>
<table>
<tr><th>Rechtsrahmen</th><th>Relevanz</th></tr>
<tr><td><strong>DSGVO (EU) 2016/679</strong></td><td>Datenschutz-Grundverordnung — Kernregulierung fuer personenbezogene Daten</td></tr>
<tr><td><strong>AI Act (EU) 2024/1689</strong></td><td>KI-Verordnung — Anforderungen an KI-Systeme nach Risikoklasse</td></tr>
<tr><td><strong>NIS2 (EU) 2022/2555</strong></td><td>Netzwerk- und Informationssicherheit — Cybersicherheitspflichten</td></tr>
<tr><td><strong>BDSG</strong></td><td>Bundesdatenschutzgesetz — Nationale Ergaenzung zur DSGVO</td></tr>
</table>
</div>
</div>
`
// =========================================================================
// Section 2: Geltungsbereich
// =========================================================================
html += `
<div class="section">
<div class="section-header">2. Geltungsbereich</div>
<div class="section-body">
<p>Dieses Pflichtenregister gilt fuer alle Geschaeftsprozesse und IT-Systeme von
<strong>${escHtml(orgName)}</strong>${orgHeader.industry ? ` (Branche: ${escHtml(orgHeader.industry)})` : ''}.</p>
<p style="margin-top: 8px;">Anwendbare Regulierungen:</p>
<table>
<tr><th>Regulierung</th><th>Anzahl Pflichten</th><th>Status</th></tr>
`
for (const [source, obls] of bySource.entries()) {
const completed = obls.filter(o => o.status === 'completed').length
const pct = obls.length > 0 ? Math.round((completed / obls.length) * 100) : 0
html += ` <tr>
<td>${escHtml(source)}</td>
<td>${obls.length}</td>
<td>${completed}/${obls.length} abgeschlossen (${pct}%)</td>
</tr>
`
}
html += ` </table>
<p>Insgesamt umfasst dieses Register <strong>${obligations.length}</strong> Pflichten aus
<strong>${distinctSources.length}</strong> Regulierungen.</p>
</div>
</div>
`
// =========================================================================
// Section 3: Methodik
// =========================================================================
html += `
<div class="section">
<div class="section-header">3. Methodik</div>
<div class="section-body">
<p>Die Identifikation und Bewertung der Pflichten erfolgt in drei Schritten:</p>
<div class="principle"><strong>Pflicht-Identifikation:</strong> Systematische Analyse aller anwendbaren Regulierungen und Extraktion der einzelnen Pflichten mit Artikel-Referenz, Beschreibung und Zielgruppe.</div>
<div class="principle"><strong>Bewertung und Priorisierung:</strong> Jede Pflicht wird nach Prioritaet (kritisch, hoch, mittel, niedrig) und Dringlichkeit (Frist) bewertet. Die Bewertung basiert auf dem Risikopotenzial bei Nichterfuellung.</div>
<div class="principle"><strong>Ueberwachung und Nachverfolgung:</strong> Regelmaessige Pruefung des Umsetzungsstatus, Aktualisierung der Fristen und Dokumentation von Nachweisen.</div>
<p style="margin-top: 12px;">Die Pflichten werden ueber einen automatisierten Compliance-Check geprueft, der
11 Kriterien umfasst (siehe Abschnitt 10: Compliance-Status).</p>
</div>
</div>
`
// =========================================================================
// Section 4: Regulatorische Grundlagen
// =========================================================================
html += `
<div class="section page-break">
<div class="section-header">4. Regulatorische Grundlagen</div>
<div class="section-body">
<p>Die folgende Tabelle zeigt die regulatorischen Grundlagen mit Artikelzahl und Umsetzungsstatus:</p>
<table>
<tr>
<th>Regulierung</th>
<th>Pflichten</th>
<th>Kritisch</th>
<th>Hoch</th>
<th>Mittel</th>
<th>Niedrig</th>
<th>Abgeschlossen</th>
</tr>
`
for (const [source, obls] of bySource.entries()) {
const critical = obls.filter(o => o.priority === 'critical').length
const high = obls.filter(o => o.priority === 'high').length
const medium = obls.filter(o => o.priority === 'medium').length
const low = obls.filter(o => o.priority === 'low').length
const completed = obls.filter(o => o.status === 'completed').length
html += ` <tr>
<td><strong>${escHtml(source)}</strong></td>
<td>${obls.length}</td>
<td>${critical}</td>
<td>${high}</td>
<td>${medium}</td>
<td>${low}</td>
<td>${completed}</td>
</tr>
`
}
// Totals row
const totalCritical = obligations.filter(o => o.priority === 'critical').length
const totalHigh = obligations.filter(o => o.priority === 'high').length
const totalMedium = obligations.filter(o => o.priority === 'medium').length
const totalLow = obligations.filter(o => o.priority === 'low').length
const totalCompleted = obligations.filter(o => o.status === 'completed').length
html += ` <tr style="font-weight: 700; background: #f5f3ff;">
<td>Gesamt</td>
<td>${obligations.length}</td>
<td>${totalCritical}</td>
<td>${totalHigh}</td>
<td>${totalMedium}</td>
<td>${totalLow}</td>
<td>${totalCompleted}</td>
</tr>
`
html += ` </table>
</div>
</div>
`
// =========================================================================
// Section 5: Pflichtenuebersicht
// =========================================================================
html += `
<div class="section">
<div class="section-header">5. Pflichtenuebersicht</div>
<div class="section-body">
<p>Uebersicht aller ${obligations.length} Pflichten nach Regulierung und Status:</p>
<table>
<tr>
<th>Regulierung</th>
<th>Gesamt</th>
<th>Ausstehend</th>
<th>In Bearbeitung</th>
<th>Abgeschlossen</th>
<th>Ueberfaellig</th>
</tr>
`
for (const [source, obls] of bySource.entries()) {
const pending = obls.filter(o => o.status === 'pending').length
const inProgress = obls.filter(o => o.status === 'in-progress').length
const completed = obls.filter(o => o.status === 'completed').length
const overdue = obls.filter(o => o.status === 'overdue').length
html += ` <tr>
<td>${escHtml(source)}</td>
<td>${obls.length}</td>
<td>${pending}</td>
<td>${inProgress}</td>
<td>${completed}</td>
<td>${overdue > 0 ? `<span class="badge badge-critical">${overdue}</span>` : '0'}</td>
</tr>
`
}
html += ` </table>
</div>
</div>
`
// =========================================================================
// Section 6: Detaillierte Pflichten
// =========================================================================
html += `
<div class="section">
<div class="section-header">6. Detaillierte Pflichten</div>
<div class="section-body">
`
for (const [source, obls] of bySource.entries()) {
// Sort by priority (critical first) then by title
const priorityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 }
const sorted = [...obls].sort((a, b) => {
const pa = priorityOrder[a.priority] ?? 2
const pb = priorityOrder[b.priority] ?? 2
if (pa !== pb) return pa - pb
return a.title.localeCompare(b.title)
})
html += ` <h3 style="color: #5b21b6; margin: 20px 0 10px 0; font-size: 11pt;">${escHtml(source)} <span style="font-weight: 400; font-size: 9pt; color: #64748b;">(${sorted.length} Pflichten)</span></h3>
`
for (const o of sorted) {
const statusLabel = STATUS_LABELS_DE[o.status] || o.status
const statusBadge = STATUS_BADGE_CLASSES[o.status] || 'badge-draft'
const priorityLabel = PRIORITY_LABELS_DE[o.priority] || o.priority
const priorityBadge = PRIORITY_BADGE_CLASSES[o.priority] || 'badge-draft'
const deadlineStr = o.deadline ? formatDateDE(o.deadline) : '—'
const evidenceStr = o.evidence && o.evidence.length > 0
? o.evidence.map(e => escHtml(e)).join(', ')
: '<em style="color: #d97706;">Kein Nachweis</em>'
const systemsStr = o.linked_systems && o.linked_systems.length > 0
? o.linked_systems.map(s => escHtml(s)).join(', ')
: '—'
html += `
<div class="policy-detail">
<div class="policy-detail-header">
<span>${escHtml(o.title)}</span>
<span class="badge ${statusBadge}">${escHtml(statusLabel)}</span>
</div>
<div class="policy-detail-body">
<table>
<tr><th>Rechtsquelle</th><td>${escHtml(o.source)} ${escHtml(o.source_article || '')}</td></tr>
<tr><th>Beschreibung</th><td>${escHtml(o.description || '—')}</td></tr>
<tr><th>Prioritaet</th><td><span class="badge ${priorityBadge}">${escHtml(priorityLabel)}</span></td></tr>
<tr><th>Status</th><td><span class="badge ${statusBadge}">${escHtml(statusLabel)}</span></td></tr>
<tr><th>Verantwortlich</th><td>${escHtml(o.responsible || '—')}</td></tr>
<tr><th>Frist</th><td>${deadlineStr}</td></tr>
<tr><th>Nachweise</th><td>${evidenceStr}</td></tr>
<tr><th>Betroffene Systeme</th><td>${systemsStr}</td></tr>
${o.linked_vendor_ids && o.linked_vendor_ids.length > 0 ? `<tr><th>Auftragsverarbeiter</th><td>${o.linked_vendor_ids.map(id => escHtml(id)).join(', ')}</td></tr>` : ''}
${o.notes ? `<tr><th>Notizen</th><td>${escHtml(o.notes)}</td></tr>` : ''}
</table>
</div>
</div>
`
}
}
html += ` </div>
</div>
`
// =========================================================================
// Section 7: Verantwortlichkeiten
// =========================================================================
html += `
<div class="section page-break">
<div class="section-header">7. Verantwortlichkeiten</div>
<div class="section-body">
<p>Die folgende Rollenmatrix zeigt, welche Personen oder Abteilungen fuer welche Pflichten
die Umsetzungsverantwortung tragen:</p>
<table>
<tr><th>Verantwortlich</th><th>Pflichten</th><th>Anzahl</th><th>Davon offen</th></tr>
`
for (const [role, obls] of roleMap.entries()) {
const openCount = obls.filter(o => o.status !== 'completed').length
const titles = obls.slice(0, 5).map(o => escHtml(o.title))
const suffix = obls.length > 5 ? `, ... (+${obls.length - 5})` : ''
html += ` <tr>
<td>${escHtml(role)}</td>
<td>${titles.join('; ')}${suffix}</td>
<td>${obls.length}</td>
<td>${openCount}</td>
</tr>
`
}
html += ` </table>
</div>
</div>
`
// =========================================================================
// Section 8: Fristen und Termine
// =========================================================================
const now = new Date()
const withDeadline = obligations
.filter(o => o.deadline && o.status !== 'completed')
.sort((a, b) => new Date(a.deadline!).getTime() - new Date(b.deadline!).getTime())
const overdue = withDeadline.filter(o => new Date(o.deadline!) < now)
const upcoming = withDeadline.filter(o => new Date(o.deadline!) >= now)
html += `
<div class="section">
<div class="section-header">8. Fristen und Termine</div>
<div class="section-body">
`
if (overdue.length > 0) {
html += ` <h4 style="color: #dc2626; margin-bottom: 8px;">Ueberfaellige Pflichten (${overdue.length})</h4>
<table>
<tr><th>Pflicht</th><th>Regulierung</th><th>Frist</th><th>Tage ueberfaellig</th><th>Prioritaet</th></tr>
`
for (const o of overdue) {
const days = daysBetween(new Date(o.deadline!), now)
html += ` <tr>
<td>${escHtml(o.title)}</td>
<td>${escHtml(o.source)}</td>
<td>${formatDateDE(o.deadline)}</td>
<td><span class="badge badge-critical">${days} Tage</span></td>
<td><span class="badge ${PRIORITY_BADGE_CLASSES[o.priority] || 'badge-draft'}">${escHtml(PRIORITY_LABELS_DE[o.priority] || o.priority)}</span></td>
</tr>
`
}
html += ` </table>
`
}
if (upcoming.length > 0) {
html += ` <h4 style="color: #5b21b6; margin: 16px 0 8px 0;">Anstehende Fristen (${upcoming.length})</h4>
<table>
<tr><th>Pflicht</th><th>Regulierung</th><th>Frist</th><th>Verbleibend</th><th>Verantwortlich</th></tr>
`
for (const o of upcoming.slice(0, 20)) {
const days = daysBetween(now, new Date(o.deadline!))
html += ` <tr>
<td>${escHtml(o.title)}</td>
<td>${escHtml(o.source)}</td>
<td>${formatDateDE(o.deadline)}</td>
<td>${days} Tage</td>
<td>${escHtml(o.responsible || '—')}</td>
</tr>
`
}
if (upcoming.length > 20) {
html += ` <tr><td colspan="5" style="text-align: center; color: #64748b;">... und ${upcoming.length - 20} weitere</td></tr>
`
}
html += ` </table>
`
}
if (withDeadline.length === 0) {
html += ` <p><em>Keine offenen Pflichten mit Fristen vorhanden.</em></p>
`
}
html += ` </div>
</div>
`
// =========================================================================
// Section 9: Nachweisverzeichnis
// =========================================================================
const withEvidence = obligations.filter(o => o.evidence && o.evidence.length > 0)
const withoutEvidence = obligations.filter(o => !o.evidence || o.evidence.length === 0)
html += `
<div class="section page-break">
<div class="section-header">9. Nachweisverzeichnis</div>
<div class="section-body">
<p>${withEvidence.length} von ${obligations.length} Pflichten haben Nachweise hinterlegt.</p>
`
if (withEvidence.length > 0) {
html += ` <table>
<tr><th>Pflicht</th><th>Regulierung</th><th>Nachweise</th><th>Status</th></tr>
`
for (const o of withEvidence) {
html += ` <tr>
<td>${escHtml(o.title)}</td>
<td>${escHtml(o.source)}</td>
<td>${o.evidence!.map(e => escHtml(e)).join(', ')}</td>
<td><span class="badge ${STATUS_BADGE_CLASSES[o.status] || 'badge-draft'}">${escHtml(STATUS_LABELS_DE[o.status] || o.status)}</span></td>
</tr>
`
}
html += ` </table>
`
}
if (withoutEvidence.length > 0) {
html += ` <p style="margin-top: 12px;"><strong>Pflichten ohne Nachweise (${withoutEvidence.length}):</strong></p>
<ul style="margin: 4px 0 8px 24px; font-size: 9pt; color: #d97706;">
`
for (const o of withoutEvidence.slice(0, 15)) {
html += ` <li>${escHtml(o.title)} (${escHtml(o.source)})</li>
`
}
if (withoutEvidence.length > 15) {
html += ` <li>... und ${withoutEvidence.length - 15} weitere</li>
`
}
html += ` </ul>
`
}
html += ` </div>
</div>
`
// =========================================================================
// Section 10: Compliance-Status
// =========================================================================
html += `
<div class="section">
<div class="section-header">10. Compliance-Status</div>
<div class="section-body">
`
if (complianceResult) {
const scoreClass = complianceResult.score >= 90 ? 'score-excellent'
: complianceResult.score >= 75 ? 'score-good'
: complianceResult.score >= 50 ? 'score-needs-work'
: 'score-poor'
const scoreLabel = complianceResult.score >= 90 ? 'Ausgezeichnet'
: complianceResult.score >= 75 ? 'Gut'
: complianceResult.score >= 50 ? 'Verbesserungswuerdig'
: 'Mangelhaft'
html += ` <p><span class="score-box ${scoreClass}">${complianceResult.score}/100</span> ${escHtml(scoreLabel)}</p>
<table style="margin-top: 12px;">
<tr><th>Kennzahl</th><th>Wert</th></tr>
<tr><td>Geprueft am</td><td>${formatDateDE(complianceResult.checkedAt)}</td></tr>
<tr><td>Befunde gesamt</td><td>${complianceResult.summary.total}</td></tr>
<tr><td>Kritisch</td><td>${complianceResult.summary.critical}</td></tr>
<tr><td>Hoch</td><td>${complianceResult.summary.high}</td></tr>
<tr><td>Mittel</td><td>${complianceResult.summary.medium}</td></tr>
<tr><td>Niedrig</td><td>${complianceResult.summary.low}</td></tr>
</table>
`
if (complianceResult.issues.length > 0) {
html += ` <p style="margin-top: 12px;"><strong>Befunde nach Schweregrad:</strong></p>
<table>
<tr><th>Schweregrad</th><th>Befund</th><th>Betroffene Pflichten</th><th>Empfehlung</th></tr>
`
const severityOrder: ObligationComplianceIssueSeverity[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
for (const sev of severityOrder) {
const issuesForSev = complianceResult.issues.filter(i => i.severity === sev)
for (const issue of issuesForSev) {
html += ` <tr>
<td><span class="badge badge-${sev.toLowerCase()}" style="color: ${OBLIGATION_SEVERITY_COLORS[sev]}">${OBLIGATION_SEVERITY_LABELS_DE[sev]}</span></td>
<td>${escHtml(issue.message)}</td>
<td>${issue.affectedObligations.length > 0 ? issue.affectedObligations.length + ' Pflicht(en)' : '—'}</td>
<td>${escHtml(issue.recommendation)}</td>
</tr>
`
}
}
html += ` </table>
`
} else {
html += ` <p style="margin-top: 8px;"><em>Keine Beanstandungen. Alle Pflichten sind konform.</em></p>
`
}
} else {
html += ` <p><em>Compliance-Check wurde noch nicht ausgefuehrt. Fuehren Sie den Check im
Pflichtenregister-Tab durch, um den Status in das Dokument aufzunehmen.</em></p>
`
}
html += ` </div>
</div>
`
// =========================================================================
// Section 11: Aenderungshistorie
// =========================================================================
html += `
<div class="section">
<div class="section-header">11. Aenderungshistorie</div>
<div class="section-body">
<table>
<tr><th>Version</th><th>Datum</th><th>Autor</th><th>Aenderungen</th></tr>
`
if (revisions.length > 0) {
for (const rev of revisions) {
html += ` <tr>
<td>${escHtml(rev.version)}</td>
<td>${formatDateDE(rev.date)}</td>
<td>${escHtml(rev.author)}</td>
<td>${escHtml(rev.changes)}</td>
</tr>
`
}
} else {
html += ` <tr>
<td>${escHtml(orgHeader.documentVersion)}</td>
<td>${today}</td>
<td>${escHtml(orgHeader.dpoName || orgHeader.responsiblePerson || '—')}</td>
<td>Erstversion des Pflichtenregisters</td>
</tr>
`
}
html += ` </table>
</div>
</div>
`
// =========================================================================
// Footer
// =========================================================================
html += `
<div class="page-footer">
<span>Pflichtenregister — ${escHtml(orgName)}</span>
<span>Stand: ${today} | Version ${escHtml(orgHeader.documentVersion)}</span>
</div>
</body>
</html>`
return html
}
// =============================================================================
// INTERNAL HELPERS
// =============================================================================
function escHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function formatDateDE(dateStr: string | null | undefined): string {
if (!dateStr) return '—'
try {
const date = new Date(dateStr)
if (isNaN(date.getTime())) return '—'
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
} catch {
return '—'
}
}
function daysBetween(earlier: Date, later: Date): number {
const diffMs = later.getTime() - earlier.getTime()
return Math.floor(diffMs / (1000 * 60 * 60 * 24))
}

View File

@@ -1,553 +0,0 @@
// =============================================================================
// TOM Module - Compliance Check Engine
// Prueft Technische und Organisatorische Massnahmen auf Vollstaendigkeit,
// Konsistenz und DSGVO-Konformitaet (Art. 32 DSGVO)
// =============================================================================
import type {
TOMGeneratorState,
DerivedTOM,
RiskProfile,
DataProfile,
ControlCategory,
ImplementationStatus,
} from './tom-generator/types'
import { getControlById, getControlsByCategory, getAllCategories } from './tom-generator/controls/loader'
import { SDM_CATEGORY_MAPPING } from './tom-generator/types'
// =============================================================================
// TYPES
// =============================================================================
export type TOMComplianceIssueSeverity = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
export type TOMComplianceIssueType =
| 'MISSING_RESPONSIBLE'
| 'OVERDUE_REVIEW'
| 'MISSING_EVIDENCE'
| 'INCOMPLETE_CATEGORY'
| 'NO_ENCRYPTION_MEASURES'
| 'NO_PSEUDONYMIZATION'
| 'MISSING_AVAILABILITY'
| 'NO_REVIEW_PROCESS'
| 'UNCOVERED_SDM_GOAL'
| 'HIGH_RISK_WITHOUT_MEASURES'
| 'STALE_NOT_IMPLEMENTED'
export interface TOMComplianceIssue {
id: string
controlId: string
controlName: string
type: TOMComplianceIssueType
severity: TOMComplianceIssueSeverity
title: string
description: string
recommendation: string
}
export interface TOMComplianceCheckResult {
issues: TOMComplianceIssue[]
score: number // 0-100
stats: {
total: number
passed: number
failed: number
bySeverity: Record<TOMComplianceIssueSeverity, number>
}
}
// =============================================================================
// CONSTANTS
// =============================================================================
export const TOM_SEVERITY_LABELS_DE: Record<TOMComplianceIssueSeverity, string> = {
CRITICAL: 'Kritisch',
HIGH: 'Hoch',
MEDIUM: 'Mittel',
LOW: 'Niedrig',
}
export const TOM_SEVERITY_COLORS: Record<TOMComplianceIssueSeverity, string> = {
CRITICAL: '#dc2626',
HIGH: '#ea580c',
MEDIUM: '#d97706',
LOW: '#6b7280',
}
// =============================================================================
// HELPERS
// =============================================================================
let issueCounter = 0
function createIssueId(): string {
issueCounter++
return `TCI-${Date.now()}-${String(issueCounter).padStart(4, '0')}`
}
function createIssue(
controlId: string,
controlName: string,
type: TOMComplianceIssueType,
severity: TOMComplianceIssueSeverity,
title: string,
description: string,
recommendation: string
): TOMComplianceIssue {
return { id: createIssueId(), controlId, controlName, type, severity, title, description, recommendation }
}
function daysBetween(date: Date, now: Date): number {
const diffMs = now.getTime() - date.getTime()
return Math.floor(diffMs / (1000 * 60 * 60 * 24))
}
// =============================================================================
// PER-TOM CHECKS (1-3, 11)
// =============================================================================
/**
* Check 1: MISSING_RESPONSIBLE (MEDIUM)
* REQUIRED TOM without responsiblePerson AND responsibleDepartment.
*/
function checkMissingResponsible(tom: DerivedTOM): TOMComplianceIssue | null {
if (tom.applicability !== 'REQUIRED') return null
if (!tom.responsiblePerson && !tom.responsibleDepartment) {
return createIssue(
tom.controlId,
tom.name,
'MISSING_RESPONSIBLE',
'MEDIUM',
'Keine verantwortliche Person/Abteilung',
`Die TOM "${tom.name}" ist als REQUIRED eingestuft, hat aber weder eine verantwortliche Person noch eine verantwortliche Abteilung zugewiesen. Ohne klare Verantwortlichkeit kann die Massnahme nicht zuverlaessig umgesetzt und gepflegt werden.`,
'Weisen Sie eine verantwortliche Person oder Abteilung zu, die fuer die Umsetzung und regelmaessige Pruefung dieser Massnahme zustaendig ist.'
)
}
return null
}
/**
* Check 2: OVERDUE_REVIEW (MEDIUM)
* TOM with reviewDate in the past.
*/
function checkOverdueReview(tom: DerivedTOM): TOMComplianceIssue | null {
if (!tom.reviewDate) return null
const reviewDate = new Date(tom.reviewDate)
const now = new Date()
if (reviewDate < now) {
const overdueDays = daysBetween(reviewDate, now)
return createIssue(
tom.controlId,
tom.name,
'OVERDUE_REVIEW',
'MEDIUM',
'Ueberfaellige Pruefung',
`Die TOM "${tom.name}" haette am ${reviewDate.toLocaleDateString('de-DE')} geprueft werden muessen. Die Pruefung ist ${overdueDays} Tag(e) ueberfaellig. Gemaess Art. 32 Abs. 1 lit. d DSGVO ist eine regelmaessige Ueberpruefung der Wirksamkeit von TOMs erforderlich.`,
'Fuehren Sie umgehend eine Wirksamkeitspruefung dieser Massnahme durch und aktualisieren Sie das naechste Pruefungsdatum.'
)
}
return null
}
/**
* Check 3: MISSING_EVIDENCE (HIGH)
* IMPLEMENTED TOM where linkedEvidence is empty but the control has evidenceRequirements.
*/
function checkMissingEvidence(tom: DerivedTOM): TOMComplianceIssue | null {
if (tom.implementationStatus !== 'IMPLEMENTED') return null
if (tom.linkedEvidence.length > 0) return null
const control = getControlById(tom.controlId)
if (!control || control.evidenceRequirements.length === 0) return null
return createIssue(
tom.controlId,
tom.name,
'MISSING_EVIDENCE',
'HIGH',
'Kein Nachweis hinterlegt',
`Die TOM "${tom.name}" ist als IMPLEMENTED markiert, hat aber keine verknuepften Nachweisdokumente. Der Control erfordert ${control.evidenceRequirements.length} Nachweis(e): ${control.evidenceRequirements.join(', ')}. Ohne Nachweise ist die Umsetzung nicht auditfaehig.`,
'Laden Sie die erforderlichen Nachweisdokumente hoch und verknuepfen Sie sie mit dieser Massnahme.'
)
}
/**
* Check 11: STALE_NOT_IMPLEMENTED (LOW)
* REQUIRED TOM that has been NOT_IMPLEMENTED for >90 days.
* Uses implementationDate === null and state.createdAt / state.updatedAt as reference.
*/
function checkStaleNotImplemented(tom: DerivedTOM, state: TOMGeneratorState): TOMComplianceIssue | null {
if (tom.applicability !== 'REQUIRED') return null
if (tom.implementationStatus !== 'NOT_IMPLEMENTED') return null
if (tom.implementationDate !== null) return null
const referenceDate = state.createdAt ? new Date(state.createdAt) : (state.updatedAt ? new Date(state.updatedAt) : null)
if (!referenceDate) return null
const ageInDays = daysBetween(referenceDate, new Date())
if (ageInDays <= 90) return null
return createIssue(
tom.controlId,
tom.name,
'STALE_NOT_IMPLEMENTED',
'LOW',
'Langfristig nicht umgesetzte Pflichtmassnahme',
`Die TOM "${tom.name}" ist als REQUIRED eingestuft, aber seit ${ageInDays} Tagen nicht umgesetzt. Pflichtmassnahmen, die laenger als 90 Tage nicht implementiert werden, deuten auf organisatorische Blockaden oder unzureichende Priorisierung hin.`,
'Pruefen Sie, ob die Massnahme weiterhin erforderlich ist, und erstellen Sie einen konkreten Umsetzungsplan mit Verantwortlichkeiten und Fristen.'
)
}
// =============================================================================
// AGGREGATE CHECKS (4-10)
// =============================================================================
/**
* Check 4: INCOMPLETE_CATEGORY (HIGH)
* Category where ALL applicable (REQUIRED) controls are NOT_IMPLEMENTED.
*/
function checkIncompleteCategory(toms: DerivedTOM[]): TOMComplianceIssue[] {
const issues: TOMComplianceIssue[] = []
// Group applicable TOMs by category
const categoryMap = new Map<ControlCategory, DerivedTOM[]>()
for (const tom of toms) {
const control = getControlById(tom.controlId)
if (!control) continue
const category = control.category
if (!categoryMap.has(category)) {
categoryMap.set(category, [])
}
categoryMap.get(category)!.push(tom)
}
for (const [category, categoryToms] of Array.from(categoryMap.entries())) {
// Only check categories that have at least one REQUIRED control
const requiredToms = categoryToms.filter((t: DerivedTOM) => t.applicability === 'REQUIRED')
if (requiredToms.length === 0) continue
const allNotImplemented = requiredToms.every((t: DerivedTOM) => t.implementationStatus === 'NOT_IMPLEMENTED')
if (allNotImplemented) {
issues.push(
createIssue(
category,
category,
'INCOMPLETE_CATEGORY',
'HIGH',
`Kategorie "${category}" vollstaendig ohne Umsetzung`,
`Alle ${requiredToms.length} Pflichtmassnahme(n) in der Kategorie "${category}" sind nicht umgesetzt. Eine vollstaendig unabgedeckte Kategorie stellt eine erhebliche Luecke im TOM-Konzept dar.`,
`Setzen Sie mindestens die wichtigsten Massnahmen in der Kategorie "${category}" um, um eine Grundabdeckung sicherzustellen.`
)
)
}
}
return issues
}
/**
* Check 5: NO_ENCRYPTION_MEASURES (CRITICAL)
* No ENCRYPTION control with status IMPLEMENTED.
*/
function checkNoEncryption(toms: DerivedTOM[]): TOMComplianceIssue | null {
const hasImplementedEncryption = toms.some((tom) => {
const control = getControlById(tom.controlId)
return control?.category === 'ENCRYPTION' && tom.implementationStatus === 'IMPLEMENTED'
})
if (!hasImplementedEncryption) {
return createIssue(
'ENCRYPTION',
'Verschluesselung',
'NO_ENCRYPTION_MEASURES',
'CRITICAL',
'Keine Verschluesselungsmassnahmen umgesetzt',
'Es ist keine einzige Verschluesselungsmassnahme als IMPLEMENTED markiert. Art. 32 Abs. 1 lit. a DSGVO nennt Verschluesselung explizit als geeignete technische Massnahme. Ohne Verschluesselung sind personenbezogene Daten bei Zugriff oder Verlust ungeschuetzt.',
'Implementieren Sie umgehend Verschluesselungsmassnahmen fuer Daten im Ruhezustand (Encryption at Rest) und waehrend der Uebertragung (Encryption in Transit).'
)
}
return null
}
/**
* Check 6: NO_PSEUDONYMIZATION (MEDIUM)
* DataProfile has special categories (Art. 9) but no PSEUDONYMIZATION control implemented.
*/
function checkNoPseudonymization(toms: DerivedTOM[], dataProfile: DataProfile | null): TOMComplianceIssue | null {
if (!dataProfile || !dataProfile.hasSpecialCategories) return null
const hasImplementedPseudonymization = toms.some((tom) => {
const control = getControlById(tom.controlId)
return control?.category === 'PSEUDONYMIZATION' && tom.implementationStatus === 'IMPLEMENTED'
})
if (!hasImplementedPseudonymization) {
return createIssue(
'PSEUDONYMIZATION',
'Pseudonymisierung',
'NO_PSEUDONYMIZATION',
'MEDIUM',
'Keine Pseudonymisierung bei besonderen Datenkategorien',
'Das Datenprofil enthaelt besondere Kategorien personenbezogener Daten (Art. 9 DSGVO), aber keine Pseudonymisierungsmassnahme ist umgesetzt. Art. 32 Abs. 1 lit. a DSGVO empfiehlt Pseudonymisierung ausdruecklich als Schutzmassnahme.',
'Implementieren Sie Pseudonymisierungsmassnahmen fuer die Verarbeitung besonderer Datenkategorien, um das Risiko fuer betroffene Personen zu minimieren.'
)
}
return null
}
/**
* Check 7: MISSING_AVAILABILITY (HIGH)
* No AVAILABILITY or RECOVERY control implemented AND no DR plan in securityProfile.
*/
function checkMissingAvailability(toms: DerivedTOM[], state: TOMGeneratorState): TOMComplianceIssue | null {
const hasAvailabilityOrRecovery = toms.some((tom) => {
const control = getControlById(tom.controlId)
return (
(control?.category === 'AVAILABILITY' || control?.category === 'RECOVERY') &&
tom.implementationStatus === 'IMPLEMENTED'
)
})
const hasDRPlan = state.securityProfile?.hasDRPlan ?? false
if (!hasAvailabilityOrRecovery && !hasDRPlan) {
return createIssue(
'AVAILABILITY',
'Verfuegbarkeit / Wiederherstellbarkeit',
'MISSING_AVAILABILITY',
'HIGH',
'Keine Verfuegbarkeits- oder Wiederherstellungsmassnahmen',
'Weder Verfuegbarkeits- noch Wiederherstellungsmassnahmen sind umgesetzt, und es existiert kein Disaster-Recovery-Plan im Security-Profil. Art. 32 Abs. 1 lit. b und c DSGVO verlangen die Faehigkeit zur raschen Wiederherstellung der Verfuegbarkeit personenbezogener Daten.',
'Implementieren Sie Backup-Konzepte, Redundanzloesungen und einen Disaster-Recovery-Plan, um die Verfuegbarkeit und Wiederherstellbarkeit sicherzustellen.'
)
}
return null
}
/**
* Check 8: NO_REVIEW_PROCESS (MEDIUM)
* No REVIEW control implemented (Art. 32 Abs. 1 lit. d requires periodic review).
*/
function checkNoReviewProcess(toms: DerivedTOM[]): TOMComplianceIssue | null {
const hasImplementedReview = toms.some((tom) => {
const control = getControlById(tom.controlId)
return control?.category === 'REVIEW' && tom.implementationStatus === 'IMPLEMENTED'
})
if (!hasImplementedReview) {
return createIssue(
'REVIEW',
'Ueberpruefung & Bewertung',
'NO_REVIEW_PROCESS',
'MEDIUM',
'Kein Verfahren zur regelmaessigen Ueberpruefung',
'Es ist keine Ueberpruefungsmassnahme als IMPLEMENTED markiert. Art. 32 Abs. 1 lit. d DSGVO verlangt ein Verfahren zur regelmaessigen Ueberpruefung, Bewertung und Evaluierung der Wirksamkeit der technischen und organisatorischen Massnahmen.',
'Implementieren Sie einen regelmaessigen Review-Prozess (z.B. quartalsweise TOM-Audits, jaehrliche Wirksamkeitspruefung) und dokumentieren Sie die Ergebnisse.'
)
}
return null
}
/**
* Check 9: UNCOVERED_SDM_GOAL (HIGH)
* SDM goal with 0% coverage — no implemented control maps to it via SDM_CATEGORY_MAPPING.
*/
function checkUncoveredSDMGoal(toms: DerivedTOM[]): TOMComplianceIssue[] {
const issues: TOMComplianceIssue[] = []
// Build reverse mapping: SDM goal -> ControlCategories that cover it
const sdmGoals = [
'Verfuegbarkeit',
'Integritaet',
'Vertraulichkeit',
'Nichtverkettung',
'Intervenierbarkeit',
'Transparenz',
'Datenminimierung',
] as const
const goalToCategoriesMap = new Map<string, ControlCategory[]>()
for (const goal of sdmGoals) {
goalToCategoriesMap.set(goal, [])
}
// Build reverse lookup from SDM_CATEGORY_MAPPING
for (const [category, goals] of Object.entries(SDM_CATEGORY_MAPPING)) {
for (const goal of goals) {
const existing = goalToCategoriesMap.get(goal)
if (existing) {
existing.push(category as ControlCategory)
}
}
}
// Collect implemented categories
const implementedCategories = new Set<ControlCategory>()
for (const tom of toms) {
if (tom.implementationStatus !== 'IMPLEMENTED') continue
const control = getControlById(tom.controlId)
if (control) {
implementedCategories.add(control.category)
}
}
// Check each SDM goal
for (const goal of sdmGoals) {
const coveringCategories = goalToCategoriesMap.get(goal) ?? []
const hasCoverage = coveringCategories.some((cat) => implementedCategories.has(cat))
if (!hasCoverage) {
issues.push(
createIssue(
`SDM-${goal}`,
goal,
'UNCOVERED_SDM_GOAL',
'HIGH',
`SDM-Gewaehrleistungsziel "${goal}" nicht abgedeckt`,
`Das Gewaehrleistungsziel "${goal}" des Standard-Datenschutzmodells (SDM) ist durch keine umgesetzte Massnahme abgedeckt. Zugehoerige Kategorien (${coveringCategories.join(', ')}) haben keine IMPLEMENTED Controls. Das SDM ist die anerkannte Methodik zur Umsetzung der DSGVO-Anforderungen.`,
`Setzen Sie mindestens eine Massnahme aus den Kategorien ${coveringCategories.join(', ')} um, um das SDM-Ziel "${goal}" abzudecken.`
)
)
}
}
return issues
}
/**
* Check 10: HIGH_RISK_WITHOUT_MEASURES (CRITICAL)
* Protection level VERY_HIGH but < 50% of REQUIRED controls implemented.
*/
function checkHighRiskWithoutMeasures(toms: DerivedTOM[], riskProfile: RiskProfile | null): TOMComplianceIssue | null {
if (!riskProfile || riskProfile.protectionLevel !== 'VERY_HIGH') return null
const requiredToms = toms.filter((t) => t.applicability === 'REQUIRED')
if (requiredToms.length === 0) return null
const implementedCount = requiredToms.filter((t) => t.implementationStatus === 'IMPLEMENTED').length
const implementationRate = implementedCount / requiredToms.length
if (implementationRate < 0.5) {
const percentage = Math.round(implementationRate * 100)
return createIssue(
'RISK-PROFILE',
'Risikoprofil VERY_HIGH',
'HIGH_RISK_WITHOUT_MEASURES',
'CRITICAL',
'Sehr hoher Schutzbedarf bei niedriger Umsetzungsrate',
`Der Schutzbedarf ist als VERY_HIGH eingestuft, aber nur ${implementedCount} von ${requiredToms.length} Pflichtmassnahmen (${percentage}%) sind umgesetzt. Bei sehr hohem Schutzbedarf muessen mindestens 50% der Pflichtmassnahmen implementiert sein, um ein angemessenes Schutzniveau gemaess Art. 32 DSGVO zu gewaehrleisten.`,
'Priorisieren Sie die Umsetzung der verbleibenden Pflichtmassnahmen. Beginnen Sie mit CRITICAL- und HIGH-Priority Controls. Erwaeegen Sie einen Umsetzungsplan mit klaren Meilensteinen.'
)
}
return null
}
// =============================================================================
// MAIN COMPLIANCE CHECK
// =============================================================================
/**
* Fuehrt einen vollstaendigen Compliance-Check ueber alle TOMs durch.
*
* @param state - Der vollstaendige TOMGeneratorState
* @returns TOMComplianceCheckResult mit Issues, Score und Statistiken
*/
export function runTOMComplianceCheck(state: TOMGeneratorState): TOMComplianceCheckResult {
// Reset counter for deterministic IDs within a single check run
issueCounter = 0
const issues: TOMComplianceIssue[] = []
// Filter to applicable TOMs only (REQUIRED or RECOMMENDED, exclude NOT_APPLICABLE)
const applicableTOMs = state.derivedTOMs.filter(
(tom) => tom.applicability === 'REQUIRED' || tom.applicability === 'RECOMMENDED'
)
// Run per-TOM checks (1-3, 11) on each applicable TOM
for (const tom of applicableTOMs) {
const perTomChecks = [
checkMissingResponsible(tom),
checkOverdueReview(tom),
checkMissingEvidence(tom),
checkStaleNotImplemented(tom, state),
]
for (const issue of perTomChecks) {
if (issue !== null) {
issues.push(issue)
}
}
}
// Run aggregate checks (4-10)
issues.push(...checkIncompleteCategory(applicableTOMs))
const aggregateChecks = [
checkNoEncryption(applicableTOMs),
checkNoPseudonymization(applicableTOMs, state.dataProfile),
checkMissingAvailability(applicableTOMs, state),
checkNoReviewProcess(applicableTOMs),
checkHighRiskWithoutMeasures(applicableTOMs, state.riskProfile),
]
for (const issue of aggregateChecks) {
if (issue !== null) {
issues.push(issue)
}
}
issues.push(...checkUncoveredSDMGoal(applicableTOMs))
// Calculate score
const bySeverity: Record<TOMComplianceIssueSeverity, number> = {
LOW: 0,
MEDIUM: 0,
HIGH: 0,
CRITICAL: 0,
}
for (const issue of issues) {
bySeverity[issue.severity]++
}
const rawScore =
100 -
(bySeverity.CRITICAL * 15 +
bySeverity.HIGH * 10 +
bySeverity.MEDIUM * 5 +
bySeverity.LOW * 2)
const score = Math.max(0, rawScore)
// Calculate pass/fail per TOM
const failedControlIds = new Set(
issues.filter((i) => !i.controlId.startsWith('SDM-') && i.controlId !== 'RISK-PROFILE').map((i) => i.controlId)
)
const totalTOMs = applicableTOMs.length
const failedCount = failedControlIds.size
const passedCount = Math.max(0, totalTOMs - failedCount)
return {
issues,
score,
stats: {
total: totalTOMs,
passed: passedCount,
failed: failedCount,
bySeverity,
},
}
}

View File

@@ -1,906 +0,0 @@
// =============================================================================
// TOM Module - TOM-Dokumentation Document Generator
// Generates a printable, audit-ready HTML document according to DSGVO Art. 32
// =============================================================================
import type {
TOMGeneratorState,
DerivedTOM,
CompanyProfile,
RiskProfile,
ControlCategory,
} from './tom-generator/types'
import { SDM_CATEGORY_MAPPING } from './tom-generator/types'
import {
getControlById,
getControlsByCategory,
getAllCategories,
getCategoryMetadata,
} from './tom-generator/controls/loader'
import type { TOMComplianceCheckResult, TOMComplianceIssueSeverity } from './tom-compliance'
// =============================================================================
// TYPES
// =============================================================================
export interface TOMDocumentOrgHeader {
organizationName: string
industry: string
dpoName: string
dpoContact: string
responsiblePerson: string
itSecurityContact: string
locations: string[]
employeeCount: string
documentVersion: string
lastReviewDate: string
nextReviewDate: string
reviewInterval: string
}
export interface TOMDocumentRevision {
version: string
date: string
author: string
changes: string
}
// =============================================================================
// DEFAULTS
// =============================================================================
export function createDefaultTOMDocumentOrgHeader(): TOMDocumentOrgHeader {
const now = new Date()
const nextYear = new Date()
nextYear.setFullYear(nextYear.getFullYear() + 1)
return {
organizationName: '',
industry: '',
dpoName: '',
dpoContact: '',
responsiblePerson: '',
itSecurityContact: '',
locations: [],
employeeCount: '',
documentVersion: '1.0',
lastReviewDate: now.toISOString().split('T')[0],
nextReviewDate: nextYear.toISOString().split('T')[0],
reviewInterval: 'Jaehrlich',
}
}
// =============================================================================
// SEVERITY LABELS (for Compliance Status section)
// =============================================================================
const SEVERITY_LABELS_DE: Record<TOMComplianceIssueSeverity, string> = {
CRITICAL: 'Kritisch',
HIGH: 'Hoch',
MEDIUM: 'Mittel',
LOW: 'Niedrig',
}
const SEVERITY_COLORS: Record<TOMComplianceIssueSeverity, string> = {
CRITICAL: '#dc2626',
HIGH: '#ea580c',
MEDIUM: '#d97706',
LOW: '#6b7280',
}
// =============================================================================
// CATEGORY LABELS (German)
// =============================================================================
const CATEGORY_LABELS_DE: Record<ControlCategory, string> = {
ACCESS_CONTROL: 'Zutrittskontrolle',
ADMISSION_CONTROL: 'Zugangskontrolle',
ACCESS_AUTHORIZATION: 'Zugriffskontrolle',
TRANSFER_CONTROL: 'Weitergabekontrolle',
INPUT_CONTROL: 'Eingabekontrolle',
ORDER_CONTROL: 'Auftragskontrolle',
AVAILABILITY: 'Verfuegbarkeit',
SEPARATION: 'Trennbarkeit',
ENCRYPTION: 'Verschluesselung',
PSEUDONYMIZATION: 'Pseudonymisierung',
RESILIENCE: 'Belastbarkeit',
RECOVERY: 'Wiederherstellbarkeit',
REVIEW: 'Ueberpruefung & Bewertung',
}
// =============================================================================
// STATUS & APPLICABILITY LABELS
// =============================================================================
const STATUS_LABELS_DE: Record<string, string> = {
IMPLEMENTED: 'Umgesetzt',
PARTIAL: 'Teilweise umgesetzt',
NOT_IMPLEMENTED: 'Nicht umgesetzt',
}
const STATUS_BADGE_CLASSES: Record<string, string> = {
IMPLEMENTED: 'badge-active',
PARTIAL: 'badge-review',
NOT_IMPLEMENTED: 'badge-critical',
}
const APPLICABILITY_LABELS_DE: Record<string, string> = {
REQUIRED: 'Erforderlich',
RECOMMENDED: 'Empfohlen',
OPTIONAL: 'Optional',
NOT_APPLICABLE: 'Nicht anwendbar',
}
// =============================================================================
// HTML DOCUMENT BUILDER
// =============================================================================
export function buildTOMDocumentHtml(
derivedTOMs: DerivedTOM[],
orgHeader: TOMDocumentOrgHeader,
companyProfile: CompanyProfile | null,
riskProfile: RiskProfile | null,
complianceResult: TOMComplianceCheckResult | null,
revisions: TOMDocumentRevision[]
): string {
const today = new Date().toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
const orgName = orgHeader.organizationName || 'Organisation'
// Filter out NOT_APPLICABLE TOMs for display
const applicableTOMs = derivedTOMs.filter(t => t.applicability !== 'NOT_APPLICABLE')
// Group TOMs by category via control library lookup
const tomsByCategory = new Map<ControlCategory, DerivedTOM[]>()
for (const tom of applicableTOMs) {
const control = getControlById(tom.controlId)
const cat = control?.category || 'REVIEW'
if (!tomsByCategory.has(cat)) tomsByCategory.set(cat, [])
tomsByCategory.get(cat)!.push(tom)
}
// Build role map: role/department → list of control codes
const roleMap = new Map<string, string[]>()
for (const tom of applicableTOMs) {
const role = tom.responsiblePerson || tom.responsibleDepartment || 'Nicht zugewiesen'
if (!roleMap.has(role)) roleMap.set(role, [])
const control = getControlById(tom.controlId)
roleMap.get(role)!.push(control?.code || tom.controlId)
}
// =========================================================================
// HTML Template
// =========================================================================
let html = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>TOM-Dokumentation — ${escHtml(orgName)}</title>
<style>
@page { size: A4; margin: 20mm 18mm 22mm 18mm; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 10pt;
line-height: 1.5;
color: #1e293b;
}
/* Cover */
.cover {
min-height: 90vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
page-break-after: always;
}
.cover h1 {
font-size: 28pt;
color: #5b21b6;
margin-bottom: 8px;
font-weight: 700;
}
.cover .subtitle {
font-size: 14pt;
color: #7c3aed;
margin-bottom: 40px;
}
.cover .org-info {
background: #f5f3ff;
border: 1px solid #ddd6fe;
border-radius: 8px;
padding: 24px 40px;
text-align: left;
width: 400px;
margin-bottom: 24px;
}
.cover .org-info div { margin-bottom: 6px; }
.cover .org-info .label { font-weight: 600; color: #5b21b6; display: inline-block; min-width: 160px; }
.cover .legal-ref {
font-size: 9pt;
color: #64748b;
margin-top: 20px;
}
/* TOC */
.toc {
page-break-after: always;
padding-top: 40px;
}
.toc h2 {
font-size: 18pt;
color: #5b21b6;
margin-bottom: 20px;
border-bottom: 2px solid #5b21b6;
padding-bottom: 8px;
}
.toc-entry {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px dotted #cbd5e1;
font-size: 10pt;
}
.toc-entry .toc-num { font-weight: 600; color: #5b21b6; min-width: 40px; }
/* Sections */
.section {
page-break-inside: avoid;
margin-bottom: 24px;
}
.section-header {
font-size: 14pt;
color: #5b21b6;
font-weight: 700;
margin: 30px 0 12px 0;
border-bottom: 2px solid #ddd6fe;
padding-bottom: 6px;
}
.section-body { margin-bottom: 16px; }
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin: 10px 0 16px 0;
font-size: 9pt;
}
th, td {
border: 1px solid #e2e8f0;
padding: 6px 8px;
text-align: left;
vertical-align: top;
}
th {
background: #f5f3ff;
color: #5b21b6;
font-weight: 600;
font-size: 8.5pt;
text-transform: uppercase;
letter-spacing: 0.3px;
}
tr:nth-child(even) td { background: #faf5ff; }
/* Detail cards */
.policy-detail {
page-break-inside: avoid;
border: 1px solid #e2e8f0;
border-radius: 6px;
margin-bottom: 16px;
overflow: hidden;
}
.policy-detail-header {
background: #f5f3ff;
padding: 8px 12px;
font-weight: 700;
color: #5b21b6;
border-bottom: 1px solid #ddd6fe;
display: flex;
justify-content: space-between;
}
.policy-detail-body { padding: 0; }
.policy-detail-body table { margin: 0; }
.policy-detail-body th { width: 200px; }
/* Badges */
.badge {
display: inline-block;
padding: 1px 8px;
border-radius: 9999px;
font-size: 8pt;
font-weight: 600;
}
.badge-active { background: #dcfce7; color: #166534; }
.badge-draft { background: #f3f4f6; color: #374151; }
.badge-review { background: #fef9c3; color: #854d0e; }
.badge-critical { background: #fecaca; color: #991b1b; }
.badge-high { background: #fed7aa; color: #9a3412; }
.badge-medium { background: #fef3c7; color: #92400e; }
.badge-low { background: #f3f4f6; color: #4b5563; }
/* Principles */
.principle {
margin-bottom: 10px;
padding-left: 20px;
position: relative;
}
.principle::before {
content: '';
position: absolute;
left: 0;
top: 6px;
width: 10px;
height: 10px;
background: #7c3aed;
border-radius: 50%;
}
.principle strong { color: #5b21b6; }
/* Score */
.score-box {
display: inline-block;
padding: 4px 16px;
border-radius: 8px;
font-size: 18pt;
font-weight: 700;
margin-right: 12px;
}
.score-excellent { background: #dcfce7; color: #166534; }
.score-good { background: #dbeafe; color: #1e40af; }
.score-needs-work { background: #fef3c7; color: #92400e; }
.score-poor { background: #fecaca; color: #991b1b; }
/* Footer */
.page-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 8px 18mm;
font-size: 7.5pt;
color: #94a3b8;
display: flex;
justify-content: space-between;
border-top: 1px solid #e2e8f0;
}
/* Print */
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.no-print { display: none !important; }
.page-break { page-break-after: always; }
}
</style>
</head>
<body>
`
// =========================================================================
// Section 0: Cover Page
// =========================================================================
html += `
<div class="cover">
<h1>TOM-Dokumentation</h1>
<div class="subtitle">Technische und Organisatorische Massnahmen gemaess Art. 32 DSGVO</div>
<div class="org-info">
<div><span class="label">Organisation:</span> ${escHtml(orgName)}</div>
${orgHeader.industry ? `<div><span class="label">Branche:</span> ${escHtml(orgHeader.industry)}</div>` : ''}
${orgHeader.dpoName ? `<div><span class="label">DSB:</span> ${escHtml(orgHeader.dpoName)}</div>` : ''}
${orgHeader.dpoContact ? `<div><span class="label">DSB-Kontakt:</span> ${escHtml(orgHeader.dpoContact)}</div>` : ''}
${orgHeader.responsiblePerson ? `<div><span class="label">Verantwortlicher:</span> ${escHtml(orgHeader.responsiblePerson)}</div>` : ''}
${orgHeader.itSecurityContact ? `<div><span class="label">IT-Sicherheit:</span> ${escHtml(orgHeader.itSecurityContact)}</div>` : ''}
${orgHeader.employeeCount ? `<div><span class="label">Mitarbeiter:</span> ${escHtml(orgHeader.employeeCount)}</div>` : ''}
${orgHeader.locations.length > 0 ? `<div><span class="label">Standorte:</span> ${escHtml(orgHeader.locations.join(', '))}</div>` : ''}
</div>
<div class="legal-ref">
Version ${escHtml(orgHeader.documentVersion)} | Stand: ${today}<br/>
Letzte Pruefung: ${formatDateDE(orgHeader.lastReviewDate)} | Naechste Pruefung: ${formatDateDE(orgHeader.nextReviewDate)}<br/>
Pruefintervall: ${escHtml(orgHeader.reviewInterval)}
</div>
</div>
`
// =========================================================================
// Table of Contents
// =========================================================================
const sections = [
'Ziel und Zweck',
'Geltungsbereich',
'Grundprinzipien Art. 32',
'Schutzbedarf und Risikoanalyse',
'Massnahmen-Uebersicht',
'Detaillierte Massnahmen',
'SDM Gewaehrleistungsziele',
'Verantwortlichkeiten',
'Pruef- und Revisionszyklus',
'Compliance-Status',
'Aenderungshistorie',
]
html += `
<div class="toc">
<h2>Inhaltsverzeichnis</h2>
${sections.map((s, i) => `<div class="toc-entry"><span><span class="toc-num">${i + 1}.</span> ${escHtml(s)}</span></div>`).join('\n ')}
</div>
`
// =========================================================================
// Section 1: Ziel und Zweck
// =========================================================================
html += `
<div class="section">
<div class="section-header">1. Ziel und Zweck</div>
<div class="section-body">
<p>Diese TOM-Dokumentation beschreibt die technischen und organisatorischen Massnahmen
zum Schutz personenbezogener Daten bei <strong>${escHtml(orgName)}</strong>. Sie dient
der Umsetzung folgender DSGVO-Anforderungen:</p>
<table>
<tr><th>Rechtsgrundlage</th><th>Inhalt</th></tr>
<tr><td><strong>Art. 32 Abs. 1 lit. a DSGVO</strong></td><td>Pseudonymisierung und Verschluesselung personenbezogener Daten</td></tr>
<tr><td><strong>Art. 32 Abs. 1 lit. b DSGVO</strong></td><td>Faehigkeit, die Vertraulichkeit, Integritaet, Verfuegbarkeit und Belastbarkeit der Systeme und Dienste im Zusammenhang mit der Verarbeitung auf Dauer sicherzustellen</td></tr>
<tr><td><strong>Art. 32 Abs. 1 lit. c DSGVO</strong></td><td>Faehigkeit, die Verfuegbarkeit der personenbezogenen Daten und den Zugang zu ihnen bei einem physischen oder technischen Zwischenfall rasch wiederherzustellen</td></tr>
<tr><td><strong>Art. 32 Abs. 1 lit. d DSGVO</strong></td><td>Verfahren zur regelmaessigen Ueberpruefung, Bewertung und Evaluierung der Wirksamkeit der technischen und organisatorischen Massnahmen</td></tr>
</table>
<p>Die TOM-Dokumentation ist fester Bestandteil des Datenschutz-Managementsystems und wird
regelmaessig ueberprueft und aktualisiert.</p>
</div>
</div>
`
// =========================================================================
// Section 2: Geltungsbereich
// =========================================================================
const industryInfo = companyProfile?.industry || orgHeader.industry || ''
const hostingInfo = companyProfile ? `Unternehmen: ${escHtml(companyProfile.name || orgName)}, Groesse: ${escHtml(companyProfile.size || '-')}` : ''
html += `
<div class="section">
<div class="section-header">2. Geltungsbereich</div>
<div class="section-body">
<p>Diese TOM-Dokumentation gilt fuer alle IT-Systeme, Anwendungen und Verarbeitungsprozesse
von <strong>${escHtml(orgName)}</strong>${industryInfo ? ` (Branche: ${escHtml(industryInfo)})` : ''}.</p>
${hostingInfo ? `<p>${hostingInfo}</p>` : ''}
${orgHeader.locations.length > 0 ? `<p>Standorte: ${escHtml(orgHeader.locations.join(', '))}</p>` : ''}
<p>Die dokumentierten Massnahmen stammen aus zwei Quellen:</p>
<ul style="margin: 8px 0 8px 24px;">
<li><strong>Embedded Library (TOM-xxx):</strong> Integrierte Kontrollbibliothek mit spezifischen Massnahmen fuer Art. 32 DSGVO</li>
<li><strong>Canonical Control Library (CP-CLIB):</strong> Uebergreifende Kontrollbibliothek mit framework-uebergreifenden Massnahmen</li>
</ul>
<p>Insgesamt umfasst dieses Dokument <strong>${applicableTOMs.length}</strong> anwendbare Massnahmen
in <strong>${tomsByCategory.size}</strong> Kategorien.</p>
</div>
</div>
`
// =========================================================================
// Section 3: Grundprinzipien Art. 32
// =========================================================================
html += `
<div class="section">
<div class="section-header">3. Grundprinzipien Art. 32</div>
<div class="section-body">
<div class="principle"><strong>Vertraulichkeit:</strong> Schutz personenbezogener Daten vor unbefugter Kenntnisnahme durch Zutrittskontrolle, Zugangskontrolle, Zugriffskontrolle und Verschluesselung (Art. 32 Abs. 1 lit. b DSGVO).</div>
<div class="principle"><strong>Integritaet:</strong> Sicherstellung, dass personenbezogene Daten nicht unbefugt oder unbeabsichtigt veraendert werden koennen, durch Eingabekontrolle, Weitergabekontrolle und Protokollierung (Art. 32 Abs. 1 lit. b DSGVO).</div>
<div class="principle"><strong>Verfuegbarkeit und Belastbarkeit:</strong> Gewaehrleistung, dass Systeme und Dienste bei Lastspitzen und Stoerungen zuverlaessig funktionieren, durch Backup, Redundanz und Disaster Recovery (Art. 32 Abs. 1 lit. b DSGVO).</div>
<div class="principle"><strong>Rasche Wiederherstellbarkeit:</strong> Faehigkeit, nach einem physischen oder technischen Zwischenfall Daten und Systeme schnell wiederherzustellen, durch getestete Recovery-Prozesse (Art. 32 Abs. 1 lit. c DSGVO).</div>
<div class="principle"><strong>Regelmaessige Wirksamkeitspruefung:</strong> Verfahren zur regelmaessigen Ueberpruefung, Bewertung und Evaluierung der Wirksamkeit aller technischen und organisatorischen Massnahmen (Art. 32 Abs. 1 lit. d DSGVO).</div>
</div>
</div>
`
// =========================================================================
// Section 4: Schutzbedarf und Risikoanalyse
// =========================================================================
html += `
<div class="section">
<div class="section-header">4. Schutzbedarf und Risikoanalyse</div>
<div class="section-body">
`
if (riskProfile) {
html += ` <p>Die folgende Schutzbedarfsanalyse bildet die Grundlage fuer die Auswahl und Priorisierung
der technischen und organisatorischen Massnahmen:</p>
<table>
<tr><th>Kriterium</th><th>Bewertung</th></tr>
<tr><td>Vertraulichkeit</td><td>${riskProfile.ciaAssessment.confidentiality}/5</td></tr>
<tr><td>Integritaet</td><td>${riskProfile.ciaAssessment.integrity}/5</td></tr>
<tr><td>Verfuegbarkeit</td><td>${riskProfile.ciaAssessment.availability}/5</td></tr>
<tr><td>Schutzniveau</td><td><strong>${escHtml(riskProfile.protectionLevel)}</strong></td></tr>
<tr><td>DSFA-Pflicht</td><td>${riskProfile.dsfaRequired ? 'Ja' : 'Nein'}</td></tr>
${riskProfile.specialRisks.length > 0 ? `<tr><td>Spezialrisiken</td><td>${escHtml(riskProfile.specialRisks.join(', '))}</td></tr>` : ''}
${riskProfile.regulatoryRequirements.length > 0 ? `<tr><td>Regulatorische Anforderungen</td><td>${escHtml(riskProfile.regulatoryRequirements.join(', '))}</td></tr>` : ''}
</table>
`
} else {
html += ` <p><em>Die Schutzbedarfsanalyse wurde noch nicht durchgefuehrt. Fuehren Sie den
Risiko-Wizard im TOM-Generator durch, um den Schutzbedarf zu ermitteln.</em></p>
`
}
html += ` </div>
</div>
`
// =========================================================================
// Section 5: Massnahmen-Uebersicht
// =========================================================================
html += `
<div class="section page-break">
<div class="section-header">5. Massnahmen-Uebersicht</div>
<div class="section-body">
<p>Die folgende Tabelle zeigt eine Uebersicht aller ${applicableTOMs.length} anwendbaren Massnahmen
nach Kategorie:</p>
<table>
<tr>
<th>Kategorie</th>
<th>Gesamt</th>
<th>Umgesetzt</th>
<th>Teilweise</th>
<th>Offen</th>
</tr>
`
const allCategories = getAllCategories()
for (const cat of allCategories) {
const tomsInCat = tomsByCategory.get(cat)
if (!tomsInCat || tomsInCat.length === 0) continue
const implemented = tomsInCat.filter(t => t.implementationStatus === 'IMPLEMENTED').length
const partial = tomsInCat.filter(t => t.implementationStatus === 'PARTIAL').length
const notImpl = tomsInCat.filter(t => t.implementationStatus === 'NOT_IMPLEMENTED').length
const catLabel = CATEGORY_LABELS_DE[cat] || cat
html += ` <tr>
<td>${escHtml(catLabel)}</td>
<td>${tomsInCat.length}</td>
<td>${implemented}</td>
<td>${partial}</td>
<td>${notImpl}</td>
</tr>
`
}
html += ` </table>
</div>
</div>
`
// =========================================================================
// Section 6: Detaillierte Massnahmen
// =========================================================================
html += `
<div class="section">
<div class="section-header">6. Detaillierte Massnahmen</div>
<div class="section-body">
`
for (const cat of allCategories) {
const tomsInCat = tomsByCategory.get(cat)
if (!tomsInCat || tomsInCat.length === 0) continue
const catLabel = CATEGORY_LABELS_DE[cat] || cat
const catMeta = getCategoryMetadata(cat)
const gdprRef = catMeta?.gdprReference || ''
html += ` <h3 style="color: #5b21b6; margin: 20px 0 10px 0; font-size: 11pt;">${escHtml(catLabel)}${gdprRef ? ` <span style="font-weight: 400; font-size: 9pt; color: #64748b;">(${escHtml(gdprRef)})</span>` : ''}</h3>
`
// Sort TOMs by control code
const sortedTOMs = [...tomsInCat].sort((a, b) => {
const codeA = getControlById(a.controlId)?.code || a.controlId
const codeB = getControlById(b.controlId)?.code || b.controlId
return codeA.localeCompare(codeB)
})
for (const tom of sortedTOMs) {
const control = getControlById(tom.controlId)
const code = control?.code || tom.controlId
const nameDE = control?.name?.de || tom.name
const descDE = control?.description?.de || tom.description
const typeLabel = control?.type === 'TECHNICAL' ? 'Technisch' : control?.type === 'ORGANIZATIONAL' ? 'Organisatorisch' : '-'
const statusLabel = STATUS_LABELS_DE[tom.implementationStatus] || tom.implementationStatus
const statusBadge = STATUS_BADGE_CLASSES[tom.implementationStatus] || 'badge-draft'
const applicabilityLabel = APPLICABILITY_LABELS_DE[tom.applicability] || tom.applicability
const responsible = [tom.responsiblePerson, tom.responsibleDepartment].filter(s => s && s.trim()).join(' / ') || '-'
const implDate = tom.implementationDate ? formatDateDE(typeof tom.implementationDate === 'string' ? tom.implementationDate : tom.implementationDate.toISOString()) : '-'
const reviewDate = tom.reviewDate ? formatDateDE(typeof tom.reviewDate === 'string' ? tom.reviewDate : tom.reviewDate.toISOString()) : '-'
// Evidence
const evidenceInfo = tom.linkedEvidence.length > 0
? tom.linkedEvidence.join(', ')
: tom.evidenceGaps.length > 0
? `<em style="color: #d97706;">Fehlend: ${escHtml(tom.evidenceGaps.join(', '))}</em>`
: '-'
// Framework mappings
let mappingsHtml = '-'
if (control?.mappings && control.mappings.length > 0) {
mappingsHtml = control.mappings.map(m => `${escHtml(m.framework)}: ${escHtml(m.reference)}`).join('<br/>')
}
html += `
<div class="policy-detail">
<div class="policy-detail-header">
<span>${escHtml(code)}${escHtml(nameDE)}</span>
<span class="badge ${statusBadge}">${escHtml(statusLabel)}</span>
</div>
<div class="policy-detail-body">
<table>
<tr><th>Beschreibung</th><td>${escHtml(descDE)}</td></tr>
<tr><th>Massnahmentyp</th><td>${escHtml(typeLabel)}</td></tr>
<tr><th>Anwendbarkeit</th><td>${escHtml(applicabilityLabel)}${tom.applicabilityReason ? `${escHtml(tom.applicabilityReason)}` : ''}</td></tr>
<tr><th>Umsetzungsstatus</th><td><span class="badge ${statusBadge}">${escHtml(statusLabel)}</span></td></tr>
<tr><th>Verantwortlich</th><td>${escHtml(responsible)}</td></tr>
<tr><th>Umsetzungsdatum</th><td>${implDate}</td></tr>
<tr><th>Naechste Pruefung</th><td>${reviewDate}</td></tr>
<tr><th>Evidence</th><td>${evidenceInfo}</td></tr>
<tr><th>Framework-Mappings</th><td>${mappingsHtml}</td></tr>
</table>
</div>
</div>
`
}
}
html += ` </div>
</div>
`
// =========================================================================
// Section 7: SDM Gewaehrleistungsziele
// =========================================================================
const sdmGoals: Array<{ goal: string; categories: ControlCategory[] }> = []
const allSDMGoals = [
'Verfuegbarkeit',
'Integritaet',
'Vertraulichkeit',
'Nichtverkettung',
'Intervenierbarkeit',
'Transparenz',
'Datenminimierung',
] as const
for (const goal of allSDMGoals) {
const cats: ControlCategory[] = []
for (const [cat, goals] of Object.entries(SDM_CATEGORY_MAPPING)) {
if (goals.includes(goal)) {
cats.push(cat as ControlCategory)
}
}
sdmGoals.push({ goal, categories: cats })
}
html += `
<div class="section page-break">
<div class="section-header">7. SDM Gewaehrleistungsziele</div>
<div class="section-body">
<p>Die folgende Tabelle zeigt die Abdeckung der sieben Gewaehrleistungsziele des
Standard-Datenschutzmodells (SDM) durch die implementierten Massnahmen:</p>
<table>
<tr>
<th>Gewaehrleistungsziel</th>
<th>Abgedeckt</th>
<th>Gesamt</th>
<th>Abdeckung (%)</th>
</tr>
`
for (const { goal, categories } of sdmGoals) {
let totalInGoal = 0
let implementedInGoal = 0
for (const cat of categories) {
const tomsInCat = tomsByCategory.get(cat) || []
totalInGoal += tomsInCat.length
implementedInGoal += tomsInCat.filter(t => t.implementationStatus === 'IMPLEMENTED').length
}
const percentage = totalInGoal > 0 ? Math.round((implementedInGoal / totalInGoal) * 100) : 0
html += ` <tr>
<td>${escHtml(goal)}</td>
<td>${implementedInGoal}</td>
<td>${totalInGoal}</td>
<td>${percentage}%</td>
</tr>
`
}
html += ` </table>
</div>
</div>
`
// =========================================================================
// Section 8: Verantwortlichkeiten
// =========================================================================
html += `
<div class="section">
<div class="section-header">8. Verantwortlichkeiten</div>
<div class="section-body">
<p>Die folgende Rollenmatrix zeigt, welche Personen oder Abteilungen fuer welche Massnahmen
die Umsetzungsverantwortung tragen:</p>
<table>
<tr><th>Rolle / Verantwortlich</th><th>Massnahmen</th><th>Anzahl</th></tr>
`
for (const [role, controls] of roleMap.entries()) {
html += ` <tr>
<td>${escHtml(role)}</td>
<td>${controls.map(c => escHtml(c)).join(', ')}</td>
<td>${controls.length}</td>
</tr>
`
}
html += ` </table>
</div>
</div>
`
// =========================================================================
// Section 9: Pruef- und Revisionszyklus
// =========================================================================
html += `
<div class="section">
<div class="section-header">9. Pruef- und Revisionszyklus</div>
<div class="section-body">
<table>
<tr><th>Eigenschaft</th><th>Wert</th></tr>
<tr><td>Aktuelles Pruefintervall</td><td>${escHtml(orgHeader.reviewInterval)}</td></tr>
<tr><td>Letzte Pruefung</td><td>${formatDateDE(orgHeader.lastReviewDate)}</td></tr>
<tr><td>Naechste Pruefung</td><td>${formatDateDE(orgHeader.nextReviewDate)}</td></tr>
<tr><td>Aktuelle Version</td><td>${escHtml(orgHeader.documentVersion)}</td></tr>
</table>
<p style="margin-top: 8px;">Bei jeder Pruefung wird die TOM-Dokumentation auf folgende Punkte ueberprueft:</p>
<ul style="margin: 8px 0 8px 24px;">
<li>Vollstaendigkeit aller Massnahmen (neue Systeme oder Verarbeitungen erfasst?)</li>
<li>Aktualitaet des Umsetzungsstatus (Aenderungen seit letzter Pruefung?)</li>
<li>Wirksamkeit der technischen Massnahmen (Penetration-Tests, Audit-Ergebnisse)</li>
<li>Angemessenheit der organisatorischen Massnahmen (Schulungen, Richtlinien aktuell?)</li>
<li>Abdeckung aller SDM-Gewaehrleistungsziele</li>
<li>Zuordnung von Verantwortlichkeiten zu allen Massnahmen</li>
</ul>
</div>
</div>
`
// =========================================================================
// Section 10: Compliance-Status
// =========================================================================
html += `
<div class="section page-break">
<div class="section-header">10. Compliance-Status</div>
<div class="section-body">
`
if (complianceResult) {
const scoreClass = complianceResult.score >= 90 ? 'score-excellent'
: complianceResult.score >= 75 ? 'score-good'
: complianceResult.score >= 50 ? 'score-needs-work'
: 'score-poor'
const scoreLabel = complianceResult.score >= 90 ? 'Ausgezeichnet'
: complianceResult.score >= 75 ? 'Gut'
: complianceResult.score >= 50 ? 'Verbesserungswuerdig'
: 'Mangelhaft'
html += ` <p><span class="score-box ${scoreClass}">${complianceResult.score}/100</span> ${escHtml(scoreLabel)}</p>
<table style="margin-top: 12px;">
<tr><th>Kennzahl</th><th>Wert</th></tr>
<tr><td>Gepruefte Massnahmen</td><td>${complianceResult.stats.total}</td></tr>
<tr><td>Bestanden</td><td>${complianceResult.stats.passed}</td></tr>
<tr><td>Beanstandungen</td><td>${complianceResult.stats.failed}</td></tr>
</table>
`
if (complianceResult.issues.length > 0) {
html += ` <p style="margin-top: 12px;"><strong>Befunde nach Schweregrad:</strong></p>
<table>
<tr><th>Schweregrad</th><th>Anzahl</th><th>Befunde</th></tr>
`
const severityOrder: TOMComplianceIssueSeverity[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
for (const sev of severityOrder) {
const count = complianceResult.stats.bySeverity[sev]
if (count === 0) continue
const issuesForSev = complianceResult.issues.filter(i => i.severity === sev)
html += ` <tr>
<td><span class="badge badge-${sev.toLowerCase()}" style="color: ${SEVERITY_COLORS[sev]}">${SEVERITY_LABELS_DE[sev]}</span></td>
<td>${count}</td>
<td>${issuesForSev.map(i => escHtml(i.title)).join('; ')}</td>
</tr>
`
}
html += ` </table>
`
} else {
html += ` <p style="margin-top: 8px;"><em>Keine Beanstandungen. Alle Massnahmen sind konform.</em></p>
`
}
} else {
html += ` <p><em>Compliance-Check wurde noch nicht ausgefuehrt. Fuehren Sie den Check im
Export-Tab durch, um den Status in das Dokument aufzunehmen.</em></p>
`
}
html += ` </div>
</div>
`
// =========================================================================
// Section 11: Aenderungshistorie
// =========================================================================
html += `
<div class="section">
<div class="section-header">11. Aenderungshistorie</div>
<div class="section-body">
<table>
<tr><th>Version</th><th>Datum</th><th>Autor</th><th>Aenderungen</th></tr>
`
if (revisions.length > 0) {
for (const rev of revisions) {
html += ` <tr>
<td>${escHtml(rev.version)}</td>
<td>${formatDateDE(rev.date)}</td>
<td>${escHtml(rev.author)}</td>
<td>${escHtml(rev.changes)}</td>
</tr>
`
}
} else {
html += ` <tr>
<td>${escHtml(orgHeader.documentVersion)}</td>
<td>${today}</td>
<td>${escHtml(orgHeader.dpoName || orgHeader.responsiblePerson || '-')}</td>
<td>Erstversion der TOM-Dokumentation</td>
</tr>
`
}
html += ` </table>
</div>
</div>
`
// =========================================================================
// Footer
// =========================================================================
html += `
<div class="page-footer">
<span>TOM-Dokumentation — ${escHtml(orgName)}</span>
<span>Stand: ${today} | Version ${escHtml(orgHeader.documentVersion)}</span>
</div>
</body>
</html>`
return html
}
// =============================================================================
// INTERNAL HELPERS
// =============================================================================
function escHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function formatDateDE(dateStr: string | null | undefined): string {
if (!dateStr) return '-'
try {
const date = new Date(dateStr)
if (isNaN(date.getTime())) return '-'
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
} catch {
return '-'
}
}

View File

@@ -89,9 +89,9 @@ export interface ControlLibrary {
const CONTROL_LIBRARY_DATA: ControlLibrary = {
metadata: {
version: '1.1.0',
lastUpdated: '2026-03-19',
totalControls: 88,
version: '1.0.0',
lastUpdated: '2026-02-04',
totalControls: 60,
},
categories: new Map([
[
@@ -2353,648 +2353,6 @@ const CONTROL_LIBRARY_DATA: ControlLibrary = {
complexity: 'MEDIUM',
tags: ['training', 'security-awareness', 'phishing', 'social-engineering'],
},
// =========================================================================
// NEW CONTROLS (v1.1.0) — 25 additional measures
// =========================================================================
// ENCRYPTION — 2 new
{
id: 'TOM-ENC-04',
code: 'TOM-ENC-04',
category: 'ENCRYPTION',
type: 'TECHNICAL',
name: { de: 'Zertifikatsmanagement (TLS/SSL)', en: 'Certificate Management (TLS/SSL)' },
description: {
de: 'Systematische Verwaltung, Ueberwachung und rechtzeitige Erneuerung aller TLS/SSL-Zertifikate zur Vermeidung von Sicherheitsluecken durch abgelaufene Zertifikate.',
en: 'Systematic management, monitoring and timely renewal of all TLS/SSL certificates to prevent security gaps from expired certificates.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. a' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.10.1.2' },
{ framework: 'BSI_IT_GRUNDSCHUTZ', reference: 'CON.1' },
],
applicabilityConditions: [
{ field: 'architectureProfile.encryptionInTransit', operator: 'EQUALS', value: true, result: 'REQUIRED', priority: 25 },
],
defaultApplicability: 'RECOMMENDED',
evidenceRequirements: ['Zertifikatsinventar', 'Monitoring-Konfiguration', 'Erneuerungsprotokolle'],
reviewFrequency: 'QUARTERLY',
priority: 'HIGH',
complexity: 'MEDIUM',
tags: ['encryption', 'certificates', 'tls'],
},
{
id: 'TOM-ENC-05',
code: 'TOM-ENC-05',
category: 'ENCRYPTION',
type: 'ORGANIZATIONAL',
name: { de: 'Schluesselmanagement-Policy', en: 'Key Management Policy' },
description: {
de: 'Dokumentierte Richtlinie fuer den gesamten Lebenszyklus kryptografischer Schluessel inkl. Erzeugung, Verteilung, Speicherung, Rotation und Vernichtung.',
en: 'Documented policy for the full lifecycle of cryptographic keys including generation, distribution, storage, rotation and destruction.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. a' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.10.1.2' },
],
applicabilityConditions: [
{ field: 'architectureProfile.encryptionAtRest', operator: 'EQUALS', value: true, result: 'REQUIRED', priority: 25 },
{ field: 'dataProfile.hasSpecialCategories', operator: 'EQUALS', value: true, result: 'REQUIRED', priority: 30 },
],
defaultApplicability: 'RECOMMENDED',
evidenceRequirements: ['Schluesselmanagement-Richtlinie', 'Schluesselrotationsplan'],
reviewFrequency: 'ANNUAL',
priority: 'HIGH',
complexity: 'LOW',
tags: ['encryption', 'key-management', 'policy'],
},
// PSEUDONYMIZATION — 2 new
{
id: 'TOM-PS-03',
code: 'TOM-PS-03',
category: 'PSEUDONYMIZATION',
type: 'TECHNICAL',
name: { de: 'Anonymisierung fuer Analysezwecke', en: 'Anonymization for Analytics' },
description: {
de: 'Technische Verfahren zur irreversiblen Anonymisierung personenbezogener Daten fuer statistische Auswertungen und Analysen.',
en: 'Technical procedures for irreversible anonymization of personal data for statistical evaluations and analyses.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. a' },
{ framework: 'GDPR_ART25', reference: 'Art. 25 Abs. 1' },
],
applicabilityConditions: [
{ field: 'dataProfile.dataVolume', operator: 'IN', value: ['HIGH', 'VERY_HIGH'], result: 'RECOMMENDED', priority: 15 },
],
defaultApplicability: 'OPTIONAL',
evidenceRequirements: ['Anonymisierungsverfahren-Dokumentation', 'Re-Identifizierungs-Risikoanalyse'],
reviewFrequency: 'ANNUAL',
priority: 'MEDIUM',
complexity: 'HIGH',
tags: ['pseudonymization', 'anonymization', 'analytics'],
},
{
id: 'TOM-PS-04',
code: 'TOM-PS-04',
category: 'PSEUDONYMIZATION',
type: 'ORGANIZATIONAL',
name: { de: 'Pseudonymisierungskonzept', en: 'Pseudonymization Concept' },
description: {
de: 'Dokumentiertes Konzept fuer die Pseudonymisierung personenbezogener Daten mit Definition der Verfahren, Zustaendigkeiten und Zuordnungsregeln.',
en: 'Documented concept for pseudonymization of personal data with definition of procedures, responsibilities and mapping rules.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. a' },
{ framework: 'GDPR_ART25', reference: 'Art. 25 Abs. 1' },
{ framework: 'BSI_IT_GRUNDSCHUTZ', reference: 'CON.2' },
],
applicabilityConditions: [
{ field: 'dataProfile.hasSpecialCategories', operator: 'EQUALS', value: true, result: 'REQUIRED', priority: 25 },
],
defaultApplicability: 'RECOMMENDED',
evidenceRequirements: ['Pseudonymisierungskonzept', 'Verfahrensdokumentation'],
reviewFrequency: 'ANNUAL',
priority: 'HIGH',
complexity: 'MEDIUM',
tags: ['pseudonymization', 'concept', 'documentation'],
},
// INPUT_CONTROL — 1 new
{
id: 'TOM-IN-05',
code: 'TOM-IN-05',
category: 'INPUT_CONTROL',
type: 'TECHNICAL',
name: { de: 'Automatisierte Eingabevalidierung', en: 'Automated Input Validation' },
description: {
de: 'Technische Validierung aller Benutzereingaben zur Verhinderung von Injection-Angriffen und Sicherstellung der Datenintegritaet.',
en: 'Technical validation of all user inputs to prevent injection attacks and ensure data integrity.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. b' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.14.2.5' },
],
applicabilityConditions: [],
defaultApplicability: 'REQUIRED',
evidenceRequirements: ['Validierungsregeln-Dokumentation', 'Penetrationstest-Berichte'],
reviewFrequency: 'QUARTERLY',
priority: 'HIGH',
complexity: 'MEDIUM',
tags: ['input-validation', 'security', 'injection-prevention'],
},
// ORDER_CONTROL — 2 new
{
id: 'TOM-OR-05',
code: 'TOM-OR-05',
category: 'ORDER_CONTROL',
type: 'ORGANIZATIONAL',
name: { de: 'Auftragsverarbeiter-Monitoring', en: 'Processor Monitoring' },
description: {
de: 'Regelmaessige Ueberpruefung und Bewertung der Datenschutz-Massnahmen bei Auftragsverarbeitern gemaess Art. 28 Abs. 3 lit. h DSGVO.',
en: 'Regular review and assessment of data protection measures at processors according to Art. 28(3)(h) GDPR.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 28 Abs. 3 lit. h' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.15.2.1' },
],
applicabilityConditions: [
{ field: 'architectureProfile.hasSubprocessors', operator: 'EQUALS', value: true, result: 'REQUIRED', priority: 25 },
],
defaultApplicability: 'RECOMMENDED',
evidenceRequirements: ['Audit-Berichte der Auftragsverarbeiter', 'Monitoring-Checklisten'],
reviewFrequency: 'ANNUAL',
priority: 'HIGH',
complexity: 'MEDIUM',
tags: ['order-control', 'processor', 'monitoring'],
},
{
id: 'TOM-OR-06',
code: 'TOM-OR-06',
category: 'ORDER_CONTROL',
type: 'ORGANIZATIONAL',
name: { de: 'Sub-Processor Management', en: 'Sub-Processor Management' },
description: {
de: 'Dokumentiertes Verfahren zur Genehmigung, Ueberwachung und Dokumentation von Unterauftragsverarbeitern.',
en: 'Documented process for approval, monitoring and documentation of sub-processors.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 28 Abs. 2, 4' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.15.1.2' },
],
applicabilityConditions: [
{ field: 'architectureProfile.hasSubprocessors', operator: 'EQUALS', value: true, result: 'REQUIRED', priority: 25 },
{ field: 'architectureProfile.subprocessorCount', operator: 'GREATER_THAN', value: 3, result: 'REQUIRED', priority: 20 },
],
defaultApplicability: 'RECOMMENDED',
evidenceRequirements: ['Sub-Processor-Register', 'Genehmigungsverfahren', 'Vertragsdokumentation'],
reviewFrequency: 'SEMI_ANNUAL',
priority: 'HIGH',
complexity: 'MEDIUM',
tags: ['order-control', 'sub-processor'],
},
// RESILIENCE — 2 new
{
id: 'TOM-RE-04',
code: 'TOM-RE-04',
category: 'RESILIENCE',
type: 'TECHNICAL',
name: { de: 'DDoS-Abwehr (erweitert)', en: 'DDoS Mitigation (Advanced)' },
description: {
de: 'Erweiterte DDoS-Schutzmassnahmen inkl. Traffic-Analyse, automatischer Mitigation und Incident-Response-Integration.',
en: 'Advanced DDoS protection measures including traffic analysis, automatic mitigation and incident response integration.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. b' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.13.1.1' },
],
applicabilityConditions: [
{ field: 'riskProfile.protectionLevel', operator: 'EQUALS', value: 'VERY_HIGH', result: 'REQUIRED', priority: 25 },
{ field: 'architectureProfile.hostingModel', operator: 'IN', value: ['PUBLIC_CLOUD', 'HYBRID'], result: 'RECOMMENDED', priority: 15 },
],
defaultApplicability: 'OPTIONAL',
evidenceRequirements: ['DDoS-Schutzkonzept (erweitert)', 'Mitigation-Berichte', 'Incident-Playbooks'],
reviewFrequency: 'QUARTERLY',
priority: 'HIGH',
complexity: 'HIGH',
tags: ['resilience', 'ddos', 'advanced'],
},
{
id: 'TOM-RE-05',
code: 'TOM-RE-05',
category: 'RESILIENCE',
type: 'ORGANIZATIONAL',
name: { de: 'Kapazitaetsplanung', en: 'Capacity Planning' },
description: {
de: 'Systematische Planung und Ueberwachung von IT-Kapazitaeten zur Sicherstellung der Systemverfuegbarkeit bei wachsender Nutzung.',
en: 'Systematic planning and monitoring of IT capacities to ensure system availability with growing usage.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. b' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.12.1.3' },
],
applicabilityConditions: [
{ field: 'dataProfile.dataVolume', operator: 'IN', value: ['HIGH', 'VERY_HIGH'], result: 'REQUIRED', priority: 20 },
],
defaultApplicability: 'RECOMMENDED',
evidenceRequirements: ['Kapazitaetsplan', 'Trend-Analysen', 'Skalierungskonzept'],
reviewFrequency: 'QUARTERLY',
priority: 'MEDIUM',
complexity: 'MEDIUM',
tags: ['resilience', 'capacity', 'planning'],
},
// RECOVERY — 2 new
{
id: 'TOM-RC-04',
code: 'TOM-RC-04',
category: 'RECOVERY',
type: 'TECHNICAL',
name: { de: 'Georedundantes Backup', en: 'Geo-Redundant Backup' },
description: {
de: 'Speicherung von Backup-Kopien an geografisch getrennten Standorten zum Schutz vor standortbezogenen Katastrophen.',
en: 'Storage of backup copies at geographically separated locations to protect against site-specific disasters.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. c' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.12.3.1' },
{ framework: 'BSI_IT_GRUNDSCHUTZ', reference: 'CON.3' },
],
applicabilityConditions: [
{ field: 'riskProfile.protectionLevel', operator: 'IN', value: ['HIGH', 'VERY_HIGH'], result: 'REQUIRED', priority: 25 },
{ field: 'riskProfile.ciaAssessment.availability', operator: 'GREATER_THAN', value: 3, result: 'REQUIRED', priority: 20 },
],
defaultApplicability: 'RECOMMENDED',
evidenceRequirements: ['Georedundanz-Konzept', 'Backup-Standort-Dokumentation', 'Wiederherstellungstests'],
reviewFrequency: 'SEMI_ANNUAL',
priority: 'HIGH',
complexity: 'HIGH',
tags: ['recovery', 'backup', 'geo-redundancy'],
},
{
id: 'TOM-RC-05',
code: 'TOM-RC-05',
category: 'RECOVERY',
type: 'ORGANIZATIONAL',
name: { de: 'Notfallwiederherstellungs-Tests', en: 'Disaster Recovery Testing' },
description: {
de: 'Regelmaessige Durchfuehrung und Dokumentation von Notfallwiederherstellungstests zur Validierung der RTO/RPO-Ziele.',
en: 'Regular execution and documentation of disaster recovery tests to validate RTO/RPO targets.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. c, d' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.17.1.3' },
],
applicabilityConditions: [
{ field: 'securityProfile.hasDRPlan', operator: 'EQUALS', value: true, result: 'REQUIRED', priority: 25 },
],
defaultApplicability: 'RECOMMENDED',
evidenceRequirements: ['DR-Testberichte', 'RTO/RPO-Messungen', 'Verbesserungsmassnahmen'],
reviewFrequency: 'SEMI_ANNUAL',
priority: 'HIGH',
complexity: 'MEDIUM',
tags: ['recovery', 'dr-testing', 'rto', 'rpo'],
},
// SEPARATION — 2 new
{
id: 'TOM-SE-05',
code: 'TOM-SE-05',
category: 'SEPARATION',
type: 'TECHNICAL',
name: { de: 'Netzwerksegmentierung', en: 'Network Segmentation' },
description: {
de: 'Aufteilung des Netzwerks in separate Sicherheitszonen mit kontrollierten Uebergaengen zur Begrenzung der Ausbreitung von Sicherheitsvorfaellen.',
en: 'Division of the network into separate security zones with controlled transitions to limit the spread of security incidents.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. b' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.13.1.3' },
{ framework: 'BSI_IT_GRUNDSCHUTZ', reference: 'NET.1.1' },
],
applicabilityConditions: [
{ field: 'architectureProfile.hostingModel', operator: 'IN', value: ['ON_PREMISE', 'PRIVATE_CLOUD', 'HYBRID'], result: 'REQUIRED', priority: 20 },
{ field: 'riskProfile.protectionLevel', operator: 'IN', value: ['HIGH', 'VERY_HIGH'], result: 'REQUIRED', priority: 25 },
],
defaultApplicability: 'RECOMMENDED',
evidenceRequirements: ['Netzwerkplan', 'Firewall-Regeln', 'Segmentierungskonzept'],
reviewFrequency: 'SEMI_ANNUAL',
priority: 'HIGH',
complexity: 'HIGH',
tags: ['separation', 'network', 'segmentation'],
},
{
id: 'TOM-SE-06',
code: 'TOM-SE-06',
category: 'SEPARATION',
type: 'TECHNICAL',
name: { de: 'Mandantenisolierung in Cloud', en: 'Tenant Isolation in Cloud' },
description: {
de: 'Technische Sicherstellung der vollstaendigen Datentrennung zwischen verschiedenen Mandanten in Multi-Tenant-Cloud-Umgebungen.',
en: 'Technical assurance of complete data separation between different tenants in multi-tenant cloud environments.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. b' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.13.1.3' },
],
applicabilityConditions: [
{ field: 'architectureProfile.multiTenancy', operator: 'EQUALS', value: 'MULTI_TENANT', result: 'REQUIRED', priority: 30 },
{ field: 'architectureProfile.hostingModel', operator: 'IN', value: ['PUBLIC_CLOUD', 'HYBRID'], result: 'RECOMMENDED', priority: 15 },
],
defaultApplicability: 'OPTIONAL',
evidenceRequirements: ['Mandantentrennungskonzept', 'Isolierungstests', 'Cloud-Security-Assessment'],
reviewFrequency: 'SEMI_ANNUAL',
priority: 'CRITICAL',
complexity: 'HIGH',
tags: ['separation', 'multi-tenant', 'cloud'],
},
// ACCESS_CONTROL — 1 new
{
id: 'TOM-AC-06',
code: 'TOM-AC-06',
category: 'ACCESS_CONTROL',
type: 'ORGANIZATIONAL',
name: { de: 'Besuchermanagement (erweitert)', en: 'Visitor Management (Extended)' },
description: {
de: 'Erweitertes Besuchermanagement mit Voranmeldung, Identitaetspruefung, Begleitpflicht und zeitlich begrenztem Zugang zu sicherheitsrelevanten Bereichen.',
en: 'Extended visitor management with pre-registration, identity verification, escort requirement and time-limited access to security-relevant areas.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. b' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.7.2' },
],
applicabilityConditions: [
{ field: 'riskProfile.protectionLevel', operator: 'IN', value: ['HIGH', 'VERY_HIGH'], result: 'REQUIRED', priority: 20 },
{ field: 'dataProfile.hasSpecialCategories', operator: 'EQUALS', value: true, result: 'RECOMMENDED', priority: 15 },
],
defaultApplicability: 'OPTIONAL',
evidenceRequirements: ['Besuchermanagement-Richtlinie', 'Besucherprotokolle', 'Zonenkonzept'],
reviewFrequency: 'ANNUAL',
priority: 'MEDIUM',
complexity: 'LOW',
tags: ['physical-security', 'visitors', 'extended'],
},
// ADMISSION_CONTROL — 1 new
{
id: 'TOM-ADM-06',
code: 'TOM-ADM-06',
category: 'ADMISSION_CONTROL',
type: 'TECHNICAL',
name: { de: 'Endpoint Detection & Response (EDR)', en: 'Endpoint Detection & Response (EDR)' },
description: {
de: 'Einsatz von EDR-Loesungen zur Erkennung und Abwehr von Bedrohungen auf Endgeraeten in Echtzeit.',
en: 'Deployment of EDR solutions for real-time threat detection and response on endpoints.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. b' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.12.2.1' },
{ framework: 'BSI_IT_GRUNDSCHUTZ', reference: 'OPS.1.1.4' },
],
applicabilityConditions: [
{ field: 'riskProfile.protectionLevel', operator: 'IN', value: ['HIGH', 'VERY_HIGH'], result: 'REQUIRED', priority: 25 },
{ field: 'companyProfile.size', operator: 'IN', value: ['LARGE', 'ENTERPRISE'], result: 'RECOMMENDED', priority: 10 },
],
defaultApplicability: 'RECOMMENDED',
evidenceRequirements: ['EDR-Konfiguration', 'Bedrohungsberichte', 'Incident-Response-Statistiken'],
reviewFrequency: 'QUARTERLY',
priority: 'HIGH',
complexity: 'HIGH',
tags: ['endpoint', 'edr', 'threat-detection'],
},
// ACCESS_AUTHORIZATION — 2 new
{
id: 'TOM-AZ-06',
code: 'TOM-AZ-06',
category: 'ACCESS_AUTHORIZATION',
type: 'TECHNICAL',
name: { de: 'API-Zugriffskontrolle', en: 'API Access Control' },
description: {
de: 'Implementierung von Authentifizierungs- und Autorisierungsmechanismen fuer APIs (OAuth 2.0, API-Keys, Rate Limiting).',
en: 'Implementation of authentication and authorization mechanisms for APIs (OAuth 2.0, API keys, rate limiting).',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. b' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.9.4.1' },
],
applicabilityConditions: [
{ field: 'architectureProfile.hostingModel', operator: 'IN', value: ['PUBLIC_CLOUD', 'HYBRID'], result: 'REQUIRED', priority: 20 },
],
defaultApplicability: 'RECOMMENDED',
evidenceRequirements: ['API-Security-Konzept', 'OAuth-Konfiguration', 'Rate-Limiting-Regeln'],
reviewFrequency: 'QUARTERLY',
priority: 'HIGH',
complexity: 'MEDIUM',
tags: ['authorization', 'api', 'oauth'],
},
{
id: 'TOM-AZ-07',
code: 'TOM-AZ-07',
category: 'ACCESS_AUTHORIZATION',
type: 'ORGANIZATIONAL',
name: { de: 'Regelmaessiger Berechtigungsreview', en: 'Regular Permission Review' },
description: {
de: 'Systematische Ueberpruefung und Bereinigung von Zugriffsberechtigungen in regelmaessigen Abstaenden durch die jeweiligen Fachverantwortlichen.',
en: 'Systematic review and cleanup of access permissions at regular intervals by the respective department heads.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. d' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.9.2.5' },
],
applicabilityConditions: [],
defaultApplicability: 'REQUIRED',
evidenceRequirements: ['Review-Protokolle', 'Berechtigungsaenderungslog', 'Freigabedokumentation'],
reviewFrequency: 'SEMI_ANNUAL',
priority: 'HIGH',
complexity: 'LOW',
tags: ['authorization', 'review', 'permissions'],
},
// TRANSFER_CONTROL — 2 new
{
id: 'TOM-TR-06',
code: 'TOM-TR-06',
category: 'TRANSFER_CONTROL',
type: 'TECHNICAL',
name: { de: 'E-Mail-Verschluesselung (erweitert)', en: 'Email Encryption (Extended)' },
description: {
de: 'Erweiterte E-Mail-Verschluesselung mit automatischer Erkennung sensibler Inhalte und erzwungener Gateway-Verschluesselung.',
en: 'Extended email encryption with automatic detection of sensitive content and enforced gateway encryption.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. a' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.13.2.3' },
],
applicabilityConditions: [
{ field: 'dataProfile.hasSpecialCategories', operator: 'EQUALS', value: true, result: 'REQUIRED', priority: 25 },
{ field: 'riskProfile.protectionLevel', operator: 'IN', value: ['HIGH', 'VERY_HIGH'], result: 'RECOMMENDED', priority: 15 },
],
defaultApplicability: 'OPTIONAL',
evidenceRequirements: ['E-Mail-Verschluesselungs-Policy', 'Gateway-Konfiguration', 'DLP-Regeln'],
reviewFrequency: 'SEMI_ANNUAL',
priority: 'MEDIUM',
complexity: 'MEDIUM',
tags: ['transfer', 'email', 'encryption'],
},
{
id: 'TOM-TR-07',
code: 'TOM-TR-07',
category: 'TRANSFER_CONTROL',
type: 'ORGANIZATIONAL',
name: { de: 'Drittstaat-Transferbewertung', en: 'Third Country Transfer Assessment' },
description: {
de: 'Dokumentierte Bewertung und Absicherung von Datenuebermittlungen in Drittstaaten gemaess Art. 44-49 DSGVO (Standardvertragsklauseln, TIA).',
en: 'Documented assessment and safeguarding of data transfers to third countries according to Art. 44-49 GDPR (Standard Contractual Clauses, TIA).',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 44-49' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.15.1.2' },
],
applicabilityConditions: [
{ field: 'dataProfile.thirdCountryTransfers', operator: 'EQUALS', value: true, result: 'REQUIRED', priority: 30 },
{ field: 'architectureProfile.hostingLocation', operator: 'IN', value: ['THIRD_COUNTRY_ADEQUATE', 'THIRD_COUNTRY'], result: 'REQUIRED', priority: 25 },
],
defaultApplicability: 'OPTIONAL',
evidenceRequirements: ['Transfer Impact Assessment', 'Standardvertragsklauseln', 'Angemessenheitsbeschluss-Pruefung'],
reviewFrequency: 'ANNUAL',
priority: 'CRITICAL',
complexity: 'MEDIUM',
tags: ['transfer', 'third-country', 'schrems-ii'],
},
// AVAILABILITY — 2 new
{
id: 'TOM-AV-06',
code: 'TOM-AV-06',
category: 'AVAILABILITY',
type: 'TECHNICAL',
name: { de: 'Monitoring und Alerting', en: 'Monitoring and Alerting' },
description: {
de: 'Implementierung einer umfassenden Ueberwachung aller IT-Systeme mit automatischen Benachrichtigungen bei Stoerungen oder Schwellenwert-Ueberschreitungen.',
en: 'Implementation of comprehensive monitoring of all IT systems with automatic notifications for disruptions or threshold violations.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. b' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.12.4.1' },
{ framework: 'BSI_IT_GRUNDSCHUTZ', reference: 'OPS.1.1.2' },
],
applicabilityConditions: [],
defaultApplicability: 'REQUIRED',
evidenceRequirements: ['Monitoring-Konzept', 'Alerting-Konfiguration', 'Eskalationsmatrix'],
reviewFrequency: 'QUARTERLY',
priority: 'HIGH',
complexity: 'MEDIUM',
tags: ['availability', 'monitoring', 'alerting'],
},
{
id: 'TOM-AV-07',
code: 'TOM-AV-07',
category: 'AVAILABILITY',
type: 'ORGANIZATIONAL',
name: { de: 'Service Level Management', en: 'Service Level Management' },
description: {
de: 'Definition und Ueberwachung von Service Level Agreements (SLAs) fuer alle kritischen IT-Services mit klaren Verfuegbarkeitszielen.',
en: 'Definition and monitoring of Service Level Agreements (SLAs) for all critical IT services with clear availability targets.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. b' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.15.2.1' },
],
applicabilityConditions: [
{ field: 'companyProfile.size', operator: 'IN', value: ['MEDIUM', 'LARGE', 'ENTERPRISE'], result: 'RECOMMENDED', priority: 10 },
{ field: 'architectureProfile.hasSubprocessors', operator: 'EQUALS', value: true, result: 'REQUIRED', priority: 20 },
],
defaultApplicability: 'RECOMMENDED',
evidenceRequirements: ['SLA-Dokumentation', 'Verfuegbarkeitsberichte', 'Eskalationsverfahren'],
reviewFrequency: 'QUARTERLY',
priority: 'MEDIUM',
complexity: 'LOW',
tags: ['availability', 'sla', 'service-management'],
},
// SEPARATION — 1 more new (TOM-DL-05)
{
id: 'TOM-DL-05',
code: 'TOM-DL-05',
category: 'SEPARATION',
type: 'ORGANIZATIONAL',
name: { de: 'Datenloesch-Audit', en: 'Data Deletion Audit' },
description: {
de: 'Regelmaessige Ueberpruefung der Wirksamkeit und Vollstaendigkeit von Datenloeschvorgaengen durch unabhaengige Stellen.',
en: 'Regular review of the effectiveness and completeness of data deletion processes by independent parties.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 5 Abs. 1 lit. e' },
{ framework: 'GDPR_ART32', reference: 'Art. 17' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.8.3.2' },
],
applicabilityConditions: [
{ field: 'dataProfile.hasSpecialCategories', operator: 'EQUALS', value: true, result: 'REQUIRED', priority: 25 },
],
defaultApplicability: 'RECOMMENDED',
evidenceRequirements: ['Audit-Berichte', 'Loeschprotokolle', 'Stichproben-Ergebnisse'],
reviewFrequency: 'ANNUAL',
priority: 'MEDIUM',
complexity: 'MEDIUM',
tags: ['separation', 'deletion', 'audit'],
},
// REVIEW — 3 new
{
id: 'TOM-RV-09',
code: 'TOM-RV-09',
category: 'REVIEW',
type: 'ORGANIZATIONAL',
name: { de: 'Datenschutz-Audit-Programm', en: 'Data Protection Audit Program' },
description: {
de: 'Systematisches Programm zur regelmaessigen internen Ueberpruefung aller Datenschutzmassnahmen mit dokumentierten Ergebnissen und Massnahmenverfolgung.',
en: 'Systematic program for regular internal review of all data protection measures with documented results and action tracking.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. d' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.18.2.1' },
{ framework: 'BSI_IT_GRUNDSCHUTZ', reference: 'DER.3.1' },
],
applicabilityConditions: [],
defaultApplicability: 'REQUIRED',
evidenceRequirements: ['Audit-Programm', 'Audit-Berichte', 'Massnahmenplan'],
reviewFrequency: 'ANNUAL',
priority: 'HIGH',
complexity: 'MEDIUM',
tags: ['review', 'audit', 'data-protection'],
},
{
id: 'TOM-RV-10',
code: 'TOM-RV-10',
category: 'REVIEW',
type: 'TECHNICAL',
name: { de: 'Automatisierte Compliance-Pruefung', en: 'Automated Compliance Checking' },
description: {
de: 'Einsatz automatisierter Tools zur kontinuierlichen Ueberpruefung der Einhaltung von Sicherheits- und Datenschutzrichtlinien.',
en: 'Use of automated tools for continuous monitoring of compliance with security and data protection policies.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. d' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.18.2.2' },
],
applicabilityConditions: [
{ field: 'companyProfile.size', operator: 'IN', value: ['MEDIUM', 'LARGE', 'ENTERPRISE'], result: 'RECOMMENDED', priority: 10 },
{ field: 'riskProfile.protectionLevel', operator: 'IN', value: ['HIGH', 'VERY_HIGH'], result: 'RECOMMENDED', priority: 15 },
],
defaultApplicability: 'OPTIONAL',
evidenceRequirements: ['Tool-Konfiguration', 'Compliance-Dashboard', 'Automatisierte Berichte'],
reviewFrequency: 'QUARTERLY',
priority: 'MEDIUM',
complexity: 'HIGH',
tags: ['review', 'automation', 'compliance'],
},
{
id: 'TOM-RV-11',
code: 'TOM-RV-11',
category: 'REVIEW',
type: 'ORGANIZATIONAL',
name: { de: 'Management Review (Art. 32 Abs. 1 lit. d)', en: 'Management Review (Art. 32(1)(d))' },
description: {
de: 'Regelmaessige Ueberpruefung der Wirksamkeit aller technischen und organisatorischen Massnahmen durch die Geschaeftsfuehrung mit dokumentierten Ergebnissen.',
en: 'Regular review of the effectiveness of all technical and organizational measures by management with documented results.',
},
mappings: [
{ framework: 'GDPR_ART32', reference: 'Art. 32 Abs. 1 lit. d' },
{ framework: 'ISO27001_ANNEX_A', reference: 'A.18.2.1' },
],
applicabilityConditions: [],
defaultApplicability: 'REQUIRED',
evidenceRequirements: ['Management-Review-Protokolle', 'Massnahmenplan', 'Wirksamkeitsbewertung'],
reviewFrequency: 'ANNUAL',
priority: 'HIGH',
complexity: 'LOW',
tags: ['review', 'management', 'effectiveness'],
},
],
}

View File

@@ -320,158 +320,3 @@ export async function generateVideo(moduleId: string): Promise<TrainingMedia> {
export async function previewVideoScript(moduleId: string): Promise<{ title: string; sections: Array<{ heading: string; text: string; bullet_points: string[] }> }> {
return apiFetch(`/content/${moduleId}/preview-script`, { method: 'POST' })
}
// =============================================================================
// TRAINING BLOCKS (Controls → Schulungsmodule)
// =============================================================================
import type {
TrainingBlockConfig,
CanonicalControlSummary,
CanonicalControlMeta,
BlockPreview,
BlockGenerateResult,
TrainingBlockControlLink,
} from './types'
export async function listBlockConfigs(): Promise<{ blocks: TrainingBlockConfig[]; total: number }> {
return apiFetch('/blocks')
}
export async function createBlockConfig(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
frequency_type?: string
duration_minutes?: number
pass_threshold?: number
max_controls_per_module?: number
}): Promise<TrainingBlockConfig> {
return apiFetch<TrainingBlockConfig>('/blocks', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function getBlockConfig(id: string): Promise<TrainingBlockConfig> {
return apiFetch<TrainingBlockConfig>(`/blocks/${id}`)
}
export async function updateBlockConfig(id: string, data: Record<string, unknown>): Promise<TrainingBlockConfig> {
return apiFetch<TrainingBlockConfig>(`/blocks/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
export async function deleteBlockConfig(id: string): Promise<void> {
return apiFetch(`/blocks/${id}`, { method: 'DELETE' })
}
export async function previewBlock(id: string): Promise<BlockPreview> {
return apiFetch<BlockPreview>(`/blocks/${id}/preview`, { method: 'POST' })
}
export async function generateBlock(id: string, data?: {
language?: string
auto_matrix?: boolean
}): Promise<BlockGenerateResult> {
return apiFetch<BlockGenerateResult>(`/blocks/${id}/generate`, {
method: 'POST',
body: JSON.stringify(data || { language: 'de', auto_matrix: true }),
})
}
export async function getBlockControls(id: string): Promise<{ controls: TrainingBlockControlLink[]; total: number }> {
return apiFetch(`/blocks/${id}/controls`)
}
export async function listCanonicalControls(filters?: {
domain?: string
category?: string
severity?: string
target_audience?: string
}): Promise<{ controls: CanonicalControlSummary[]; total: number }> {
const params = new URLSearchParams()
if (filters?.domain) params.set('domain', filters.domain)
if (filters?.category) params.set('category', filters.category)
if (filters?.severity) params.set('severity', filters.severity)
if (filters?.target_audience) params.set('target_audience', filters.target_audience)
const qs = params.toString()
return apiFetch(`/canonical/controls${qs ? `?${qs}` : ''}`)
}
export async function getCanonicalMeta(): Promise<CanonicalControlMeta> {
return apiFetch<CanonicalControlMeta>('/canonical/meta')
}
// =============================================================================
// CERTIFICATES
// =============================================================================
export async function generateCertificate(assignmentId: string): Promise<{ certificate_id: string; assignment: TrainingAssignment }> {
return apiFetch(`/certificates/generate/${assignmentId}`, { method: 'POST' })
}
export async function listCertificates(): Promise<{ certificates: TrainingAssignment[]; total: number }> {
return apiFetch('/certificates')
}
export async function downloadCertificatePDF(certificateId: string): Promise<Blob> {
const res = await fetch(`${BASE_URL}/certificates/${certificateId}/pdf`, {
headers: {
'X-Tenant-ID': typeof window !== 'undefined'
? (localStorage.getItem('bp-tenant-id') || 'default')
: 'default',
},
})
if (!res.ok) throw new Error(`PDF download failed: ${res.status}`)
return res.blob()
}
export async function verifyCertificate(certificateId: string): Promise<{ valid: boolean; assignment: TrainingAssignment }> {
return apiFetch(`/certificates/${certificateId}/verify`)
}
// =============================================================================
// MEDIA STREAMING
// =============================================================================
export function getMediaStreamURL(mediaId: string): string {
return `${BASE_URL}/media/${mediaId}/stream`
}
// =============================================================================
// INTERACTIVE VIDEO
// =============================================================================
import type {
InteractiveVideoManifest,
CheckpointQuizResult,
CheckpointProgress,
} from './types'
export async function generateInteractiveVideo(moduleId: string): Promise<TrainingMedia> {
return apiFetch<TrainingMedia>(`/content/${moduleId}/generate-interactive`, { method: 'POST' })
}
export async function getInteractiveManifest(moduleId: string, assignmentId?: string): Promise<InteractiveVideoManifest> {
const qs = assignmentId ? `?assignment_id=${assignmentId}` : ''
return apiFetch<InteractiveVideoManifest>(`/content/${moduleId}/interactive-manifest${qs}`)
}
export async function submitCheckpointQuiz(checkpointId: string, assignmentId: string, answers: number[]): Promise<CheckpointQuizResult> {
return apiFetch<CheckpointQuizResult>(`/checkpoints/${checkpointId}/submit`, {
method: 'POST',
body: JSON.stringify({ assignment_id: assignmentId, answers }),
})
}
export async function getCheckpointProgress(assignmentId: string): Promise<{ progress: CheckpointProgress[]; total: number }> {
return apiFetch(`/checkpoints/progress/${assignmentId}`)
}

View File

@@ -65,17 +65,9 @@ export const ROLE_LABELS: Record<string, string> = {
R7: 'Fachabteilung',
R8: 'IT-Administration',
R9: 'Alle Mitarbeiter',
R10: 'Behoerden / Oeffentlicher Dienst',
}
export const ALL_ROLES = ['R1', 'R2', 'R3', 'R4', 'R5', 'R6', 'R7', 'R8', 'R9', 'R10'] as const
export const TARGET_AUDIENCE_LABELS: Record<string, string> = {
enterprise: 'Unternehmen',
authority: 'Behoerden',
provider: 'IT-Dienstleister',
all: 'Alle',
}
export const ALL_ROLES = ['R1', 'R2', 'R3', 'R4', 'R5', 'R6', 'R7', 'R8', 'R9'] as const
// =============================================================================
// MAIN ENTITIES
@@ -281,7 +273,7 @@ export interface QuizSubmitResponse {
// MEDIA (Audio/Video)
// =============================================================================
export type MediaType = 'audio' | 'video' | 'interactive_video'
export type MediaType = 'audio' | 'video'
export type MediaStatus = 'processing' | 'completed' | 'failed'
export interface TrainingMedia {
@@ -315,121 +307,3 @@ export interface VideoScriptSection {
text: string
bullet_points: string[]
}
// =============================================================================
// TRAINING BLOCKS (Controls → Schulungsmodule)
// =============================================================================
export interface TrainingBlockConfig {
id: string
tenant_id: string
name: string
description?: string
domain_filter?: string
category_filter?: string
severity_filter?: string
target_audience_filter?: string
regulation_area: RegulationArea
module_code_prefix: string
frequency_type: FrequencyType
duration_minutes: number
pass_threshold: number
max_controls_per_module: number
is_active: boolean
last_generated_at?: string
created_at: string
updated_at: string
}
export interface CanonicalControlSummary {
control_id: string
title: string
objective: string
rationale: string
requirements: string[]
severity: string
category: string
target_audience: string
tags: string[]
}
export interface CanonicalControlMeta {
domains: { domain: string; count: number }[]
categories: { category: string; count: number }[]
audiences: { audience: string; count: number }[]
total: number
}
export interface BlockPreview {
control_count: number
module_count: number
controls: CanonicalControlSummary[]
proposed_roles: string[]
}
export interface BlockGenerateResult {
modules_created: number
controls_linked: number
matrix_entries_created: number
content_generated: number
errors?: string[]
}
export interface TrainingBlockControlLink {
id: string
block_config_id: string
module_id: string
control_id: string
control_title: string
control_objective: string
control_requirements: string[]
sort_order: number
created_at: string
}
// =============================================================================
// INTERACTIVE VIDEO / CHECKPOINTS
// =============================================================================
export interface InteractiveVideoManifest {
media_id: string
stream_url: string
checkpoints: CheckpointEntry[]
}
export interface CheckpointEntry {
checkpoint_id: string
index: number
title: string
timestamp_seconds: number
questions: CheckpointQuestion[]
progress?: CheckpointProgress
}
export interface CheckpointQuestion {
question: string
options: string[]
correct_index: number
explanation: string
}
export interface CheckpointProgress {
id: string
assignment_id: string
checkpoint_id: string
passed: boolean
attempts: number
last_attempt_at?: string
}
export interface CheckpointQuizResult {
passed: boolean
score: number
feedback: CheckpointQuizFeedback[]
}
export interface CheckpointQuizFeedback {
question: string
correct: boolean
explanation: string
}

View File

@@ -796,16 +796,16 @@ export const SDK_STEPS: SDKStep[] = [
},
{
id: 'vendor-compliance',
seq: 2500,
seq: 4200,
phase: 2,
package: 'dokumentation',
order: 6,
package: 'betrieb',
order: 3,
name: 'Vendor Compliance',
nameShort: 'Vendor',
description: 'Dienstleister-Management',
url: '/sdk/vendor-compliance',
checkpointId: 'CP-VEND',
prerequisiteSteps: ['vvt'],
prerequisiteSteps: ['escalations'],
isOptional: false,
},
{
@@ -920,20 +920,6 @@ export const SDK_STEPS: SDKStep[] = [
prerequisiteSteps: [],
isOptional: true,
},
{
id: 'atomic-controls',
seq: 4925,
phase: 2,
package: 'betrieb',
order: 11.5,
name: 'Atomare Controls',
nameShort: 'Atomar',
description: 'Deduplizierte atomare Controls mit Herkunftsnachweis',
url: '/sdk/atomic-controls',
checkpointId: 'CP-ATOM',
prerequisiteSteps: [],
isOptional: true,
},
{
id: 'control-provenance',
seq: 4950,
@@ -1953,7 +1939,6 @@ export type LicenseType =
* Template types available for document generation
*/
export type TemplateType =
// Legal / Vertragsvorlagen
| 'privacy_policy'
| 'terms_of_service'
| 'agb'
@@ -1971,55 +1956,6 @@ export type TemplateType =
| 'copyright_policy'
| 'clause'
| 'dsfa'
// Sicherheitskonzepte (Migration 051)
| 'it_security_concept'
| 'data_protection_concept'
| 'backup_recovery_concept'
| 'logging_concept'
| 'incident_response_plan'
| 'access_control_concept'
| 'risk_management_concept'
// CRA Cybersecurity (Migration 056)
| 'cybersecurity_policy'
// IT-Sicherheit Policies (Migration 071)
| 'information_security_policy'
| 'access_control_policy'
| 'password_policy'
| 'encryption_policy'
| 'logging_policy'
| 'backup_policy'
| 'incident_response_policy'
| 'change_management_policy'
| 'patch_management_policy'
| 'asset_management_policy'
| 'cloud_security_policy'
| 'devsecops_policy'
| 'secrets_management_policy'
| 'vulnerability_management_policy'
// Daten-Policies (Migration 072)
| 'data_protection_policy'
| 'data_classification_policy'
| 'data_retention_policy'
| 'data_transfer_policy'
| 'privacy_incident_policy'
// Personal-Policies (Migration 072)
| 'employee_security_policy'
| 'security_awareness_policy'
| 'remote_work_policy'
| 'offboarding_policy'
// Lieferanten-Policies (Migration 072)
| 'vendor_risk_management_policy'
| 'third_party_security_policy'
| 'supplier_security_policy'
// BCM/Notfall (Migration 072)
| 'business_continuity_policy'
| 'disaster_recovery_policy'
| 'crisis_management_policy'
// Modul-Dokumente (Migration 073)
| 'vvt_register'
| 'tom_documentation'
| 'loeschkonzept'
| 'pflichtenregister'
/**
* Jurisdiction codes for legal documents
@@ -2254,7 +2190,6 @@ export const DEFAULT_PLACEHOLDERS: Record<string, string> = {
* Template type labels for display
*/
export const TEMPLATE_TYPE_LABELS: Record<TemplateType, string> = {
// Legal / Vertragsvorlagen
privacy_policy: 'Datenschutzerklärung',
terms_of_service: 'Nutzungsbedingungen',
agb: 'Allgemeine Geschäftsbedingungen',
@@ -2272,54 +2207,6 @@ export const TEMPLATE_TYPE_LABELS: Record<TemplateType, string> = {
copyright_policy: 'Urheberrechtsrichtlinie',
clause: 'Vertragsklausel',
dsfa: 'Datenschutz-Folgenabschätzung',
// Sicherheitskonzepte
it_security_concept: 'IT-Sicherheitskonzept',
data_protection_concept: 'Datenschutzkonzept',
backup_recovery_concept: 'Backup- und Recovery-Konzept',
logging_concept: 'Logging-Konzept',
incident_response_plan: 'Incident-Response-Plan',
access_control_concept: 'Zugriffskonzept',
risk_management_concept: 'Risikomanagement-Konzept',
cybersecurity_policy: 'Cybersecurity-Richtlinie (CRA)',
// IT-Sicherheit Policies
information_security_policy: 'Informationssicherheitsrichtlinie',
access_control_policy: 'Zugriffskontrollrichtlinie',
password_policy: 'Passwortrichtlinie',
encryption_policy: 'Verschlüsselungsrichtlinie',
logging_policy: 'Protokollierungsrichtlinie',
backup_policy: 'Datensicherungsrichtlinie',
incident_response_policy: 'Incident-Response-Richtlinie',
change_management_policy: 'Change-Management-Richtlinie',
patch_management_policy: 'Patch-Management-Richtlinie',
asset_management_policy: 'Asset-Management-Richtlinie',
cloud_security_policy: 'Cloud-Security-Richtlinie',
devsecops_policy: 'DevSecOps-Richtlinie',
secrets_management_policy: 'Secrets-Management-Richtlinie',
vulnerability_management_policy: 'Schwachstellenmanagement-Richtlinie',
// Daten-Policies
data_protection_policy: 'Datenschutzrichtlinie',
data_classification_policy: 'Datenklassifizierungsrichtlinie',
data_retention_policy: 'Aufbewahrungsrichtlinie',
data_transfer_policy: 'Datenübermittlungsrichtlinie',
privacy_incident_policy: 'Datenschutzvorfall-Richtlinie',
// Personal-Policies
employee_security_policy: 'Mitarbeiter-Sicherheitsrichtlinie',
security_awareness_policy: 'Security-Awareness-Richtlinie',
remote_work_policy: 'Remote-Work-Richtlinie',
offboarding_policy: 'Offboarding-Richtlinie',
// Lieferanten-Policies
vendor_risk_management_policy: 'Lieferanten-Risikomanagement',
third_party_security_policy: 'Drittanbieter-Sicherheitsrichtlinie',
supplier_security_policy: 'Lieferanten-Sicherheitsanforderungen',
// BCM/Notfall
business_continuity_policy: 'Business-Continuity-Richtlinie',
disaster_recovery_policy: 'Disaster-Recovery-Richtlinie',
crisis_management_policy: 'Krisenmanagement-Richtlinie',
// Modul-Dokumente
vvt_register: 'Verarbeitungsverzeichnis (Art. 30)',
tom_documentation: 'TOM-Dokumentation (Art. 32)',
loeschkonzept: 'Löschkonzept (Art. 5/17)',
pflichtenregister: 'Pflichtenregister',
}
/**

View File

@@ -15,9 +15,16 @@ import {
VendorComplianceAction,
VendorComplianceContextValue,
ProcessingActivity,
Vendor,
ContractDocument,
Finding,
Control,
ControlInstance,
RiskAssessment,
VendorStatistics,
ComplianceStatistics,
RiskOverview,
ExportFormat,
VendorStatus,
VendorRole,
RiskLevel,
@@ -468,6 +475,24 @@ export function VendorComplianceProvider({
[apiBase]
)
const updateProcessingActivity = useCallback(
async (id: string, data: Partial<ProcessingActivity>): Promise<void> => {
const response = await fetch(`${apiBase}/processing-activities/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Fehler beim Aktualisieren der Verarbeitungstätigkeit')
}
dispatch({ type: 'UPDATE_PROCESSING_ACTIVITY', payload: { id, data } })
},
[apiBase]
)
const deleteProcessingActivity = useCallback(
async (id: string): Promise<void> => {
const response = await fetch(`${apiBase}/processing-activities/${id}`, {
@@ -512,6 +537,49 @@ export function VendorComplianceProvider({
// VENDOR ACTIONS
// ==========================================
const createVendor = useCallback(
async (
data: Omit<Vendor, 'id' | 'tenantId' | 'createdAt' | 'updatedAt'>
): Promise<Vendor> => {
const response = await fetch(`${apiBase}/vendors`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Fehler beim Erstellen des Vendors')
}
const result = await response.json()
const vendor = result.data
dispatch({ type: 'ADD_VENDOR', payload: vendor })
return vendor
},
[apiBase]
)
const updateVendor = useCallback(
async (id: string, data: Partial<Vendor>): Promise<void> => {
const response = await fetch(`${apiBase}/vendors/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Fehler beim Aktualisieren des Vendors')
}
dispatch({ type: 'UPDATE_VENDOR', payload: { id, data } })
},
[apiBase]
)
const deleteVendor = useCallback(
async (id: string): Promise<void> => {
const response = await fetch(`${apiBase}/vendors/${id}`, {
@@ -532,6 +600,67 @@ export function VendorComplianceProvider({
// CONTRACT ACTIONS
// ==========================================
const uploadContract = useCallback(
async (
vendorId: string,
file: File,
metadata: Partial<ContractDocument>
): Promise<ContractDocument> => {
const formData = new FormData()
formData.append('file', file)
formData.append('vendorId', vendorId)
formData.append('metadata', JSON.stringify(metadata))
const response = await fetch(`${apiBase}/contracts`, {
method: 'POST',
body: formData,
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Fehler beim Hochladen des Vertrags')
}
const result = await response.json()
const contract = result.data
dispatch({ type: 'ADD_CONTRACT', payload: contract })
// Update vendor's contracts list
const vendor = state.vendors.find((v) => v.id === vendorId)
if (vendor) {
dispatch({
type: 'UPDATE_VENDOR',
payload: {
id: vendorId,
data: { contracts: [...vendor.contracts, contract.id] },
},
})
}
return contract
},
[apiBase, state.vendors]
)
const updateContract = useCallback(
async (id: string, data: Partial<ContractDocument>): Promise<void> => {
const response = await fetch(`${apiBase}/contracts/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Fehler beim Aktualisieren des Vertrags')
}
dispatch({ type: 'UPDATE_CONTRACT', payload: { id, data } })
},
[apiBase]
)
const deleteContract = useCallback(
async (id: string): Promise<void> => {
const contract = state.contracts.find((c) => c.id === id)
@@ -607,6 +736,125 @@ export function VendorComplianceProvider({
[apiBase]
)
// ==========================================
// FINDINGS ACTIONS
// ==========================================
const updateFinding = useCallback(
async (id: string, data: Partial<Finding>): Promise<void> => {
const response = await fetch(`${apiBase}/findings/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Fehler beim Aktualisieren des Findings')
}
dispatch({ type: 'UPDATE_FINDING', payload: { id, data } })
},
[apiBase]
)
const resolveFinding = useCallback(
async (id: string, resolution: string): Promise<void> => {
await updateFinding(id, {
status: 'RESOLVED',
resolution,
resolvedAt: new Date(),
})
},
[updateFinding]
)
// ==========================================
// CONTROL ACTIONS
// ==========================================
const updateControlInstance = useCallback(
async (id: string, data: Partial<ControlInstance>): Promise<void> => {
const response = await fetch(`${apiBase}/control-instances/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Fehler beim Aktualisieren des Control-Status')
}
dispatch({ type: 'UPDATE_CONTROL_INSTANCE', payload: { id, data } })
},
[apiBase]
)
// ==========================================
// EXPORT ACTIONS
// ==========================================
const exportVVT = useCallback(
async (format: ExportFormat, activityIds?: string[]): Promise<string> => {
const params = new URLSearchParams({ format })
if (activityIds && activityIds.length > 0) {
params.append('activityIds', activityIds.join(','))
}
const response = await fetch(`${apiBase}/export/vvt?${params}`)
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Fehler beim Exportieren des VVT')
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
return url
},
[apiBase]
)
const exportVendorAuditPack = useCallback(
async (vendorId: string, format: ExportFormat): Promise<string> => {
const params = new URLSearchParams({ format, vendorId })
const response = await fetch(`${apiBase}/export/vendor-audit?${params}`)
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Fehler beim Exportieren des Vendor Audit Packs')
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
return url
},
[apiBase]
)
const exportRoPA = useCallback(
async (format: ExportFormat): Promise<string> => {
const params = new URLSearchParams({ format })
const response = await fetch(`${apiBase}/export/ropa?${params}`)
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Fehler beim Exportieren des RoPA')
}
const blob = await response.blob()
const url = URL.createObjectURL(blob)
return url
},
[apiBase]
)
// ==========================================
// INITIALIZATION
// ==========================================
@@ -629,11 +877,23 @@ export function VendorComplianceProvider({
vendorStats,
complianceStats,
riskOverview,
createProcessingActivity,
updateProcessingActivity,
deleteProcessingActivity,
duplicateProcessingActivity,
createVendor,
updateVendor,
deleteVendor,
uploadContract,
updateContract,
deleteContract,
startContractReview,
updateFinding,
resolveFinding,
updateControlInstance,
exportVVT,
exportVendorAuditPack,
exportRoPA,
loadData,
refresh,
}),
@@ -642,11 +902,23 @@ export function VendorComplianceProvider({
vendorStats,
complianceStats,
riskOverview,
createProcessingActivity,
updateProcessingActivity,
deleteProcessingActivity,
duplicateProcessingActivity,
createVendor,
updateVendor,
deleteVendor,
uploadContract,
updateContract,
deleteContract,
startContractReview,
updateFinding,
resolveFinding,
updateControlInstance,
exportVVT,
exportVendorAuditPack,
exportRoPA,
loadData,
refresh,
]
@@ -675,3 +947,64 @@ export function useVendorCompliance(): VendorComplianceContextValue {
return context
}
// ==========================================
// SELECTORS
// ==========================================
export function useVendor(vendorId: string | null) {
const { vendors } = useVendorCompliance()
return useMemo(
() => vendors.find((v) => v.id === vendorId) ?? null,
[vendors, vendorId]
)
}
export function useProcessingActivity(activityId: string | null) {
const { processingActivities } = useVendorCompliance()
return useMemo(
() => processingActivities.find((a) => a.id === activityId) ?? null,
[processingActivities, activityId]
)
}
export function useVendorContracts(vendorId: string | null) {
const { contracts } = useVendorCompliance()
return useMemo(
() => contracts.filter((c) => c.vendorId === vendorId),
[contracts, vendorId]
)
}
export function useVendorFindings(vendorId: string | null) {
const { findings } = useVendorCompliance()
return useMemo(
() => findings.filter((f) => f.vendorId === vendorId),
[findings, vendorId]
)
}
export function useContractFindings(contractId: string | null) {
const { findings } = useVendorCompliance()
return useMemo(
() => findings.filter((f) => f.contractId === contractId),
[findings, contractId]
)
}
export function useControlInstancesForEntity(
entityType: 'VENDOR' | 'PROCESSING_ACTIVITY',
entityId: string | null
) {
const { controlInstances, controls } = useVendorCompliance()
return useMemo(() => {
if (!entityId) return []
return controlInstances
.filter((ci) => ci.entityType === entityType && ci.entityId === entityId)
.map((ci) => ({
...ci,
control: controls.find((c) => c.id === ci.controlId),
}))
}, [controlInstances, controls, entityType, entityId])
}

View File

@@ -21,6 +21,12 @@ export * from './types'
export {
VendorComplianceProvider,
useVendorCompliance,
useVendor,
useProcessingActivity,
useVendorContracts,
useVendorFindings,
useContractFindings,
useControlInstancesForEntity,
} from './context'
// ==========================================

View File

@@ -828,16 +828,34 @@ export interface VendorComplianceContextValue extends VendorComplianceState {
riskOverview: RiskOverview
// Actions - Processing Activities
createProcessingActivity: (data: Omit<ProcessingActivity, 'id' | 'tenantId' | 'createdAt' | 'updatedAt'>) => Promise<ProcessingActivity>
updateProcessingActivity: (id: string, data: Partial<ProcessingActivity>) => Promise<void>
deleteProcessingActivity: (id: string) => Promise<void>
duplicateProcessingActivity: (id: string) => Promise<ProcessingActivity>
// Actions - Vendors
createVendor: (data: Omit<Vendor, 'id' | 'tenantId' | 'createdAt' | 'updatedAt'>) => Promise<Vendor>
updateVendor: (id: string, data: Partial<Vendor>) => Promise<void>
deleteVendor: (id: string) => Promise<void>
// Actions - Contracts
uploadContract: (vendorId: string, file: File, metadata: Partial<ContractDocument>) => Promise<ContractDocument>
updateContract: (id: string, data: Partial<ContractDocument>) => Promise<void>
deleteContract: (id: string) => Promise<void>
startContractReview: (contractId: string) => Promise<void>
// Actions - Findings
updateFinding: (id: string, data: Partial<Finding>) => Promise<void>
resolveFinding: (id: string, resolution: string) => Promise<void>
// Actions - Controls
updateControlInstance: (id: string, data: Partial<ControlInstance>) => Promise<void>
// Actions - Export
exportVVT: (format: ExportFormat, activityIds?: string[]) => Promise<string>
exportVendorAuditPack: (vendorId: string, format: ExportFormat) => Promise<string>
exportRoPA: (format: ExportFormat) => Promise<string>
// Data Loading
loadData: () => Promise<void>
refresh: () => Promise<void>
@@ -941,6 +959,15 @@ export interface VendorFormData {
notes?: string
}
export interface ContractUploadData {
vendorId: string
documentType: DocumentType
version: string
effectiveDate?: Date
expirationDate?: Date
autoRenewal?: boolean
}
// ==========================================
// HELPER FUNCTIONS
// ==========================================

View File

@@ -103,21 +103,25 @@ export interface VVTActivity {
owner: string
createdAt: string
updatedAt: string
}
// Library refs (optional, parallel to freetext)
purposeRefs?: string[]
legalBasisRefs?: string[]
dataSubjectRefs?: string[]
dataCategoryRefs?: string[]
recipientRefs?: string[]
retentionRuleRef?: string
transferMechanismRefs?: string[]
tomRefs?: string[]
linkedLoeschfristenIds?: string[]
linkedTomMeasureIds?: string[]
sourceTemplateId?: string
riskScore?: number
art30Completeness?: VVTCompleteness
// Processor-Record (Art. 30 Abs. 2)
export interface VVTProcessorActivity {
id: string
vvtId: string
controllerReference: string
processingCategories: string[]
subProcessorChain: SubProcessor[]
thirdCountryTransfers: { country: string; recipient: string; transferMechanism: string }[]
tomDescription: string
status: 'DRAFT' | 'REVIEW' | 'APPROVED' | 'ARCHIVED'
}
export interface SubProcessor {
name: string
purpose: string
country: string
isThirdCountry: boolean
}
// =============================================================================
@@ -182,14 +186,6 @@ export const ART9_CATEGORIES: string[] = [
'CRIMINAL_DATA',
]
export interface VVTCompleteness {
score: number
missing: string[]
warnings: string[]
passed: number
total: number
}
// =============================================================================
// HELPER: Create empty activity
// =============================================================================

View File

@@ -1,53 +0,0 @@
-- Wiki Article: BetrVG & KI — Mitbestimmung bei IT-Systemen
-- Kategorie: arbeitsrecht (existiert bereits)
-- Ausfuehren auf Production-DB nach Compliance-Refactoring
INSERT INTO compliance.compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls, version)
VALUES
('betrvg-ki-mitbestimmung', 'arbeitsrecht',
'BetrVG & KI — Mitbestimmung bei IT-Systemen',
'Uebersicht der Mitbestimmungsrechte des Betriebsrats bei Einfuehrung von KI- und IT-Systemen gemaess §87 Abs.1 Nr.6 BetrVG. Inkl. BAG-Rechtsprechung und Konflikt-Score.',
'# BetrVG & KI — Mitbestimmung bei IT-Systemen
## Kernregel: §87 Abs.1 Nr.6 BetrVG
Die **Einfuehrung und Anwendung** von technischen Einrichtungen, die dazu **geeignet** sind, das Verhalten oder die Leistung der Arbeitnehmer zu ueberwachen, beduerfen der **Zustimmung des Betriebsrats**.
### Wichtig: Eignung genuegt!
Das BAG hat klargestellt: Bereits die **objektive Eignung** zur Ueberwachung genuegt — eine tatsaechliche Nutzung zu diesem Zweck ist nicht erforderlich.
---
## Leitentscheidungen des BAG
### Microsoft Office 365 (BAG 1 ABR 20/21, 08.03.2022)
Das BAG hat ausdruecklich entschieden, dass Microsoft Office 365 der Mitbestimmung unterliegt.
### Standardsoftware (BAG 1 ABN 36/18, 23.10.2018)
Auch alltaegliche Standardsoftware wie Excel ist mitbestimmungsrelevant. Keine Geringfuegigkeitsschwelle.
### SAP ERP (BAG 1 ABR 45/11, 25.09.2012)
HR-/ERP-Systeme erheben und verknuepfen individualisierbare Verhaltens- und Leistungsdaten.
### SaaS/Cloud (BAG 1 ABR 68/13, 21.07.2015)
Auch bei Ueberwachung ueber Dritt-Systeme bleibt der Betriebsrat zu beteiligen.
### Belastungsstatistik (BAG 1 ABR 46/15, 25.04.2017)
Dauerhafte Kennzahlenueberwachung ist ein schwerwiegender Eingriff in das Persoenlichkeitsrecht.
---
## Betriebsrats-Konflikt-Score (SDK)
Das SDK berechnet automatisch einen Konflikt-Score (0-100):
- Beschaeftigtendaten (+10), Ueberwachungseignung (+20), HR-Bezug (+20)
- Individualisierbare Logs (+15), Kommunikationsanalyse (+10)
- Scoring/Ranking (+10), Vollautomatisiert (+10), Keine BR-Konsultation (+5)
Eskalation: Score >= 50 ohne BR → E2, Score >= 75 → E3.',
'["§87 Abs.1 Nr.6 BetrVG", "§90 BetrVG", "§94 BetrVG", "§95 BetrVG", "Art. 88 DSGVO", "§26 BDSG"]',
ARRAY['BetrVG', 'Mitbestimmung', 'Betriebsrat', 'KI', 'Ueberwachung', 'Microsoft 365'],
'critical',
'["https://www.bundesarbeitsgericht.de/entscheidung/1-abr-20-21/", "https://www.bundesarbeitsgericht.de/entscheidung/1-abn-36-18/"]',
1)
ON CONFLICT (id) DO UPDATE SET content = EXCLUDED.content, summary = EXCLUDED.summary, updated_at = NOW();

View File

@@ -1,157 +0,0 @@
-- Wiki Articles: Domain-spezifische KI-Compliance
-- 4 Artikel fuer die wichtigsten Hochrisiko-Domains
-- 1. KI im Recruiting
INSERT INTO compliance.compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls, version)
VALUES ('ki-recruiting-compliance', 'arbeitsrecht',
'KI im Recruiting — AGG, DSGVO Art. 22, AI Act Hochrisiko',
'Compliance-Anforderungen bei KI-gestuetzter Personalauswahl: Automatisierte Absagen, Bias-Risiken, Beweislastumkehr.',
'# KI im Recruiting — Compliance-Anforderungen
## AI Act Einstufung
KI im Recruiting faellt unter **Annex III Nr. 4 (Employment)** = **High-Risk**.
## Kritische Punkte
### Art. 22 DSGVO — Automatisierte Entscheidungen
Vollautomatische Absagen ohne menschliche Pruefung sind **grundsaetzlich unzulaessig**.
Erlaubt: KI erstellt Vorschlag → Mensch prueft → Mensch entscheidet → Mensch gibt Absage frei.
### AGG — Diskriminierungsverbot
- § 1 AGG: Keine Benachteiligung nach Geschlecht, Alter, Herkunft, Religion, Behinderung
- § 22 AGG: **Beweislastumkehr** — Arbeitgeber muss beweisen, dass KEINE Diskriminierung vorliegt
- § 15 AGG: Schadensersatz bis 3 Monatsgehaelter pro Fall
- Proxy-Merkmale vermeiden: Name→Herkunft, Foto→Alter
### BetrVG — Mitbestimmung
- § 87 Abs. 1 Nr. 6: Betriebsrat muss zustimmen
- § 95: Auswahlrichtlinien mitbestimmungspflichtig
- BAG 1 ABR 20/21: Gilt auch fuer Standardsoftware
## Pflichtmassnahmen
1. Human-in-the-Loop (echt, kein Rubber Stamping)
2. Regelmaessige Bias-Audits
3. DSFA durchfuehren
4. Betriebsvereinbarung abschliessen
5. Bewerber ueber KI-Nutzung informieren',
'["Art. 22 DSGVO", "§ 1 AGG", "§ 22 AGG", "§ 15 AGG", "§ 87 BetrVG", "§ 95 BetrVG", "Annex III Nr. 4 AI Act"]',
ARRAY['Recruiting', 'HR', 'AGG', 'Bias', 'Art. 22', 'Beweislastumkehr', 'Betriebsrat'],
'critical',
'["https://www.bundesarbeitsgericht.de/entscheidung/1-abr-20-21/"]',
1)
ON CONFLICT (id) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
-- 2. KI in der Bildung
INSERT INTO compliance.compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls, version)
VALUES ('ki-bildung-compliance', 'branchenspezifisch',
'KI in der Bildung — Notenvergabe, Pruefungsbewertung, Minderjaehrige',
'AI Act Annex III Nr. 3: Hochrisiko bei KI-gestuetzter Bewertung in Bildung und Ausbildung.',
'# KI in der Bildung — Compliance-Anforderungen
## AI Act Einstufung
KI in Bildung/Ausbildung faellt unter **Annex III Nr. 3 (Education)** = **High-Risk**.
## Kritische Szenarien
- KI beeinflusst Noten → High-Risk
- KI bewertet Pruefungen → High-Risk
- KI steuert Zugang zu Bildungsangeboten → High-Risk
- Minderjaehrige betroffen → Besonderer Schutz (Art. 24 EU-Grundrechtecharta)
## BLOCK-Regel
**Minderjaehrige betroffen + keine Lehrkraft-Pruefung = UNZULAESSIG**
## Pflichtmassnahmen
1. Lehrkraft prueft JEDES KI-Ergebnis vor Mitteilung an Schueler
2. Chancengleichheit unabhaengig von sozioekonomischem Hintergrund
3. Keine Benachteiligung durch Sprache oder Behinderung
4. FRIA durchfuehren (Grundrechte-Folgenabschaetzung)
5. DSFA bei Verarbeitung von Schuelerdaten
## Grundrechte
- Recht auf Bildung (Art. 14 EU-Charta)
- Rechte des Kindes (Art. 24 EU-Charta)
- Nicht-Diskriminierung (Art. 21 EU-Charta)',
'["Annex III Nr. 3 AI Act", "Art. 14 EU-Grundrechtecharta", "Art. 24 EU-Grundrechtecharta", "Art. 35 DSGVO"]',
ARRAY['Bildung', 'Education', 'Noten', 'Pruefung', 'Minderjaehrige', 'Schule'],
'critical',
'[]',
1)
ON CONFLICT (id) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
-- 3. KI im Gesundheitswesen
INSERT INTO compliance.compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls, version)
VALUES ('ki-gesundheit-compliance', 'branchenspezifisch',
'KI im Gesundheitswesen — MDR, Diagnose, Triage',
'AI Act Annex III Nr. 5 + MDR: Hochrisiko bei KI in Diagnose, Behandlung und Triage.',
'# KI im Gesundheitswesen — Compliance-Anforderungen
## Regulatorischer Rahmen
- **AI Act Annex III Nr. 5** — Zugang zu wesentlichen Diensten (Gesundheit)
- **MDR (EU) 2017/745** — Medizinprodukteverordnung
- **DSGVO Art. 9** — Gesundheitsdaten = besondere Kategorie
## Kritische Szenarien
- KI unterstuetzt Diagnosen → High-Risk + DSFA Pflicht
- KI priorisiert Patienten (Triage) → Lebenskritisch, hoechste Anforderungen
- KI empfiehlt Behandlungen → High-Risk
- System ist Medizinprodukt → MDR-Zertifizierung erforderlich
## BLOCK-Regeln
- **Medizinprodukt ohne klinische Validierung = UNZULAESSIG**
- MDR Art. 61: Klinische Bewertung ist Pflicht
## Grundrechte
- Menschenwuerde (Art. 1 EU-Charta)
- Schutz personenbezogener Daten (Art. 8 EU-Charta)
- Patientenautonomie
## Pflichtmassnahmen
1. Klinische Validierung vor Einsatz
2. Human Oversight durch qualifiziertes Fachpersonal
3. DSFA fuer Gesundheitsdatenverarbeitung
4. Genauigkeitsmetriken definieren und messen
5. Incident Reporting bei Fehlfunktionen',
'["Annex III Nr. 5 AI Act", "MDR (EU) 2017/745", "Art. 9 DSGVO", "Art. 35 DSGVO"]',
ARRAY['Gesundheit', 'Healthcare', 'MDR', 'Diagnose', 'Triage', 'Medizinprodukt'],
'critical',
'[]',
1)
ON CONFLICT (id) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();
-- 4. KI in Finanzdienstleistungen
INSERT INTO compliance.compliance_wiki_articles (id, category_id, title, summary, content, legal_refs, tags, relevance, source_urls, version)
VALUES ('ki-finance-compliance', 'branchenspezifisch',
'KI in Finanzdienstleistungen — Scoring, DORA, Versicherung',
'AI Act Annex III Nr. 5 + DORA + MaRisk: Compliance bei Kredit-Scoring, Algo-Trading, Versicherungspraemien.',
'# KI in Finanzdienstleistungen — Compliance-Anforderungen
## Regulatorischer Rahmen
- **AI Act Annex III Nr. 5** — Zugang zu wesentlichen Diensten
- **DORA** — Digital Operational Resilience Act
- **MaRisk/BAIT** — Bankaufsichtliche Anforderungen
- **MiFID II** — Algorithmischer Handel
## Kritische Szenarien
- Kredit-Scoring → High-Risk (Art. 22 DSGVO + Annex III)
- Automatisierte Schadenbearbeitung → Art. 22 Risiko
- Individuelle Praemienberechnung → Diskriminierungsrisiko
- Algo-Trading → MiFID II Anforderungen
- Robo Advisor → WpHG-Pflichten
## Pflichtmassnahmen
1. Transparenz bei Scoring-Entscheidungen
2. Bias-Audits bei Kreditvergabe
3. Human Oversight bei Ablehnungen
4. DORA-konforme IT-Resilienz
5. Incident Reporting
## Besondere Risiken
- Diskriminierendes Kredit-Scoring (AGG + AI Act)
- Ungerechtfertigte Verweigerung von Finanzdienstleistungen
- Mangelnde Erklaerbarkeit bei Scoring-Algorithmen',
'["Annex III Nr. 5 AI Act", "DORA", "MaRisk", "MiFID II", "Art. 22 DSGVO", "§ 1 AGG"]',
ARRAY['Finance', 'Banking', 'Versicherung', 'Scoring', 'DORA', 'Kredit', 'Algo-Trading'],
'critical',
'[]',
1)
ON CONFLICT (id) DO UPDATE SET content = EXCLUDED.content, updated_at = NOW();

File diff suppressed because it is too large Load Diff

View File

@@ -18,14 +18,6 @@
"test:all": "vitest run && playwright test --project=chromium"
},
"dependencies": {
"@tiptap/extension-image": "^3.20.2",
"@tiptap/extension-table": "^3.20.2",
"@tiptap/extension-table-cell": "^3.20.2",
"@tiptap/extension-table-header": "^3.20.2",
"@tiptap/extension-table-row": "^3.20.2",
"@tiptap/pm": "^3.20.2",
"@tiptap/react": "^3.20.2",
"@tiptap/starter-kit": "^3.20.2",
"bpmn-js": "^18.0.1",
"jspdf": "^4.1.0",
"jszip": "^3.10.1",

View File

@@ -0,0 +1,55 @@
# ai-compliance-sdk
Go/Gin service providing AI-Act compliance analysis: iACE impact assessments, UCCA rules engine, hazard library, training/academy, audit, escalation, portfolio, RBAC, RAG, whistleblower, workshop.
**Port:** `8090` → exposed `8093` (container: `bp-compliance-ai-sdk`)
**Stack:** Go 1.24, Gin, pgx, Postgres.
## Architecture (target — Phase 2)
```
cmd/server/main.go # Thin entrypoint (<50 LOC)
internal/
├── app/ # Wiring + lifecycle
├── domain/<aggregate>/ # Types, interfaces, errors
├── service/<aggregate>/ # Business logic
├── repository/postgres/ # Repo implementations
├── transport/http/ # Gin handlers + middleware + router
└── platform/ # DB pool, logger, config, httperr
```
See `../AGENTS.go.md` for the full convention.
## Run locally
```bash
cd ai-compliance-sdk
go mod download
export COMPLIANCE_DATABASE_URL=...
go run ./cmd/server
```
## Tests
```bash
go test -race -cover ./...
golangci-lint run --timeout 5m ./...
```
Co-located `*_test.go`, table-driven. Repo layer uses testcontainers-go (or the compose Postgres) — no SQL mocks.
## Public API surface
Handlers under `internal/api/handlers/` (Phase 2 moves to `internal/transport/http/handler/`). Health at `GET /health`. iACE, UCCA, training, academy, portfolio, escalation, audit, rag, whistleblower, workshop subresources. Every route is a contract.
## Environment
| Var | Purpose |
|-----|---------|
| `COMPLIANCE_DATABASE_URL` | Postgres DSN |
| `LLM_GATEWAY_URL` | LLM router for rag/iACE |
| `QDRANT_URL` | Vector search |
## Don't touch
DB schema. Hand-rolled migrations elsewhere own it.

View File

@@ -104,18 +104,13 @@ func main() {
auditHandlers := handlers.NewAuditHandlers(auditStore, exporter)
uccaHandlers := handlers.NewUCCAHandlers(uccaStore, escalationStore, providerRegistry)
escalationHandlers := handlers.NewEscalationHandlers(escalationStore, uccaStore)
registrationStore := ucca.NewRegistrationStore(pool)
registrationHandlers := handlers.NewRegistrationHandlers(registrationStore, uccaStore)
paymentHandlers := handlers.NewPaymentHandlers(pool)
tenderHandlers := handlers.NewTenderHandlers(pool, paymentHandlers.GetControlLibrary())
roadmapHandlers := handlers.NewRoadmapHandlers(roadmapStore)
workshopHandlers := handlers.NewWorkshopHandlers(workshopStore)
portfolioHandlers := handlers.NewPortfolioHandlers(portfolioStore)
academyHandlers := handlers.NewAcademyHandlers(academyStore, trainingStore)
whistleblowerHandlers := handlers.NewWhistleblowerHandlers(whistleblowerStore)
iaceHandler := handlers.NewIACEHandler(iaceStore, providerRegistry)
blockGenerator := training.NewBlockGenerator(trainingStore, contentGenerator)
trainingHandlers := handlers.NewTrainingHandlers(trainingStore, contentGenerator, blockGenerator, ttsClient)
iaceHandler := handlers.NewIACEHandler(iaceStore)
trainingHandlers := handlers.NewTrainingHandlers(trainingStore, contentGenerator)
ragHandlers := handlers.NewRAGHandlers(corpusVersionStore)
// Initialize obligations framework (v2 with TOM mapping)
@@ -274,49 +269,10 @@ func main() {
uccaRoutes.POST("/escalations/:id/review", escalationHandlers.StartReview)
uccaRoutes.POST("/escalations/:id/decide", escalationHandlers.DecideEscalation)
// AI Act Decision Tree
dtRoutes := uccaRoutes.Group("/decision-tree")
{
dtRoutes.GET("", uccaHandlers.GetDecisionTree)
dtRoutes.POST("/evaluate", uccaHandlers.EvaluateDecisionTree)
dtRoutes.GET("/results", uccaHandlers.ListDecisionTreeResults)
dtRoutes.GET("/results/:id", uccaHandlers.GetDecisionTreeResult)
dtRoutes.DELETE("/results/:id", uccaHandlers.DeleteDecisionTreeResult)
}
// Obligations framework (v2 with TOM mapping)
obligationsHandlers.RegisterRoutes(uccaRoutes)
}
// AI Registration routes - EU AI Database (Art. 49)
regRoutes := v1.Group("/ai-registration")
{
regRoutes.POST("", registrationHandlers.Create)
regRoutes.GET("", registrationHandlers.List)
regRoutes.GET("/:id", registrationHandlers.Get)
regRoutes.PUT("/:id", registrationHandlers.Update)
regRoutes.PATCH("/:id/status", registrationHandlers.UpdateStatus)
regRoutes.POST("/prefill/:assessment_id", registrationHandlers.Prefill)
regRoutes.GET("/:id/export", registrationHandlers.Export)
}
// Payment Compliance routes
payRoutes := v1.Group("/payment-compliance")
{
payRoutes.GET("/controls", paymentHandlers.ListControls)
payRoutes.POST("/assessments", paymentHandlers.CreateAssessment)
payRoutes.GET("/assessments", paymentHandlers.ListAssessments)
payRoutes.GET("/assessments/:id", paymentHandlers.GetAssessment)
payRoutes.PATCH("/assessments/:id/verdict", paymentHandlers.UpdateControlVerdict)
// Tender Analysis
payRoutes.POST("/tender/upload", tenderHandlers.Upload)
payRoutes.POST("/tender/:id/extract", tenderHandlers.Extract)
payRoutes.POST("/tender/:id/match", tenderHandlers.Match)
payRoutes.GET("/tender", tenderHandlers.ListAnalyses)
payRoutes.GET("/tender/:id", tenderHandlers.GetAnalysis)
}
// RAG routes - Legal Corpus Search & Versioning
ragRoutes := v1.Group("/rag")
{
@@ -324,7 +280,6 @@ func main() {
ragRoutes.GET("/regulations", ragHandlers.ListRegulations)
ragRoutes.GET("/corpus-status", ragHandlers.CorpusStatus)
ragRoutes.GET("/corpus-versions/:collection", ragHandlers.CorpusVersionHistory)
ragRoutes.GET("/scroll", ragHandlers.HandleScrollChunks)
}
// Roadmap routes - Compliance Implementation Roadmaps
@@ -477,7 +432,6 @@ func main() {
trainingRoutes.GET("/modules/:id", trainingHandlers.GetModule)
trainingRoutes.POST("/modules", trainingHandlers.CreateModule)
trainingRoutes.PUT("/modules/:id", trainingHandlers.UpdateModule)
trainingRoutes.DELETE("/modules/:id", trainingHandlers.DeleteModule)
// Compliance Training Matrix (CTM)
trainingRoutes.GET("/matrix", trainingHandlers.GetMatrix)
@@ -492,7 +446,6 @@ func main() {
trainingRoutes.POST("/assignments/:id/start", trainingHandlers.StartAssignment)
trainingRoutes.POST("/assignments/:id/progress", trainingHandlers.UpdateAssignmentProgress)
trainingRoutes.POST("/assignments/:id/complete", trainingHandlers.CompleteAssignment)
trainingRoutes.PUT("/assignments/:id", trainingHandlers.UpdateAssignment)
// Quiz
trainingRoutes.GET("/quiz/:moduleId", trainingHandlers.GetQuiz)
@@ -525,10 +478,6 @@ func main() {
c.Params = append(c.Params, gin.Param{Key: "id", Value: c.Param("mediaId")})
trainingHandlers.PublishMedia(c)
})
trainingRoutes.GET("/media/:mediaId/stream", func(c *gin.Context) {
c.Params = append(c.Params, gin.Param{Key: "id", Value: c.Param("mediaId")})
trainingHandlers.StreamMedia(c)
})
// Deadlines & Escalation
trainingRoutes.GET("/deadlines", trainingHandlers.GetDeadlines)
@@ -540,30 +489,7 @@ func main() {
trainingRoutes.GET("/stats", trainingHandlers.GetStats)
// Certificates
trainingRoutes.POST("/certificates/generate/:assignmentId", trainingHandlers.GenerateCertificate)
trainingRoutes.GET("/certificates", trainingHandlers.ListCertificates)
trainingRoutes.GET("/certificates/:id/verify", trainingHandlers.VerifyCertificate)
trainingRoutes.GET("/certificates/:id/pdf", trainingHandlers.DownloadCertificatePDF)
// Training Blocks — Controls → Schulungsmodule Pipeline
trainingRoutes.GET("/blocks", trainingHandlers.ListBlockConfigs)
trainingRoutes.POST("/blocks", trainingHandlers.CreateBlockConfig)
trainingRoutes.GET("/blocks/:id", trainingHandlers.GetBlockConfig)
trainingRoutes.PUT("/blocks/:id", trainingHandlers.UpdateBlockConfig)
trainingRoutes.DELETE("/blocks/:id", trainingHandlers.DeleteBlockConfig)
trainingRoutes.POST("/blocks/:id/preview", trainingHandlers.PreviewBlock)
trainingRoutes.POST("/blocks/:id/generate", trainingHandlers.GenerateBlock)
trainingRoutes.GET("/blocks/:id/controls", trainingHandlers.GetBlockControls)
// Canonical Controls Browsing
trainingRoutes.GET("/canonical/controls", trainingHandlers.ListCanonicalControls)
trainingRoutes.GET("/canonical/meta", trainingHandlers.GetCanonicalMeta)
// Interactive Video (Narrator + Checkpoints)
trainingRoutes.POST("/content/:moduleId/generate-interactive", trainingHandlers.GenerateInteractiveVideo)
trainingRoutes.GET("/content/:moduleId/interactive-manifest", trainingHandlers.GetInteractiveManifest)
trainingRoutes.POST("/checkpoints/:checkpointId/submit", trainingHandlers.SubmitCheckpointQuiz)
trainingRoutes.GET("/checkpoints/progress/:assignmentId", trainingHandlers.GetCheckpointProgress)
}
// Whistleblower routes - Hinweisgebersystem (HinSchG)
@@ -597,18 +523,6 @@ func main() {
iaceRoutes.GET("/hazard-library", iaceHandler.ListHazardLibrary)
// Controls Library (project-independent)
iaceRoutes.GET("/controls-library", iaceHandler.ListControlsLibrary)
// ISO 12100 reference data (project-independent)
iaceRoutes.GET("/lifecycle-phases", iaceHandler.ListLifecyclePhases)
iaceRoutes.GET("/roles", iaceHandler.ListRoles)
iaceRoutes.GET("/evidence-types", iaceHandler.ListEvidenceTypes)
iaceRoutes.GET("/protective-measures-library", iaceHandler.ListProtectiveMeasures)
// Component Library & Energy Sources (Hazard Matching Engine)
iaceRoutes.GET("/component-library", iaceHandler.ListComponentLibrary)
iaceRoutes.GET("/energy-sources", iaceHandler.ListEnergySources)
// Tag Taxonomy
iaceRoutes.GET("/tags", iaceHandler.ListTags)
// Hazard Patterns
iaceRoutes.GET("/hazard-patterns", iaceHandler.ListHazardPatterns)
// Project Management
iaceRoutes.POST("/projects", iaceHandler.CreateProject)
@@ -638,12 +552,6 @@ func main() {
iaceRoutes.PUT("/projects/:id/hazards/:hid", iaceHandler.UpdateHazard)
iaceRoutes.POST("/projects/:id/hazards/suggest", iaceHandler.SuggestHazards)
// Pattern Matching Engine
iaceRoutes.POST("/projects/:id/match-patterns", iaceHandler.MatchPatterns)
iaceRoutes.POST("/projects/:id/apply-patterns", iaceHandler.ApplyPatternResults)
iaceRoutes.POST("/projects/:id/hazards/:hid/suggest-measures", iaceHandler.SuggestMeasuresForHazard)
iaceRoutes.POST("/projects/:id/mitigations/:mid/suggest-evidence", iaceHandler.SuggestEvidenceForMitigation)
// Risk Assessment
iaceRoutes.POST("/projects/:id/hazards/:hid/assess", iaceHandler.AssessRisk)
iaceRoutes.GET("/projects/:id/risk-summary", iaceHandler.GetRiskSummary)
@@ -653,7 +561,6 @@ func main() {
iaceRoutes.POST("/projects/:id/hazards/:hid/mitigations", iaceHandler.CreateMitigation)
iaceRoutes.PUT("/mitigations/:mid", iaceHandler.UpdateMitigation)
iaceRoutes.POST("/mitigations/:mid/verify", iaceHandler.VerifyMitigation)
iaceRoutes.POST("/projects/:id/validate-mitigation-hierarchy", iaceHandler.ValidateMitigationHierarchy)
// Evidence
iaceRoutes.POST("/projects/:id/evidence", iaceHandler.UploadEvidence)
@@ -669,7 +576,6 @@ func main() {
iaceRoutes.GET("/projects/:id/tech-file", iaceHandler.ListTechFileSections)
iaceRoutes.PUT("/projects/:id/tech-file/:section", iaceHandler.UpdateTechFileSection)
iaceRoutes.POST("/projects/:id/tech-file/:section/approve", iaceHandler.ApproveTechFileSection)
iaceRoutes.POST("/projects/:id/tech-file/:section/generate", iaceHandler.GenerateSingleSection)
iaceRoutes.GET("/projects/:id/tech-file/export", iaceHandler.ExportTechFile)
// Monitoring
@@ -679,10 +585,6 @@ func main() {
// Audit Trail
iaceRoutes.GET("/projects/:id/audit-trail", iaceHandler.GetAuditTrail)
// RAG Library Search (Phase 6)
iaceRoutes.POST("/library-search", iaceHandler.SearchLibrary)
iaceRoutes.POST("/projects/:id/tech-file/:section/enrich", iaceHandler.EnrichTechFileSection)
}
}

View File

@@ -1,270 +0,0 @@
Evidence Library — 50 Nachweisarten (vollständig beschrieben)
Jeder Nachweis dient dazu, die Wirksamkeit einer Schutzmaßnahme oder Sicherheitsanforderung nachzuweisen. Die Struktur ist so gestaltet, dass sie direkt in eine Compliance oder CEDokumentationsEngine integriert werden kann.
Struktur eines Nachweises
evidence_id
title
purpose
verification_method
typical_steps
expected_result
generated_document
### E01 Konstruktionsreview
**Purpose:** Überprüfung der sicherheitsrelevanten Konstruktion. Verification Method: Engineering Review. Typical Steps: Zeichnungen prüfen, Gefahrenstellen identifizieren, Schutzmaßnahmen bewerten. Expected Result: Konstruktion erfüllt Sicherheitsanforderungen. Generated Document: ReviewProtokoll.
### E02 Sicherheitskonzept
**Purpose:** Dokumentation der Sicherheitsarchitektur. Verification Method: Architekturprüfung. Typical Steps: Systemgrenzen definieren, Schutzkonzept beschreiben. Expected Result: vollständiges Sicherheitskonzept. Generated Document: Sicherheitsdokument.
### E03 Gefährdungsanalyse
**Purpose:** Identifikation aller relevanten Gefährdungen. Verification Method: strukturierte Analyse. Typical Steps: Gefährdungsliste erstellen, Risikobewertung durchführen. Expected Result: vollständige Hazard List. Generated Document: Risikoanalysebericht.
### E04 Sicherheitsabstandsberechnung
**Purpose:** Nachweis sicherer Mindestabstände. Verification Method: mathematische Berechnung. Typical Steps: Bewegungsenergie bestimmen, Distanz berechnen. Expected Result: Mindestabstand erfüllt Anforderungen. Generated Document: Berechnungsprotokoll.
### E05 Festigkeitsnachweis
**Purpose:** strukturelle Stabilität sicherstellen. Verification Method: statische Berechnung oder Simulation. Typical Steps: Belastungen definieren, Struktur analysieren. Expected Result: Bauteil hält Belastungen stand. Generated Document: Festigkeitsbericht.
### E06 Risikoanalysebericht
**Purpose:** Dokumentation der Risikobeurteilung. Verification Method: Risikomodell. Typical Steps: Gefährdungen bewerten, Maßnahmen definieren. Expected Result: akzeptables Restrisiko. Generated Document: Risikobeurteilung.
### E07 Architekturdiagramm
**Purpose:** Darstellung der Systemarchitektur. Verification Method: Systemmodellierung. Typical Steps: Komponenten und Schnittstellen beschreiben. Expected Result: nachvollziehbare Systemstruktur. Generated Document: Architekturdiagramm.
### E08 SoftwareDesignreview
**Purpose:** Bewertung des Softwaredesigns. Verification Method: Entwicklerreview. Typical Steps: Architektur analysieren, Sicherheitslogik prüfen. Expected Result: robustes Design. Generated Document: Reviewbericht.
### E09 Code Review
**Purpose:** Fehler und Sicherheitsprobleme erkennen. Verification Method: Peer Review. Typical Steps: Quellcode analysieren. Expected Result: sicherer und wartbarer Code. Generated Document: CodeReviewProtokoll.
### E10 Sicherheitsanforderungsdokument
**Purpose:** Definition der Sicherheitsanforderungen. Verification Method: Dokumentationsprüfung. Typical Steps: Anforderungen sammeln und validieren. Expected Result: vollständige Security Requirements. Generated Document: Requirements Dokument.
### E11 Funktionstest
**Purpose:** Überprüfung der Systemfunktion. Verification Method: Testfallausführung. Typical Steps: Testfälle definieren, Ergebnisse dokumentieren. Expected Result: Funktionen arbeiten korrekt. Generated Document: Testprotokoll.
### E12 Integrationstest
**Purpose:** Zusammenspiel von Komponenten prüfen. Verification Method: Systemtests. Typical Steps: Schnittstellen testen. Expected Result: korrekte Interaktion. Generated Document: Integrationsbericht.
### E13 Systemtest
**Purpose:** Gesamtfunktion der Maschine prüfen. Verification Method: EndtoEnd Test. Typical Steps: reale Betriebsbedingungen simulieren. Expected Result: System arbeitet stabil. Generated Document: Systemtestbericht.
### E14 Sicherheitsfunktionstest
**Purpose:** Wirksamkeit der Sicherheitsfunktion prüfen. Verification Method: gezielte Auslösung. Typical Steps: Sicherheitsfunktion aktivieren. Expected Result: sichere Reaktion. Generated Document: Sicherheitsprotokoll.
### E15 NotHalt Test
**Purpose:** Funktion des NotHalts sicherstellen. Verification Method: manuelle Betätigung. Typical Steps: NotHalt drücken, Stopzeit messen. Expected Result: Maschine stoppt sofort. Generated Document: Testbericht.
### E16 Verriegelungstest
**Purpose:** Schutzsystem prüfen. Verification Method: mechanischer Test. Typical Steps: Tür öffnen während Betrieb. Expected Result: Maschine stoppt. Generated Document: Prüfprotokoll.
### E17 Fault Injection Test
**Purpose:** Fehlerreaktionen prüfen. Verification Method: simulierte Fehler. Typical Steps: Sensorfehler auslösen. Expected Result: sichere Reaktion. Generated Document: Testreport.
### E18 Simulationstest
**Purpose:** Verhalten im Modell prüfen. Verification Method: Simulation. Typical Steps: Szenarien simulieren. Expected Result: korrektes Verhalten. Generated Document: Simulationsbericht.
### E19 Lasttest
**Purpose:** Verhalten unter Last prüfen. Verification Method: Belastungstest. Typical Steps: maximale Last anwenden. Expected Result: System bleibt stabil. Generated Document: Lasttestbericht.
### E20 Stresstest
**Purpose:** Extrembedingungen prüfen. Verification Method: Überlastsimulation. Typical Steps: Grenzwerte testen. Expected Result: System bleibt kontrollierbar. Generated Document: Stresstestbericht.
### E21 Schutzleiterprüfung
**Purpose:** Erdung überprüfen. Verification Method: elektrische Messung. Expected Result: ausreichende Leitfähigkeit. Generated Document: Messprotokoll.
### E22 Isolationsmessung
**Purpose:** elektrische Isolation prüfen. Verification Method: Hochspannungsmessung. Expected Result: Isolation ausreichend. Generated Document: Prüfbericht.
### E23 Hochspannungsprüfung
**Purpose:** elektrische Sicherheit testen. Verification Method: HVTest. Expected Result: keine Durchschläge. Generated Document: Testprotokoll.
### E24 Kurzschlussprüfung
**Purpose:** Verhalten bei Kurzschluss prüfen. Verification Method: Simulation. Expected Result: sichere Abschaltung. Generated Document: Testbericht.
### E25 Erdungsmessung
**Purpose:** Erdungssystem validieren. Verification Method: Widerstandsmessung. Expected Result: zulässiger Erdungswert. Generated Document: Messprotokoll.
### E26 Penetration Test
**Purpose:** ITSicherheit prüfen. Verification Method: Angriffssimulation. Expected Result: keine kritischen Schwachstellen. Generated Document: PentestReport.
### E27 Vulnerability Scan
**Purpose:** bekannte Schwachstellen erkennen. Verification Method: automatisierter Scan. Expected Result: Schwachstellenliste. Generated Document: Scanbericht.
### E28 SBOM Prüfung
**Purpose:** Softwareabhängigkeiten prüfen. Verification Method: Komponentenliste analysieren. Expected Result: bekannte Risiken erkannt. Generated Document: SBOMReport.
### E29 Dependency Scan
**Purpose:** Bibliotheken prüfen. Verification Method: CVEAbgleich. Expected Result: keine kritischen Abhängigkeiten. Generated Document: Scanreport.
### E30 UpdateSignaturprüfung
**Purpose:** Authentizität von Updates prüfen. Verification Method: kryptographische Validierung. Expected Result: gültige Signatur. Generated Document: Verifikationsprotokoll.
### E31 Betriebsanleitung
**Purpose:** sichere Nutzung dokumentieren. Verification Method: Dokumentationsprüfung. Expected Result: vollständige Anleitung. Generated Document: Handbuch.
### E32 Wartungsanleitung
**Purpose:** sichere Wartung ermöglichen. Verification Method: Review. Expected Result: klare Wartungsprozesse. Generated Document: Wartungsdokument.
### E33 Sicherheitsanweisung
**Purpose:** Sicherheitsregeln festlegen. Verification Method: Freigabeprozess. Expected Result: verbindliche Richtlinie. Generated Document: Sicherheitsdokument.
### E34 Schulungsnachweis
**Purpose:** Kompetenz nachweisen. Verification Method: Teilnahmeprotokoll. Expected Result: Mitarbeiter geschult. Generated Document: Trainingszertifikat.
### E35 Risikoabnahmeprotokoll
**Purpose:** Freigabe der Risikobeurteilung. Verification Method: Managementreview. Expected Result: Risiko akzeptiert. Generated Document: Freigabedokument.
### E36 Freigabedokument
**Purpose:** formale Systemfreigabe. Verification Method: Genehmigungsprozess. Expected Result: System genehmigt. Generated Document: Freigabeprotokoll.
### E37 Änderungsprotokoll
**Purpose:** Änderungen nachvollziehen. Verification Method: Change Management. Expected Result: Änderungsverlauf dokumentiert. Generated Document: Change Log.
### E38 Auditbericht
**Purpose:** Compliance prüfen. Verification Method: Audit. Expected Result: Audit ohne kritische Abweichungen. Generated Document: Auditreport.
### E39 Abnahmeprotokoll
**Purpose:** Endabnahme dokumentieren. Verification Method: Abnahmetest. Expected Result: System akzeptiert. Generated Document: Abnahmebericht.
### E40 Prüfprotokoll
**Purpose:** Prüfergebnisse festhalten. Verification Method: standardisierte Tests. Expected Result: erfolgreiche Prüfung. Generated Document: Prüfprotokoll.
### E41 MonitoringLogs
**Purpose:** Betriebsüberwachung. Verification Method: Loganalyse. Expected Result: keine Sicherheitsereignisse. Generated Document: Logreport.
### E42 Ereignisprotokolle
**Purpose:** sicherheitsrelevante Ereignisse dokumentieren. Verification Method: Ereignisaufzeichnung. Expected Result: vollständige Historie. Generated Document: Ereignisbericht.
### E43 Alarmberichte
**Purpose:** Systemalarme dokumentieren. Verification Method: Alarmanalyse. Expected Result: nachvollziehbare Alarmhistorie. Generated Document: Alarmreport.
### E44 IncidentReport
**Purpose:** Sicherheitsvorfall dokumentieren. Verification Method: Incident Management. Expected Result: Ursachenanalyse abgeschlossen. Generated Document: Incidentbericht.
### E45 Wartungsbericht
**Purpose:** Wartungsarbeiten dokumentieren. Verification Method: Servicebericht. Expected Result: Wartung durchgeführt. Generated Document: Wartungsprotokoll.
### E46 Redundanzprüfung
**Purpose:** Redundante Systeme testen. Verification Method: FailoverTest. Expected Result: System bleibt funktionsfähig. Generated Document: Redundanzbericht.
### E47 Sicherheitsvalidierung
**Purpose:** Gesamtvalidierung der Sicherheitsfunktionen. Verification Method: kombinierte Tests. Expected Result: Sicherheitsanforderungen erfüllt. Generated Document: Validierungsbericht.
### E48 CyberSecurityAudit
**Purpose:** ITSicherheitsprüfung. Verification Method: Auditverfahren. Expected Result: Sicherheitsniveau bestätigt. Generated Document: Auditbericht.
### E49 Konfigurationsprüfung
**Purpose:** Systemkonfiguration prüfen. Verification Method: Konfigurationsreview. Expected Result: sichere Einstellungen. Generated Document: Konfigurationsbericht.
### E50 Endabnahmebericht
**Purpose:** finale Systemfreigabe. Verification Method: Abschlussprüfung. Expected Result: Maschine freigegeben. Generated Document: Endabnahmebericht.

File diff suppressed because it is too large Load Diff

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