15 Commits

Author SHA1 Message Date
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
472 changed files with 61014 additions and 171377 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 + Orca
### 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!) |
| **Orca** | 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 Orca.
**WICHTIG:** Code wird auf dem MacBook bearbeitet. Production-Deployment laeuft automatisch auf Hetzner via CI/CD.
### Entwicklungsworkflow (CI/CD — Orca)
### 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 origin main && git push gitea main
# 3. FERTIG! Push auf gitea triggert automatisch:
# - Gitea Actions: Lint → Tests → Validierung
# - Orca: 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 Orca auf "Redeploy" klicken — Gitea Actions triggert Orca 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 Orca-Logs.
**Ablauf im Terminal:**
```
> git push origin main ✓
> "Deploy gestartet, ich ueberwache den Status..."
> [Hintergrund-Polling laeuft]
> "Deploy abgeschlossen! Alle Services healthy. Du kannst jetzt testen."
```
### CI/CD Pipeline (Gitea Actions → Orca)
### 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
→ Orca: 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 Orca 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 (Orca-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 Orca 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,8 +225,8 @@ breakpilot-compliance/
### Deployment (CI/CD — Standardweg)
```bash
# Committen und pushen → Orca deployt automatisch:
git push origin main
# Committen und pushen → CI/CD deployt automatisch auf Hetzner:
git push origin main && git push gitea main
# CI-Status pruefen (im Browser):
# https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions
@@ -233,11 +240,11 @@ curl -sf https://sdk-dev.breakpilot.ai/health
```bash
# Zu BEIDEN Remotes pushen (PFLICHT! — vom MacBook):
git push origin main
git push origin main && git push gitea main
# Remotes:
# origin: lokale Gitea (macmini:3003)
# gitea: gitea.meghsakha.com:22222
```
### Lokale Docker-Befehle (Mac Mini — nur fuer Dev/Tests)
@@ -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

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

View File

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

View File

@@ -5,8 +5,8 @@
#
# Phasen: gesetze, eu, templates, datenschutz, verbraucherschutz, verify, version, all
#
# Voraussetzung: RAG-Service und Qdrant muessen auf Orca laufen.
# Die BreakPilot-Services muessen deployed sein (ci.yaml deploy-orca).
# 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

@@ -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

@@ -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

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

View File

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

View File

@@ -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>
)
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,27 +1,209 @@
'use client'
import { useState, useEffect } from 'react'
import { Shield, FileText } from 'lucide-react'
import {
Shield, BookOpen, ExternalLink, CheckCircle2, AlertTriangle,
Lock, Scale, FileText, Eye, ArrowLeft,
} from 'lucide-react'
import Link from 'next/link'
import { PROVENANCE_SECTIONS } from './_data/provenance-sections'
import { MarkdownRenderer } from './_components/MarkdownRenderer'
import { LicenseMatrix } from './_components/LicenseMatrix'
import { SourceRegistry } from './_components/SourceRegistry'
// =============================================================================
// TYPES
// =============================================================================
interface LicenseInfo {
license_id: string; name: string; terms_url: string | null; commercial_use: string
ai_training_restriction: string | null; tdm_allowed_under_44b: string | null
deletion_required: boolean; notes: string | null
license_id: string
name: string
terms_url: string | null
commercial_use: string
ai_training_restriction: string | null
tdm_allowed_under_44b: string | null
deletion_required: boolean
notes: string | null
}
interface SourceInfo {
source_id: string; title: string; publisher: string; url: string | null
version_label: string | null; language: string; license_id: string; license_name: string
commercial_use: string; allowed_analysis: boolean; allowed_store_excerpt: boolean
allowed_ship_embeddings: boolean; allowed_ship_in_product: boolean
vault_retention_days: number; vault_access_tier: string
source_id: string
title: string
publisher: string
url: string | null
version_label: string | null
language: string
license_id: string
license_name: string
commercial_use: string
allowed_analysis: boolean
allowed_store_excerpt: boolean
allowed_ship_embeddings: boolean
allowed_ship_in_product: boolean
vault_retention_days: number
vault_access_tier: string
}
// =============================================================================
// STATIC PROVENANCE DOCUMENTATION
// =============================================================================
const PROVENANCE_SECTIONS = [
{
id: 'methodology',
title: 'Methodik der Control-Erstellung',
content: `## Unabhaengige Formulierung
Alle Controls in der Canonical Control Library wurden **eigenstaendig formuliert** und folgen einer
**unabhaengigen Taxonomie**. Es werden keine proprietaeren Bezeichner, Nummern oder Strukturen
aus geschuetzten Quellen uebernommen.
### Dreistufiger Prozess
1. **Offene Recherche** — Identifikation von Security-Anforderungen aus oeffentlichen, frei zugaenglichen
Frameworks (OWASP, NIST, ENISA). Jede Anforderung wird aus mindestens 2 unabhaengigen offenen Quellen belegt.
2. **Eigenstaendige Formulierung** — Jedes Control wird mit eigener Sprache, eigener Struktur und eigener
Taxonomie (z.B. AUTH-001, NET-001) verfasst. Kein Copy-Paste, keine Paraphrase geschuetzter Texte.
3. **Too-Close-Pruefung** — Automatisierte Aehnlichkeitspruefung gegen Quelltexte mit 5 Metriken
(Token Overlap, N-Gram Jaccard, Embedding Cosine, LCS Ratio, Exact-Phrase). Nur Controls mit
Status PASS oder WARN (+ Human Review) werden freigegeben.
### Rechtliche Grundlage
- **UrhG §44b** — Text & Data Mining erlaubt fuer Analyse; Kopien werden danach geloescht
- **UrhG §23** — Hinreichender Abstand zum Originalwerk durch eigene Formulierung
- **BSI Nutzungsbedingungen** — Kommerzielle Nutzung nur mit Zustimmung; wir nutzen BSI-Dokumente
ausschliesslich als Analysegrundlage, nicht im Produkt`,
},
{
id: 'taxonomy',
title: 'Unabhaengige Taxonomie',
content: `## Eigenes Klassifikationssystem
Die Canonical Control Library verwendet ein **eigenes Domain-Schema**, das sich bewusst von
proprietaeren Frameworks unterscheidet:
| 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, NET-002). Dieses Format ist
**nicht von BSI oder anderen proprietaeren Standards abgeleitet**, sondern folgt einem
allgemein ueblichen Nummerierungsschema.`,
},
{
id: 'open-sources',
title: 'Offene Referenzquellen',
content: `## Primaere offene Quellen
Alle Controls sind in mindestens einer der folgenden **frei zugaenglichen** Quellen verankert:
### OWASP (CC BY-SA 4.0 — kommerziell erlaubt)
- **ASVS** — Application Security Verification Standard v4.0.3
- **MASVS** — Mobile Application Security Verification Standard v2.1
- **Top 10** — OWASP Top 10 (2021)
- **Cheat Sheets** — OWASP Cheat Sheet Series
- **SAMM** — Software Assurance Maturity Model
### NIST (Public Domain — keine Einschraenkungen)
- **SP 800-53 Rev.5** — Security and Privacy Controls
- **SP 800-63B** — Digital Identity Guidelines (Authentication)
- **SP 800-57** — Key Management Recommendations
- **SP 800-52 Rev.2** — TLS Implementation Guidelines
- **SP 800-92** — Log Management Guide
- **SP 800-218 (SSDF)** — Secure Software Development Framework
- **SP 800-60** — Information Types to Security Categories
### ENISA (CC BY 4.0 — kommerziell erlaubt)
- Good Practices for IoT/Mobile Security
- Data Protection Engineering
- Algorithms, Key Sizes and Parameters Report
### Weitere offene Quellen
- **SLSA** (Supply-chain Levels for Software Artifacts) — Google Open Source
- **CIS Controls v8** (CC BY-NC-ND — nur fuer interne Analyse)`,
},
{
id: 'restricted-sources',
title: 'Geschuetzte Quellen — Nur interne Analyse',
content: `## Quellen mit eingeschraenkter Nutzung
Die folgenden Quellen werden **ausschliesslich intern zur Analyse** verwendet.
Kein Text, keine Struktur, keine Bezeichner aus diesen Quellen erscheinen im Produkt.
### BSI (Nutzungsbedingungen — kommerziell eingeschraenkt)
- TR-03161 Teil 1-3 (Mobile, Web, Hintergrunddienste)
- Nutzung: TDM unter UrhG §44b, Kopien werden geloescht
- Kein Shipping von Zitaten, Embeddings oder Strukturen
### ISO/IEC (Kostenpflichtig — kein Shipping)
- ISO 27001, ISO 27002
- Nutzung: Nur als Referenz fuer Mapping, kein Text im Produkt
### ETSI (Restriktiv — kein kommerzieller Gebrauch)
- Nutzung: Nur als Hintergrundwissen, kein direkter Einfluss
### Trennungsprinzip
| Ebene | Geschuetzte Quelle | Offene Quelle |
|-------|--------------------|---------------|
| Analyse | ✅ Darf gelesen werden | ✅ Darf gelesen werden |
| Inspiration | ✅ Darf Ideen liefern | ✅ Darf Ideen liefern |
| Formulierung | ❌ Keine Uebernahme | ✅ Darf zitiert werden |
| Struktur | ❌ Keine Uebernahme | ✅ Darf verwendet werden |
| Produkttext | ❌ Nicht erlaubt | ✅ Erlaubt |`,
},
{
id: 'validation',
title: 'Automatisierte Validierung',
content: `## CI/CD-Pruefungen
Jedes Control wird bei jedem Commit automatisch geprueft:
### 1. Schema-Validierung
- Alle Pflichtfelder vorhanden
- Control-ID Format: \`^[A-Z]{2,6}-[0-9]{3}$\`
- Severity: low, medium, high, critical
- Risk Score: 0-10
### 2. No-Leak Scanner
Regex-Pruefung gegen verbotene Muster in produktfaehigen Feldern:
- \`O.[A-Za-z]+_[0-9]+\` — BSI Objective-IDs
- \`TR-03161\` — Direkte BSI-TR-Referenzen
- \`BSI-TR-\` — BSI-spezifische Locators
- \`Anforderung [A-Z].[0-9]+\` — BSI-Anforderungsformat
### 3. Open Anchor Check
Jedes freigegebene Control muss mindestens 1 Open-Source-Referenz haben.
### 4. Too-Close Detektor (5 Metriken)
| Metrik | Warn | Fail | Beschreibung |
|--------|------|------|-------------|
| Exact Phrase | ≥8 Tokens | ≥12 Tokens | Laengste identische Token-Sequenz |
| Token Overlap | ≥0.20 | ≥0.30 | Jaccard-Aehnlichkeit der Token-Mengen |
| 3-Gram Jaccard | ≥0.10 | ≥0.18 | Zeichenketten-Aehnlichkeit |
| Embedding Cosine | ≥0.86 | ≥0.92 | Semantische Aehnlichkeit (bge-m3) |
| LCS Ratio | ≥0.35 | ≥0.50 | Longest Common Subsequence |
**Entscheidungslogik:**
- **PASS** — Kein Fail + max 1 Warn
- **WARN** — Max 2 Warn, kein Fail → Human Review erforderlich
- **FAIL** — Irgendein Fail → Blockiert, Umformulierung noetig`,
},
]
// =============================================================================
// PAGE
// =============================================================================
export default function ControlProvenancePage() {
const [licenses, setLicenses] = useState<LicenseInfo[]>([])
const [sources, setSources] = useState<SourceInfo[]>([])
@@ -50,6 +232,7 @@ export default function ControlProvenancePage() {
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b border-gray-200 bg-white px-6 py-4">
<div className="flex items-center gap-3">
<FileText className="w-6 h-6 text-green-600" />
@@ -59,7 +242,10 @@ export default function ControlProvenancePage() {
Dokumentation der unabhaengigen Herkunft aller Security Controls rechtssicherer Nachweis
</p>
</div>
<Link href="/sdk/control-library" className="ml-auto flex items-center gap-1 text-sm text-purple-600 hover:text-purple-800">
<Link
href="/sdk/control-library"
className="ml-auto flex items-center gap-1 text-sm text-purple-600 hover:text-purple-800"
>
<Shield className="w-4 h-4" />
Zur Control Library
</Link>
@@ -84,19 +270,29 @@ export default function ControlProvenancePage() {
{section.title}
</button>
))}
<div className="border-t border-gray-200 mt-3 pt-3">
<p className="text-xs font-semibold text-gray-400 uppercase px-3 mb-2">Live-Daten</p>
{['license-matrix', 'source-registry'].map(id => (
<button
key={id}
onClick={() => setActiveSection(id)}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
activeSection === id ? 'bg-green-100 text-green-900 font-medium' : 'text-gray-700 hover:bg-gray-100'
}`}
>
{id === 'license-matrix' ? 'Lizenz-Matrix' : 'Quellenregister'}
</button>
))}
<button
onClick={() => setActiveSection('license-matrix')}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
activeSection === 'license-matrix'
? 'bg-green-100 text-green-900 font-medium'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
Lizenz-Matrix
</button>
<button
onClick={() => setActiveSection('source-registry')}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
activeSection === 'source-registry'
? 'bg-green-100 text-green-900 font-medium'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
Quellenregister
</button>
</div>
</div>
</div>
@@ -104,6 +300,7 @@ export default function ControlProvenancePage() {
{/* Right: Content */}
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-3xl mx-auto">
{/* Static documentation sections */}
{currentSection && (
<div>
<h2 className="text-xl font-bold text-gray-900 mb-4">{currentSection.title}</h2>
@@ -112,11 +309,188 @@ export default function ControlProvenancePage() {
</div>
</div>
)}
{activeSection === 'license-matrix' && <LicenseMatrix licenses={licenses} loading={loading} />}
{activeSection === 'source-registry' && <SourceRegistry sources={sources} loading={loading} />}
{/* License Matrix (live data) */}
{activeSection === 'license-matrix' && (
<div>
<h2 className="text-xl font-bold text-gray-900 mb-4">Lizenz-Matrix</h2>
<p className="text-sm text-gray-600 mb-4">
Uebersicht aller Lizenzen mit ihren erlaubten Nutzungsarten.
</p>
{loading ? (
<div className="animate-pulse h-32 bg-gray-100 rounded" />
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-50">
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Lizenz</th>
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Kommerziell</th>
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">AI-Training</th>
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">TDM (§44b)</th>
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Loeschpflicht</th>
</tr>
</thead>
<tbody>
{licenses.map(lic => (
<tr key={lic.license_id} className="hover:bg-gray-50">
<td className="px-3 py-2 border-b">
<div className="font-medium text-gray-900">{lic.license_id}</div>
<div className="text-xs text-gray-500">{lic.name}</div>
</td>
<td className="px-3 py-2 border-b">
<UsageBadge value={lic.commercial_use} />
</td>
<td className="px-3 py-2 border-b">
<UsageBadge value={lic.ai_training_restriction || 'n/a'} />
</td>
<td className="px-3 py-2 border-b">
<UsageBadge value={lic.tdm_allowed_under_44b || 'unclear'} />
</td>
<td className="px-3 py-2 border-b">
{lic.deletion_required ? (
<span className="text-red-600 text-xs font-medium">Ja</span>
) : (
<span className="text-green-600 text-xs font-medium">Nein</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Source Registry (live data) */}
{activeSection === 'source-registry' && (
<div>
<h2 className="text-xl font-bold text-gray-900 mb-4">Quellenregister</h2>
<p className="text-sm text-gray-600 mb-4">
Alle registrierten Quellen mit ihren Berechtigungen.
</p>
{loading ? (
<div className="animate-pulse h-32 bg-gray-100 rounded" />
) : (
<div className="space-y-3">
{sources.map(src => (
<div key={src.source_id} className="bg-white border border-gray-200 rounded-lg p-4">
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="text-sm font-medium text-gray-900">{src.title}</h3>
<p className="text-xs text-gray-500">{src.publisher} {src.license_name}</p>
</div>
{src.url && (
<a
href={src.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800"
>
<ExternalLink className="w-3 h-3" />
Quelle
</a>
)}
</div>
<div className="flex items-center gap-3 mt-2">
<PermBadge label="Analyse" allowed={src.allowed_analysis} />
<PermBadge label="Excerpt" allowed={src.allowed_store_excerpt} />
<PermBadge label="Embeddings" allowed={src.allowed_ship_embeddings} />
<PermBadge label="Produkt" allowed={src.allowed_ship_in_product} />
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
)
}
// =============================================================================
// HELPER COMPONENTS
// =============================================================================
function UsageBadge({ value }: { value: string }) {
const config: Record<string, { bg: string; label: string }> = {
allowed: { bg: 'bg-green-100 text-green-800', label: 'Erlaubt' },
restricted: { bg: 'bg-yellow-100 text-yellow-800', label: 'Eingeschraenkt' },
prohibited: { bg: 'bg-red-100 text-red-800', label: 'Verboten' },
unclear: { bg: 'bg-gray-100 text-gray-600', label: 'Unklar' },
yes: { bg: 'bg-green-100 text-green-800', label: 'Ja' },
no: { bg: 'bg-red-100 text-red-800', label: 'Nein' },
'n/a': { bg: 'bg-gray-100 text-gray-400', label: 'k.A.' },
}
const c = config[value] || config.unclear
return <span className={`inline-flex px-1.5 py-0.5 rounded text-xs font-medium ${c.bg}`}>{c.label}</span>
}
function PermBadge({ label, allowed }: { label: string; allowed: boolean }) {
return (
<div className="flex items-center gap-1">
{allowed ? (
<CheckCircle2 className="w-3 h-3 text-green-500" />
) : (
<Lock className="w-3 h-3 text-red-400" />
)}
<span className={`text-xs ${allowed ? 'text-green-700' : 'text-red-500'}`}>{label}</span>
</div>
)
}
function MarkdownRenderer({ content }: { content: string }) {
let html = content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Code blocks
html = html.replace(
/^```[\w]*\n([\s\S]*?)^```$/gm,
(_m, code: string) => `<pre class="bg-gray-50 border rounded p-3 my-3 text-xs font-mono overflow-x-auto whitespace-pre">${code.trimEnd()}</pre>`
)
// Tables
html = html.replace(
/^(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)*)/gm,
(_m, header: string, _sep: string, body: string) => {
const ths = header.split('|').filter((c: string) => c.trim()).map((c: string) =>
`<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase border-b">${c.trim()}</th>`
).join('')
const rows = body.trim().split('\n').map((row: string) => {
const tds = row.split('|').filter((c: string) => c.trim()).map((c: string) =>
`<td class="px-3 py-2 text-sm text-gray-700 border-b border-gray-100">${c.trim()}</td>`
).join('')
return `<tr>${tds}</tr>`
}).join('')
return `<table class="w-full border-collapse my-3 text-sm"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`
}
)
// Headers
html = html.replace(/^### (.+)$/gm, '<h4 class="text-sm font-semibold text-gray-800 mt-4 mb-2">$1</h4>')
html = html.replace(/^## (.+)$/gm, '<h3 class="text-base font-semibold text-gray-900 mt-5 mb-2">$1</h3>')
// Bold
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// Inline code
html = html.replace(/`([^`]+)`/g, '<code class="bg-gray-100 px-1 py-0.5 rounded text-xs font-mono">$1</code>')
// Lists
html = html.replace(/^- (.+)$/gm, '<li class="ml-4 text-sm text-gray-700 list-disc">$1</li>')
html = html.replace(/((?:<li[^>]*>.*<\/li>\n?)+)/g, '<ul class="my-2 space-y-1">$1</ul>')
// Numbered lists
html = html.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 text-sm text-gray-700 list-decimal">$2</li>')
// Paragraphs
html = html.replace(/^(?!<[hultdp]|$)(.+)$/gm, '<p class="text-sm text-gray-700 my-2">$1</p>')
return <div dangerouslySetInnerHTML={{ __html: html }} />
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,373 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import { ComponentForm } from './_components/ComponentForm'
import { ComponentTreeNode } from './_components/ComponentTreeNode'
import { ComponentLibraryModal } from './_components/ComponentLibraryModal'
import { useComponents } from './_hooks/useComponents'
interface Component {
id: string
name: string
type: string
version: string
description: string
safety_relevant: boolean
parent_id: string | null
children: Component[]
}
const COMPONENT_TYPES = [
{ value: 'SW', label: 'Software (SW)' },
{ value: 'FW', label: 'Firmware (FW)' },
{ value: 'AI', label: 'KI-Modul (AI)' },
{ value: 'HMI', label: 'Mensch-Maschine-Schnittstelle (HMI)' },
{ value: 'SENSOR', label: 'Sensor' },
{ value: 'ACTUATOR', label: 'Aktor' },
{ value: 'CONTROLLER', label: 'Steuerung' },
{ value: 'NETWORK', label: 'Netzwerk' },
{ value: 'MECHANICAL', label: 'Mechanik' },
{ value: 'ELECTRICAL', label: 'Elektrik' },
{ value: 'OTHER', label: 'Sonstiges' },
]
function ComponentTypeIcon({ type }: { type: string }) {
const colors: Record<string, string> = {
SW: 'bg-blue-100 text-blue-700',
FW: 'bg-indigo-100 text-indigo-700',
AI: 'bg-purple-100 text-purple-700',
HMI: 'bg-pink-100 text-pink-700',
SENSOR: 'bg-cyan-100 text-cyan-700',
ACTUATOR: 'bg-orange-100 text-orange-700',
CONTROLLER: 'bg-green-100 text-green-700',
NETWORK: 'bg-yellow-100 text-yellow-700',
MECHANICAL: 'bg-gray-100 text-gray-700',
ELECTRICAL: 'bg-red-100 text-red-700',
OTHER: 'bg-gray-100 text-gray-500',
}
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${colors[type] || colors.OTHER}`}>
{type}
</span>
)
}
function ComponentTreeNode({
component,
depth,
onEdit,
onDelete,
onAddChild,
}: {
component: Component
depth: number
onEdit: (c: Component) => void
onDelete: (id: string) => void
onAddChild: (parentId: string) => void
}) {
const [expanded, setExpanded] = useState(true)
const hasChildren = component.children && component.children.length > 0
return (
<div>
<div
className="flex items-center gap-2 py-2 px-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 group transition-colors"
style={{ paddingLeft: `${depth * 24 + 12}px` }}
>
{/* Expand/collapse */}
<button
onClick={() => setExpanded(!expanded)}
className={`w-5 h-5 flex items-center justify-center text-gray-400 ${hasChildren ? 'visible' : 'invisible'}`}
>
<svg
className={`w-4 h-4 transition-transform ${expanded ? 'rotate-90' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
<ComponentTypeIcon type={component.type} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-gray-900 dark:text-white">{component.name}</span>
{component.version && (
<span className="ml-2 text-xs text-gray-400">v{component.version}</span>
)}
{component.safety_relevant && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 text-red-700">
Sicherheitsrelevant
</span>
)}
</div>
{component.description && (
<span className="text-xs text-gray-400 truncate max-w-[200px] hidden lg:block">
{component.description}
</span>
)}
{/* Actions */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => onAddChild(component.id)}
title="Unterkomponente hinzufuegen"
className="p-1 text-gray-400 hover:text-purple-600 hover:bg-purple-50 rounded transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</button>
<button
onClick={() => onEdit(component)}
title="Bearbeiten"
className="p-1 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => onDelete(component.id)}
title="Loeschen"
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
{expanded && hasChildren && (
<div>
{component.children.map((child) => (
<ComponentTreeNode
key={child.id}
component={child}
depth={depth + 1}
onEdit={onEdit}
onDelete={onDelete}
onAddChild={onAddChild}
/>
))}
</div>
)}
</div>
)
}
interface ComponentFormData {
name: string
type: string
version: string
description: string
safety_relevant: boolean
parent_id: string | null
}
function ComponentForm({
onSubmit,
onCancel,
initialData,
parentId,
}: {
onSubmit: (data: ComponentFormData) => void
onCancel: () => void
initialData?: Component | null
parentId?: string | null
}) {
const [formData, setFormData] = useState<ComponentFormData>({
name: initialData?.name || '',
type: initialData?.type || 'SW',
version: initialData?.version || '',
description: initialData?.description || '',
safety_relevant: initialData?.safety_relevant || false,
parent_id: parentId || initialData?.parent_id || null,
})
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{initialData ? 'Komponente bearbeiten' : parentId ? 'Unterkomponente hinzufuegen' : 'Neue Komponente'}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name *</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="z.B. Bildverarbeitungsmodul"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Typ</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
{COMPONENT_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Version</label>
<input
type="text"
value={formData.version}
onChange={(e) => setFormData({ ...formData, version: e.target.value })}
placeholder="z.B. 1.2.0"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div className="flex items-center gap-3 pt-6">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={formData.safety_relevant}
onChange={(e) => setFormData({ ...formData, safety_relevant: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-purple-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-red-500" />
</label>
<span className="text-sm text-gray-700 dark:text-gray-300">Sicherheitsrelevant</span>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Kurze Beschreibung der Komponente..."
rows={2}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
</div>
<div className="mt-4 flex items-center gap-3">
<button
onClick={() => onSubmit(formData)}
disabled={!formData.name}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
formData.name
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
{initialData ? 'Aktualisieren' : 'Hinzufuegen'}
</button>
<button
onClick={onCancel}
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
>
Abbrechen
</button>
</div>
</div>
)
}
function buildTree(components: Component[]): Component[] {
const map = new Map<string, Component>()
const roots: Component[] = []
components.forEach((c) => {
map.set(c.id, { ...c, children: [] })
})
components.forEach((c) => {
const node = map.get(c.id)!
if (c.parent_id && map.has(c.parent_id)) {
map.get(c.parent_id)!.children.push(node)
} else {
roots.push(node)
}
})
return roots
}
export default function ComponentsPage() {
const params = useParams()
const projectId = params.projectId as string
const c = useComponents(projectId)
const [components, setComponents] = useState<Component[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editingComponent, setEditingComponent] = useState<Component | null>(null)
const [addingParentId, setAddingParentId] = useState<string | null>(null)
if (c.loading) {
useEffect(() => {
fetchComponents()
}, [projectId])
async function fetchComponents() {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`)
if (res.ok) {
const json = await res.json()
setComponents(json.components || json || [])
}
} catch (err) {
console.error('Failed to fetch components:', err)
} finally {
setLoading(false)
}
}
async function handleSubmit(data: ComponentFormData) {
try {
const url = editingComponent
? `/api/sdk/v1/iace/projects/${projectId}/components/${editingComponent.id}`
: `/api/sdk/v1/iace/projects/${projectId}/components`
const method = editingComponent ? 'PUT' : 'POST'
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (res.ok) {
setShowForm(false)
setEditingComponent(null)
setAddingParentId(null)
await fetchComponents()
}
} catch (err) {
console.error('Failed to save component:', err)
}
}
async function handleDelete(id: string) {
if (!confirm('Komponente wirklich loeschen? Unterkomponenten werden ebenfalls entfernt.')) return
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components/${id}`, {
method: 'DELETE',
})
if (res.ok) {
await fetchComponents()
}
} catch (err) {
console.error('Failed to delete component:', err)
}
}
function handleEdit(component: Component) {
setEditingComponent(component)
setAddingParentId(null)
setShowForm(true)
}
function handleAddChild(parentId: string) {
setAddingParentId(parentId)
setEditingComponent(null)
setShowForm(true)
}
const tree = buildTree(components)
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
@@ -29,40 +385,39 @@ export default function ComponentsPage() {
Erfassen Sie alle Software-, Firmware-, KI- und Hardware-Komponenten der Maschine.
</p>
</div>
{!c.showForm && (
<div className="flex items-center gap-2">
<button onClick={() => c.setShowLibrary(true)}
className="flex items-center gap-2 px-3 py-2 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors text-sm">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
Aus Bibliothek waehlen
</button>
<button
onClick={() => { c.setShowForm(true); c.setEditingComponent(null); c.setAddingParentId(null) }}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Komponente hinzufuegen
</button>
</div>
{!showForm && (
<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>
{c.showLibrary && (
<ComponentLibraryModal onAdd={c.handleAddFromLibrary} onClose={() => c.setShowLibrary(false)} />
)}
{c.showForm && (
{/* Form */}
{showForm && (
<ComponentForm
onSubmit={c.handleSubmit}
onCancel={() => { c.setShowForm(false); c.setEditingComponent(null); c.setAddingParentId(null) }}
initialData={c.editingComponent} parentId={c.addingParentId}
onSubmit={handleSubmit}
onCancel={() => {
setShowForm(false)
setEditingComponent(null)
setAddingParentId(null)
}}
initialData={editingComponent}
parentId={addingParentId}
/>
)}
{c.tree.length > 0 ? (
{/* Component Tree */}
{tree.length > 0 ? (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700">
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-750 rounded-t-xl">
<div className="flex items-center gap-2 text-xs font-medium text-gray-500 uppercase tracking-wider">
@@ -74,14 +429,20 @@ export default function ComponentsPage() {
</div>
</div>
<div className="py-1">
{c.tree.map((component) => (
<ComponentTreeNode key={component.id} component={component} depth={0}
onEdit={c.handleEdit} onDelete={c.handleDelete} onAddChild={c.handleAddChild} />
{tree.map((component) => (
<ComponentTreeNode
key={component.id}
component={component}
depth={0}
onEdit={handleEdit}
onDelete={handleDelete}
onAddChild={handleAddChild}
/>
))}
</div>
</div>
) : (
!c.showForm && (
!showForm && (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -93,16 +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={() => c.setShowLibrary(true)}
className="px-6 py-3 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors">
Aus Bibliothek waehlen
</button>
<button onClick={() => c.setShowForm(true)}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
Manuell hinzufuegen
</button>
</div>
<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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,332 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import { REDUCTION_TYPES } from './_components/types'
import { HierarchyWarning } from './_components/HierarchyWarning'
import { MitigationForm } from './_components/MitigationForm'
import { MitigationCard } from './_components/MitigationCard'
import { MeasuresLibraryModal } from './_components/MeasuresLibraryModal'
import { SuggestMeasuresModal } from './_components/SuggestMeasuresModal'
import { useMitigations } from './_hooks/useMitigations'
interface Mitigation {
id: string
title: string
description: string
reduction_type: 'design' | 'protection' | 'information'
status: 'planned' | 'implemented' | 'verified'
linked_hazard_ids: string[]
linked_hazard_names: string[]
created_at: string
verified_at: string | null
verified_by: string | null
}
interface Hazard {
id: string
name: string
risk_level: string
}
const REDUCTION_TYPES = {
design: {
label: 'Design',
description: 'Inhaerent sichere Konstruktion',
color: 'border-blue-200 bg-blue-50',
headerColor: 'bg-blue-100 text-blue-800',
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
</svg>
),
},
protection: {
label: 'Schutz',
description: 'Technische Schutzmassnahmen',
color: 'border-green-200 bg-green-50',
headerColor: 'bg-green-100 text-green-800',
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
},
information: {
label: 'Information',
description: 'Hinweise und Schulungen',
color: 'border-yellow-200 bg-yellow-50',
headerColor: 'bg-yellow-100 text-yellow-800',
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
}
function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
planned: 'bg-gray-100 text-gray-700',
implemented: 'bg-blue-100 text-blue-700',
verified: 'bg-green-100 text-green-700',
}
const labels: Record<string, string> = {
planned: 'Geplant',
implemented: 'Umgesetzt',
verified: 'Verifiziert',
}
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${colors[status] || colors.planned}`}>
{labels[status] || status}
</span>
)
}
interface MitigationFormData {
title: string
description: string
reduction_type: 'design' | 'protection' | 'information'
linked_hazard_ids: string[]
}
function MitigationForm({
onSubmit,
onCancel,
hazards,
preselectedType,
}: {
onSubmit: (data: MitigationFormData) => void
onCancel: () => void
hazards: Hazard[]
preselectedType?: 'design' | 'protection' | 'information'
}) {
const [formData, setFormData] = useState<MitigationFormData>({
title: '',
description: '',
reduction_type: preselectedType || 'design',
linked_hazard_ids: [],
})
function toggleHazard(id: string) {
setFormData((prev) => ({
...prev,
linked_hazard_ids: prev.linked_hazard_ids.includes(id)
? prev.linked_hazard_ids.filter((h) => h !== id)
: [...prev.linked_hazard_ids, id],
}))
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<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>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titel *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="z.B. Lichtvorhang an Gefahrenstelle"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reduktionstyp</label>
<select
value={formData.reduction_type}
onChange={(e) => setFormData({ ...formData, reduction_type: e.target.value as MitigationFormData['reduction_type'] })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
<option value="design">Design - Inhaerent sichere Konstruktion</option>
<option value="protection">Schutz - Technische Schutzmassnahmen</option>
<option value="information">Information - Hinweise und Schulungen</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
placeholder="Detaillierte Beschreibung der Massnahme..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
{hazards.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Verknuepfte Gefaehrdungen</label>
<div className="flex flex-wrap gap-2">
{hazards.map((h) => (
<button
key={h.id}
onClick={() => toggleHazard(h.id)}
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
formData.linked_hazard_ids.includes(h.id)
? 'border-purple-400 bg-purple-50 text-purple-700'
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'
}`}
>
{h.name}
</button>
))}
</div>
</div>
)}
</div>
<div className="mt-4 flex items-center gap-3">
<button
onClick={() => onSubmit(formData)}
disabled={!formData.title}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
formData.title
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Hinzufuegen
</button>
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
</div>
</div>
)
}
function MitigationCard({
mitigation,
onVerify,
onDelete,
}: {
mitigation: Mitigation
onVerify: (id: string) => void
onDelete: (id: string) => void
}) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-start justify-between mb-2">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title}</h4>
<StatusBadge status={mitigation.status} />
</div>
{mitigation.description && (
<p className="text-xs text-gray-500 mb-3">{mitigation.description}</p>
)}
{mitigation.linked_hazard_names.length > 0 && (
<div className="mb-3">
<div className="flex flex-wrap gap-1">
{mitigation.linked_hazard_names.map((name, i) => (
<span key={i} className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
{name}
</span>
))}
</div>
</div>
)}
<div className="flex items-center gap-2">
{mitigation.status !== 'verified' && (
<button
onClick={() => onVerify(mitigation.id)}
className="text-xs px-2.5 py-1 bg-green-50 text-green-700 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
>
Verifizieren
</button>
)}
<button
onClick={() => onDelete(mitigation.id)}
className="text-xs px-2.5 py-1 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
Loeschen
</button>
</div>
</div>
)
}
export default function MitigationsPage() {
const params = useParams()
const projectId = params.projectId as string
const m = useMitigations(projectId)
const [mitigations, setMitigations] = useState<Mitigation[]>([])
const [hazards, setHazards] = useState<Hazard[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [preselectedType, setPreselectedType] = useState<'design' | 'protection' | 'information' | undefined>()
if (m.loading) {
useEffect(() => {
fetchData()
}, [projectId])
async function fetchData() {
try {
const [mitRes, hazRes] = await Promise.all([
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
])
if (mitRes.ok) {
const json = await mitRes.json()
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 })))
}
} catch (err) {
console.error('Failed to fetch data:', err)
} finally {
setLoading(false)
}
}
async function handleSubmit(data: MitigationFormData) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (res.ok) {
setShowForm(false)
setPreselectedType(undefined)
await fetchData()
}
} catch (err) {
console.error('Failed to add mitigation:', err)
}
}
async function handleVerify(id: string) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (res.ok) {
await fetchData()
}
} catch (err) {
console.error('Failed to verify mitigation:', err)
}
}
async function handleDelete(id: string) {
if (!confirm('Massnahme wirklich loeschen?')) return
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}`, { method: 'DELETE' })
if (res.ok) {
await fetchData()
}
} catch (err) {
console.error('Failed to delete mitigation:', err)
}
}
function handleAddForType(type: 'design' | 'protection' | 'information') {
setPreselectedType(type)
setShowForm(true)
}
const byType = {
design: mitigations.filter((m) => m.reduction_type === 'design'),
protection: mitigations.filter((m) => m.reduction_type === 'protection'),
information: mitigations.filter((m) => m.reduction_type === 'information'),
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
@@ -29,59 +341,33 @@ 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">
{m.hazards.length > 0 && (
<button onClick={() => m.setShowSuggest(true)}
className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors text-sm">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
Vorschlaege
</button>
)}
<button onClick={() => m.handleOpenLibrary()}
className="flex items-center gap-2 px-4 py-2 bg-white border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
Bibliothek
</button>
<button onClick={() => { m.setPreselectedType(undefined); m.setShowForm(true) }}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
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>
{m.hierarchyWarning && <HierarchyWarning onDismiss={() => m.setHierarchyWarning(false)} />}
{m.showForm && (
{/* Form */}
{showForm && (
<MitigationForm
onSubmit={m.handleSubmit}
onCancel={() => { m.setShowForm(false); m.setPreselectedType(undefined) }}
hazards={m.hazards} preselectedType={m.preselectedType}
onOpenLibrary={m.handleOpenLibrary}
/>
)}
{m.showLibrary && (
<MeasuresLibraryModal
measures={m.measures} onSelect={m.handleSelectMeasure}
onClose={() => m.setShowLibrary(false)} filterType={m.libraryFilter}
/>
)}
{m.showSuggest && (
<SuggestMeasuresModal
hazards={m.hazards} projectId={projectId}
onAddMeasure={m.handleAddSuggestedMeasure}
onClose={() => m.setShowSuggest(false)}
onSubmit={handleSubmit}
onCancel={() => {
setShowForm(false)
setPreselectedType(undefined)
}}
hazards={hazards}
preselectedType={preselectedType}
/>
)}
@@ -89,10 +375,10 @@ export default function MitigationsPage() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{(['design', 'protection', 'information'] as const).map((type) => {
const config = REDUCTION_TYPES[type]
const items = m.byType[type]
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>
@@ -100,31 +386,24 @@ export default function MitigationsPage() {
</div>
<span className="ml-auto text-sm font-bold">{items.length}</span>
</div>
<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((item) => (
<MitigationCard key={item.id} mitigation={item} onVerify={m.handleVerify} onDelete={m.handleDelete} />
{items.map((m) => (
<MitigationCard
key={m.id}
mitigation={m}
onVerify={handleVerify}
onDelete={handleDelete}
/>
))}
</div>
<div className="mt-3 flex gap-2">
<button onClick={() => m.handleAddForType(type)}
className="flex-1 py-2 text-sm text-gray-500 hover:text-purple-600 hover:bg-white rounded-lg border border-dashed border-gray-300 hover:border-purple-300 transition-colors">
+ Hinzufuegen
</button>
<button onClick={() => m.handleOpenLibrary(type)}
className="py-2 px-3 text-sm text-gray-400 hover:text-purple-600 hover:bg-white rounded-lg border border-dashed border-gray-300 hover:border-purple-300 transition-colors"
title="Aus Bibliothek waehlen">
<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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,11 +2,6 @@
import React, { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import { VerificationForm } from './_components/VerificationForm'
import { CompleteModal } from './_components/CompleteModal'
import { SuggestEvidenceModal } from './_components/SuggestEvidenceModal'
import { VerificationTable } from './_components/VerificationTable'
import type { VerificationFormData } from './_components/VerificationForm'
interface VerificationItem {
id: string
@@ -24,6 +19,225 @@ interface VerificationItem {
created_at: string
}
const VERIFICATION_METHODS = [
{ 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 }> = {
pending: { label: 'Ausstehend', color: 'bg-gray-100 text-gray-700' },
in_progress: { label: 'In Bearbeitung', color: 'bg-blue-100 text-blue-700' },
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700' },
failed: { label: 'Fehlgeschlagen', color: 'bg-red-100 text-red-700' },
}
function StatusBadge({ status }: { status: string }) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.pending
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
{config.label}
</span>
)
}
interface VerificationFormData {
title: string
description: string
method: string
linked_hazard_id: string
linked_mitigation_id: string
}
function VerificationForm({
onSubmit,
onCancel,
hazards,
mitigations,
}: {
onSubmit: (data: VerificationFormData) => void
onCancel: () => void
hazards: { id: string; name: string }[]
mitigations: { id: string; title: string }[]
}) {
const [formData, setFormData] = useState<VerificationFormData>({
title: '',
description: '',
method: 'test',
linked_hazard_id: '',
linked_mitigation_id: '',
})
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Neues Verifikationselement</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titel *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="z.B. Funktionstest Lichtvorhang"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Methode</label>
<select
value={formData.method}
onChange={(e) => setFormData({ ...formData, method: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
{VERIFICATION_METHODS.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
placeholder="Beschreiben Sie den Verifikationsschritt..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Verknuepfte Gefaehrdung</label>
<select
value={formData.linked_hazard_id}
onChange={(e) => setFormData({ ...formData, linked_hazard_id: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
<option value="">-- Keine --</option>
{hazards.map((h) => (
<option key={h.id} value={h.id}>{h.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Verknuepfte Massnahme</label>
<select
value={formData.linked_mitigation_id}
onChange={(e) => setFormData({ ...formData, linked_mitigation_id: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
<option value="">-- Keine --</option>
{mitigations.map((m) => (
<option key={m.id} value={m.id}>{m.title}</option>
))}
</select>
</div>
</div>
</div>
<div className="mt-4 flex items-center gap-3">
<button
onClick={() => onSubmit(formData)}
disabled={!formData.title}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
formData.title
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Hinzufuegen
</button>
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
</div>
</div>
)
}
function CompleteModal({
item,
onSubmit,
onClose,
}: {
item: VerificationItem
onSubmit: (id: string, result: string, passed: boolean) => void
onClose: () => void
}) {
const [result, setResult] = useState('')
const [passed, setPassed] = useState(true)
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Verifikation abschliessen: {item.title}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ergebnis</label>
<textarea
value={result}
onChange={(e) => setResult(e.target.value)}
rows={3}
placeholder="Beschreiben Sie das Ergebnis der Verifikation..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Bewertung</label>
<div className="flex gap-3">
<button
onClick={() => setPassed(true)}
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
passed
? 'border-green-400 bg-green-50 text-green-700'
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
}`}
>
Bestanden
</button>
<button
onClick={() => setPassed(false)}
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
!passed
? 'border-red-400 bg-red-50 text-red-700'
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
}`}
>
Nicht bestanden
</button>
</div>
</div>
</div>
<div className="mt-6 flex items-center gap-3">
<button
onClick={() => onSubmit(item.id, result, passed)}
disabled={!result}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
result
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Abschliessen
</button>
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
</div>
</div>
</div>
)
}
export default function VerificationPage() {
const params = useParams()
const projectId = params.projectId as string
@@ -33,9 +247,10 @@ export default function VerificationPage() {
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [completingItem, setCompletingItem] = useState<VerificationItem | null>(null)
const [showSuggest, setShowSuggest] = useState(false)
useEffect(() => { fetchData() }, [projectId])
useEffect(() => {
fetchData()
}, [projectId])
async function fetchData() {
try {
@@ -44,47 +259,67 @@ export default function VerificationPage() {
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
])
if (verRes.ok) { const json = await verRes.json(); setItems(json.verifications || json || []) }
if (hazRes.ok) { const json = await hazRes.json(); setHazards((json.hazards || json || []).map((h: { id: string; name: string }) => ({ id: h.id, name: h.name }))) }
if (mitRes.ok) { const json = await mitRes.json(); setMitigations((json.mitigations || json || []).map((m: { id: string; title: string }) => ({ id: m.id, title: m.title }))) }
} catch (err) { console.error('Failed to fetch data:', err) }
finally { setLoading(false) }
if (verRes.ok) {
const json = await verRes.json()
setItems(json.verifications || json || [])
}
if (hazRes.ok) {
const json = await hazRes.json()
setHazards((json.hazards || json || []).map((h: { id: string; name: string }) => ({ id: h.id, name: h.name })))
}
if (mitRes.ok) {
const json = await mitRes.json()
setMitigations((json.mitigations || json || []).map((m: { id: string; title: string }) => ({ id: m.id, title: m.title })))
}
} catch (err) {
console.error('Failed to fetch data:', err)
} finally {
setLoading(false)
}
}
async function handleSubmit(data: VerificationFormData) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (res.ok) { setShowForm(false); await fetchData() }
} catch (err) { console.error('Failed to add verification:', err) }
}
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) }
if (res.ok) {
setShowForm(false)
await fetchData()
}
} catch (err) {
console.error('Failed to add verification:', err)
}
}
async function handleComplete(id: string, result: string, passed: boolean) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}/complete`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ result, passed }),
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ result, passed }),
})
if (res.ok) { setCompletingItem(null); await fetchData() }
} catch (err) { console.error('Failed to complete verification:', err) }
if (res.ok) {
setCompletingItem(null)
await fetchData()
}
} catch (err) {
console.error('Failed to complete verification:', err)
}
}
async function handleDelete(id: string) {
if (!confirm('Verifikation wirklich loeschen?')) return
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}`, { method: 'DELETE' })
if (res.ok) await fetchData()
} catch (err) { console.error('Failed to delete verification:', err) }
if (res.ok) {
await fetchData()
}
} catch (err) {
console.error('Failed to delete verification:', err)
}
}
const completed = items.filter((i) => i.status === 'completed').length
@@ -101,6 +336,7 @@ export default function VerificationPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Verifikationsplan</h1>
@@ -108,28 +344,18 @@ 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 */}
{items.length > 0 && (
<div className="grid grid-cols-4 gap-3">
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-center">
@@ -151,20 +377,85 @@ export default function VerificationPage() {
</div>
)}
{/* Form */}
{showForm && (
<VerificationForm onSubmit={handleSubmit} onCancel={() => setShowForm(false)} hazards={hazards} mitigations={mitigations} />
<VerificationForm
onSubmit={handleSubmit}
onCancel={() => setShowForm(false)}
hazards={hazards}
mitigations={mitigations}
/>
)}
{/* Complete Modal */}
{completingItem && (
<CompleteModal item={completingItem} onSubmit={handleComplete} onClose={() => setCompletingItem(null)} />
)}
{showSuggest && (
<SuggestEvidenceModal mitigations={mitigations} projectId={projectId} onAddEvidence={handleAddSuggestedEvidence} onClose={() => setShowSuggest(false)} />
<CompleteModal
item={completingItem}
onSubmit={handleComplete}
onClose={() => setCompletingItem(null)}
/>
)}
{/* Table */}
{items.length > 0 ? (
<VerificationTable items={items} onComplete={setCompletingItem} onDelete={handleDelete} />
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gray-50 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Titel</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Methode</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Gefaehrdung</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Massnahme</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ergebnis</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{items.map((item) => (
<tr key={item.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
<td className="px-4 py-3">
<div className="text-sm font-medium text-gray-900 dark:text-white">{item.title}</div>
{item.description && (
<div className="text-xs text-gray-500 truncate max-w-[200px]">{item.description}</div>
)}
</td>
<td className="px-4 py-3">
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
{VERIFICATION_METHODS.find((m) => m.value === item.method)?.label || item.method}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-600">{item.linked_hazard_name || '--'}</td>
<td className="px-4 py-3 text-sm text-gray-600">{item.linked_mitigation_name || '--'}</td>
<td className="px-4 py-3"><StatusBadge status={item.status} /></td>
<td className="px-4 py-3 text-sm text-gray-600 max-w-[150px] truncate">{item.result || '--'}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
{item.status !== 'completed' && item.status !== 'failed' && (
<button
onClick={() => setCompletingItem(item)}
className="text-xs px-2.5 py-1 bg-green-50 text-green-700 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
>
Abschliessen
</button>
)}
<button
onClick={() => handleDelete(item.id)}
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
!showForm && (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
@@ -178,16 +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>
)
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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>
)
}

File diff suppressed because it is too large Load Diff

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

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

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