Compare commits
176 Commits
5da93c5d10
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
824b1be6a4 | ||
|
|
062e827801 | ||
|
|
f404226d6e | ||
|
|
8dfab4ba14 | ||
|
|
5c1a514b52 | ||
|
|
e091bbc855 | ||
|
|
ff4c359d46 | ||
|
|
f169b13dbf | ||
|
|
42d0c7b1fc | ||
|
|
4fcb842a92 | ||
|
|
38d3d24121 | ||
|
|
dd64e33e88 | ||
|
|
2f8269d115 | ||
|
|
532febe35c | ||
|
|
0a0863f31c | ||
|
|
d892ad161f | ||
|
|
17153ccbe8 | ||
|
|
352d7112c9 | ||
|
|
0957254547 | ||
|
|
f17608a956 | ||
|
|
ce3df9f080 | ||
|
|
2da39e035d | ||
|
|
1989c410a9 | ||
|
|
c55a6ab995 | ||
|
|
bc75b4455d | ||
|
|
712fa8cb74 | ||
|
|
447ec08509 | ||
|
|
8cb1dc1108 | ||
|
|
f8d9919b97 | ||
|
|
fb2cf29b34 | ||
|
|
f39e5a71af | ||
|
|
ac42a0aaa0 | ||
|
|
52e463a7c8 | ||
|
|
2dee62fa6f | ||
|
|
3fb07e201f | ||
|
|
81c9ce5de3 | ||
|
|
db7c207464 | ||
|
|
cb034b8009 | ||
|
|
564f93259b | ||
|
|
89ac223c41 | ||
|
|
23dd5116b3 | ||
|
|
81ce9dde07 | ||
|
|
5e9cab6ab5 | ||
|
|
a29bfdd588 | ||
|
|
9dbb4cc5d2 | ||
|
|
c56bccaedf | ||
|
|
230fbeb490 | ||
|
|
6d3bdf8e74 | ||
|
|
200facda6a | ||
|
|
9282850138 | ||
|
|
770f0b5ab0 | ||
|
|
35784c35eb | ||
|
|
cce2707c03 | ||
|
|
2efc738803 | ||
|
|
e6201d5239 | ||
|
|
48ca0a6bef | ||
|
|
1a63f5857b | ||
|
|
295c18c6f7 | ||
|
|
649a3c5e4e | ||
|
|
bdd2f6fa0f | ||
|
|
ac6134ce6d | ||
|
|
0027f78fc5 | ||
|
|
b29a7caee7 | ||
|
|
a14e2f3a00 | ||
|
|
71b8c33270 | ||
|
|
f2924a58ed | ||
|
|
643b26618f | ||
|
|
c52dbdb8f1 | ||
|
|
c3a53fe5d2 | ||
|
|
df5b6d69ef | ||
|
|
4f6ac9b23a | ||
|
|
5ea31a3236 | ||
|
|
95c371e9a5 | ||
|
|
b1627252ee | ||
|
|
2a0449c9b7 | ||
|
|
92d37a1660 | ||
|
|
0e16640c28 | ||
|
|
24f02b52ed | ||
|
|
9b0f25c105 | ||
|
|
1cc34c23d9 | ||
|
|
5dd7a27336 | ||
|
|
c3afa628ed | ||
|
|
4b1eede45b | ||
|
|
2a70441eaa | ||
|
|
f2819b99af | ||
|
|
3bb9fffab6 | ||
|
|
148c7ba3af | ||
|
|
a9e0869205 | ||
|
|
653aad57e3 | ||
|
|
a7f7e57dd7 | ||
|
|
567e82ddf5 | ||
|
|
36ef34169a | ||
|
|
d22c47c9eb | ||
|
|
825e070ed9 | ||
|
|
4f6bc8f6f6 | ||
|
|
d2133dbfa2 | ||
|
|
6d2de9b897 | ||
|
|
5adb1c5f16 | ||
|
|
9c1355c05f | ||
|
|
3b2006ebce | ||
|
|
c7651796c9 | ||
|
|
c8fd9cc780 | ||
|
|
0d95c3bb44 | ||
|
|
f066cf1a03 | ||
|
|
dd09fa7a46 | ||
|
|
f3e05c1bf7 | ||
|
|
2ed1c08acf | ||
|
|
4018b9af9b | ||
|
|
a9f291ff49 | ||
|
|
0171d611f6 | ||
|
|
637fab6fdb | ||
|
|
d462141ccd | ||
|
|
5f8aebf5b1 | ||
|
|
c74f506415 | ||
|
|
49ce417428 | ||
|
|
13d13c8226 | ||
|
|
b6e6ffaaee | ||
|
|
8a05fcc2f0 | ||
|
|
9812ff46f3 | ||
|
|
30236c0001 | ||
|
|
b4d2be83eb | ||
|
|
38c7cf0a00 | ||
|
|
399fa62267 | ||
| f1710fdb9e | |||
|
|
499ddc04d5 | ||
|
|
f738ca8c52 | ||
|
|
c530898963 | ||
|
|
cdafc4d9f4 | ||
|
|
de19ef0684 | ||
|
|
c87f07c99a | ||
|
|
453eec9ed8 | ||
|
|
050f353192 | ||
|
|
8442115e7c | ||
|
|
999cc81c78 | ||
|
|
ff66612beb | ||
|
|
42ec3cad6d | ||
|
|
9945a62a50 | ||
|
|
eef1c2e7d3 | ||
|
|
a0e2a35e66 | ||
|
|
57f390190d | ||
|
|
cf60c39658 | ||
|
|
c88653b221 | ||
|
|
87d06c8b20 | ||
|
|
0b47612272 | ||
|
|
c14b31b3bc | ||
|
|
0b836f7e2d | ||
|
|
18d9eec654 | ||
|
|
339505feed | ||
|
|
23b9808bf3 | ||
|
|
c3654bc9ea | ||
|
|
363bf9606a | ||
|
|
e88c0aeeb3 | ||
|
|
ebe7e90bd8 | ||
|
|
995de9e0f4 | ||
|
|
4e08364bc6 | ||
|
|
7f38df9d9c | ||
|
|
cb48b8289e | ||
|
|
46048554cb | ||
|
|
a673cb0ce4 | ||
|
|
7afbcfd9f5 | ||
|
|
091f093e1b | ||
|
|
5d99d5d47a | ||
|
|
e3a877b549 | ||
|
|
237c05a94c | ||
|
|
b1a0dd3615 | ||
|
|
6e7d0d9b14 | ||
|
|
24afed69c1 | ||
|
|
579fe1b5e1 | ||
|
|
ee6743c7c6 | ||
|
|
a6818b39c5 | ||
|
|
e3fb81fc0d | ||
|
|
3512963006 | ||
|
|
85b3cc3421 | ||
|
|
f6019ecba9 | ||
|
|
1f91e05600 | ||
|
|
3c0c1e49da |
@@ -2,41 +2,88 @@
|
||||
|
||||
## Entwicklungsumgebung (WICHTIG - IMMER ZUERST LESEN)
|
||||
|
||||
### Zwei-Rechner-Setup
|
||||
### Zwei-Rechner-Setup + Coolify
|
||||
|
||||
| Geraet | Rolle | Aufgaben |
|
||||
|--------|-------|----------|
|
||||
| **MacBook** | Entwicklung | Claude Terminal, Code-Entwicklung, Browser (Frontend-Tests) |
|
||||
| **Mac Mini** | Server | Docker, alle Services, Tests, Builds, Deployment |
|
||||
| **Mac Mini** | Lokaler Server | Docker fuer lokale Dev/Tests (NICHT fuer Production!) |
|
||||
| **Coolify** | Production | Automatisches Build + Deploy bei Push auf gitea |
|
||||
|
||||
**WICHTIG:** Code wird direkt auf dem MacBook in diesem Repo bearbeitet. Docker und Services laufen auf dem Mac Mini.
|
||||
**WICHTIG:** Code wird auf dem MacBook bearbeitet. Production-Deployment laeuft automatisch ueber Coolify.
|
||||
|
||||
### Entwicklungsworkflow
|
||||
### Entwicklungsworkflow (CI/CD — Coolify)
|
||||
|
||||
```bash
|
||||
# 1. Code auf MacBook bearbeiten (dieses Verzeichnis)
|
||||
# 2. Committen und pushen:
|
||||
# 2. Committen und zu BEIDEN Remotes pushen:
|
||||
git push origin main && git push gitea main
|
||||
|
||||
# 3. Auf Mac Mini pullen (WICHTIG: git -C statt cd):
|
||||
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance pull --no-rebase origin main"
|
||||
# 3. FERTIG! Push auf gitea triggert automatisch:
|
||||
# - Gitea Actions: Lint → Tests → Validierung
|
||||
# - Coolify: Build → Deploy
|
||||
# Dauer: ca. 3 Minuten
|
||||
# Status pruefen: https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions
|
||||
```
|
||||
|
||||
# 4. Container neu bauen (WICHTIG: -f statt cd, da cd in SSH nicht funktioniert!):
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml build --no-cache <service> && /usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml up -d <service>"
|
||||
**NICHT MEHR NOETIG:** Manuelles `ssh macmini "docker compose build"` fuer Production.
|
||||
**NIEMALS** manuell in Coolify auf "Redeploy" klicken — Gitea Actions triggert Coolify automatisch.
|
||||
|
||||
### Post-Push Deploy-Monitoring (PFLICHT nach jedem Push auf gitea)
|
||||
|
||||
**IMMER wenn Claude auf gitea pusht, MUSS danach automatisch das Deploy-Monitoring laufen:**
|
||||
|
||||
1. Dem User sofort mitteilen: "Deploy gestartet, ich ueberwache den Status..."
|
||||
2. Im Hintergrund Health-Checks pollen (alle 20 Sekunden, max 5 Minuten):
|
||||
```bash
|
||||
# Compliance Health-Endpoints:
|
||||
curl -sf https://api-dev.breakpilot.ai/health # Backend Compliance
|
||||
curl -sf https://sdk-dev.breakpilot.ai/health # AI Compliance SDK
|
||||
```
|
||||
3. Sobald ALLE Endpoints healthy sind, dem User im Chat melden:
|
||||
**"Deploy abgeschlossen! Du kannst jetzt testen: https://admin-dev.breakpilot.ai"**
|
||||
4. Falls nach 5 Minuten noch nicht healthy → Fehlermeldung mit Hinweis auf Coolify-Logs.
|
||||
|
||||
**Ablauf im Terminal:**
|
||||
```
|
||||
> git push gitea main ✓
|
||||
> "Deploy gestartet, ich ueberwache den Status..."
|
||||
> [Hintergrund-Polling laeuft]
|
||||
> "Deploy abgeschlossen! Alle Services healthy. Du kannst jetzt testen."
|
||||
```
|
||||
|
||||
### CI/CD Pipeline (Gitea Actions → Coolify)
|
||||
|
||||
```
|
||||
Push auf gitea main → go-lint/python-lint/nodejs-lint (nur PRs)
|
||||
→ test-go-ai-compliance
|
||||
→ test-python-backend-compliance
|
||||
→ test-python-document-crawler
|
||||
→ test-python-dsms-gateway
|
||||
→ validate-canonical-controls
|
||||
→ Coolify: Build + Deploy (automatisch bei Push)
|
||||
```
|
||||
|
||||
**Dateien:**
|
||||
- `.gitea/workflows/ci.yaml` — Pipeline-Definition (Tests + Validierung)
|
||||
- `docker-compose.yml` — Haupt-Compose
|
||||
- `docker-compose.hetzner.yml` — Override: arm64→amd64 fuer Coolify Production (x86_64)
|
||||
|
||||
### Lokale Entwicklung (Mac Mini — optional)
|
||||
|
||||
```bash
|
||||
# Nur fuer lokale Tests, NICHT fuer Production:
|
||||
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance pull --no-rebase origin main"
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml build --no-cache <service>"
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml up -d <service>"
|
||||
|
||||
# Fuer schnelle Iteration ohne Commit (rsync):
|
||||
rsync -avz --exclude node_modules --exclude .next --exclude .git \
|
||||
admin-compliance/ macmini:~/Projekte/breakpilot-compliance/admin-compliance/
|
||||
```
|
||||
|
||||
### SSH-Verbindung (fuer Docker/Tests)
|
||||
|
||||
```bash
|
||||
# RICHTIG — cd funktioniert NICHT in SSH-Einzelbefehlen:
|
||||
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance <git-cmd>"
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml <compose-cmd>"
|
||||
ssh macmini "/usr/local/bin/docker exec bp-compliance-<service> <cmd>"
|
||||
```
|
||||
**WICHTIG:** Docker-Pfad auf Mac Mini ist `/usr/local/bin/docker` (nicht im Standard-SSH-PATH).
|
||||
**WICHTIG:** `cd` funktioniert NICHT in SSH-Einzelbefehlen — immer `-f <pfad>/docker-compose.yml` verwenden!
|
||||
|
||||
---
|
||||
|
||||
@@ -48,32 +95,34 @@ ssh macmini "/usr/local/bin/docker exec bp-compliance-<service> <cmd>"
|
||||
- RAG-Service (Vektorsuche fuer Compliance-Dokumente)
|
||||
- Nginx (Reverse Proxy)
|
||||
|
||||
**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_*)
|
||||
**Externe Services (Production):**
|
||||
- PostgreSQL 17 (sslmode=require) — Schemas: `compliance`, `public`
|
||||
- Qdrant @ `qdrant-dev.breakpilot.ai` (HTTPS, API-Key)
|
||||
- Object Storage @ `nbg1.your-objectstorage.com` (S3-kompatibel, TLS)
|
||||
- Object Storage (S3-kompatibel, TLS)
|
||||
|
||||
Config via `.env` auf Mac Mini (nicht im Repo): `COMPLIANCE_DATABASE_URL`, `QDRANT_URL`, `QDRANT_API_KEY`
|
||||
|
||||
Pruefen: `curl -sf http://macmini:8099/health`
|
||||
Config via `.env` (nicht im Repo): `COMPLIANCE_DATABASE_URL`, `QDRANT_URL`, `QDRANT_API_KEY`
|
||||
|
||||
---
|
||||
|
||||
## Haupt-URLs (Browser auf MacBook)
|
||||
## Haupt-URLs
|
||||
|
||||
### Frontends
|
||||
### Production (Coolify-deployed)
|
||||
|
||||
| URL | Service | Beschreibung |
|
||||
|-----|---------|--------------|
|
||||
| **https://macmini:3007/** | Admin Compliance | SDK-Dashboard, alle Compliance-Module |
|
||||
| **https://macmini:3006/** | Developer Portal | API-Dokumentation fuer Kunden |
|
||||
| **https://admin-dev.breakpilot.ai/** | Admin Compliance | SDK-Dashboard, alle Compliance-Module |
|
||||
| **https://developers-dev.breakpilot.ai/** | Developer Portal | API-Dokumentation fuer Kunden |
|
||||
| https://api-dev.breakpilot.ai/ | Backend Compliance | Compliance APIs (DSGVO, DSR, GDPR) |
|
||||
| https://sdk-dev.breakpilot.ai/ | AI Compliance SDK | KI-konforme Compliance-Analyse |
|
||||
|
||||
### Backend-APIs
|
||||
### Lokal (Mac Mini — nur Dev/Tests)
|
||||
|
||||
| URL | Service | Beschreibung |
|
||||
|-----|---------|--------------|
|
||||
| https://macmini:8002/ | Backend Compliance | Compliance APIs (DSGVO, DSR, GDPR) |
|
||||
| https://macmini:8093/ | AI Compliance SDK | KI-konforme Compliance-Analyse |
|
||||
| https://macmini:3007/ | Admin Compliance | Lokale Entwicklung |
|
||||
| https://macmini:3006/ | Developer Portal | Lokale Entwicklung |
|
||||
| https://macmini:8002/ | Backend Compliance | Lokale Entwicklung |
|
||||
| https://macmini:8093/ | AI Compliance SDK | Lokale Entwicklung |
|
||||
|
||||
### Admin Compliance Module (https://macmini:3007/)
|
||||
|
||||
@@ -113,18 +162,6 @@ Pruefen: `curl -sf http://macmini:8099/health`
|
||||
| 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
|
||||
@@ -169,50 +206,54 @@ breakpilot-compliance/
|
||||
├── dsms-node/ # IPFS Node
|
||||
├── dsms-gateway/ # IPFS Gateway
|
||||
├── scripts/ # Helper Scripts
|
||||
└── docker-compose.yml # Compliance Compose (~8 Services)
|
||||
├── docker-compose.yml # Compliance Compose (~10 Services, platform: arm64)
|
||||
├── docker-compose.hetzner.yml # Override: arm64→amd64 fuer Coolify Production
|
||||
└── .gitea/workflows/ci.yaml # CI/CD Pipeline (Lint → Tests → Validierung)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Haeufige Befehle
|
||||
|
||||
### Docker
|
||||
### Deployment (CI/CD — Standardweg)
|
||||
|
||||
```bash
|
||||
# Compliance-Services starten (Core muss laufen!)
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml up -d"
|
||||
# Committen und pushen → Coolify deployt automatisch:
|
||||
git push origin main && git push gitea main
|
||||
|
||||
# Einzelnen Service neu bauen
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml build --no-cache <service>"
|
||||
# CI-Status pruefen (im Browser):
|
||||
# https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions
|
||||
|
||||
# Service neu bauen und starten
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml build --no-cache <service> && /usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml up -d <service>"
|
||||
|
||||
# Logs
|
||||
ssh macmini "/usr/local/bin/docker logs -f bp-compliance-<service>"
|
||||
|
||||
# Status
|
||||
ssh macmini "/usr/local/bin/docker ps --filter name=bp-compliance"
|
||||
# Health Checks:
|
||||
curl -sf https://api-dev.breakpilot.ai/health
|
||||
curl -sf https://sdk-dev.breakpilot.ai/health
|
||||
```
|
||||
|
||||
**WICHTIG:** Docker-Pfad auf Mac Mini ist `/usr/local/bin/docker` (nicht im Standard-SSH-PATH).
|
||||
**WICHTIG:** `cd` funktioniert NICHT in SSH-Einzelbefehlen — immer `-f <pfad>/docker-compose.yml` verwenden!
|
||||
Der CLAUDE.md-Entwicklungsworkflow und die Beispiele mit `cd ... &&` sind veraltet — nie so verwenden.
|
||||
|
||||
### Git
|
||||
|
||||
```bash
|
||||
# Zu BEIDEN Remotes pushen (PFLICHT! — vom MacBook):
|
||||
git push origin main && git push gitea main
|
||||
|
||||
# Auf Mac Mini pullen (RICHTIG: git -C statt cd):
|
||||
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance pull --no-rebase origin main"
|
||||
|
||||
# Remotes:
|
||||
# origin: lokale Gitea (macmini:3003)
|
||||
# gitea: gitea.meghsakha.com:22222
|
||||
```
|
||||
|
||||
### Lokale Docker-Befehle (Mac Mini — nur fuer Dev/Tests)
|
||||
|
||||
```bash
|
||||
# Logs
|
||||
ssh macmini "/usr/local/bin/docker logs -f bp-compliance-<service>"
|
||||
|
||||
# Status
|
||||
ssh macmini "/usr/local/bin/docker ps --filter name=bp-compliance"
|
||||
|
||||
# Lokaler Rebuild (nur wenn noetig):
|
||||
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance pull --no-rebase origin main"
|
||||
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml build --no-cache <service> && /usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-compliance/docker-compose.yml up -d <service>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Kernprinzipien
|
||||
@@ -290,10 +331,6 @@ 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
|
||||
@@ -304,7 +341,7 @@ POST/GET /api/v1/dsr/requests
|
||||
POST/GET /api/v1/gdpr/exports
|
||||
POST/GET /api/v1/consent/admin
|
||||
|
||||
# Stammdaten, Versionierung & Change-Requests (Phase 1-6, 2026-03-07)
|
||||
# Stammdaten, Versionierung & Change-Requests
|
||||
GET/POST/DELETE /api/compliance/company-profile
|
||||
GET /api/compliance/company-profile/template-context
|
||||
GET /api/compliance/change-requests
|
||||
@@ -322,24 +359,6 @@ 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)
|
||||
@@ -347,9 +366,7 @@ GET /api/compliance/{doc}/{id}/versions
|
||||
| Datei | Beschreibung |
|
||||
|-------|--------------|
|
||||
| `admin-compliance/app/(sdk)/` | Alle 37+ SDK-Routes |
|
||||
| `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/Sidebar/SDKSidebar.tsx` | SDK Navigation |
|
||||
| `admin-compliance/components/sdk/CommandBar.tsx` | Command Palette |
|
||||
| `admin-compliance/lib/sdk/context.tsx` | SDK State (Provider) |
|
||||
| `backend-compliance/compliance/` | Haupt-Package (50+ Dateien) |
|
||||
|
||||
61
.env.coolify.example
Normal file
61
.env.coolify.example
Normal file
@@ -0,0 +1,61 @@
|
||||
# =========================================================
|
||||
# BreakPilot Compliance — Coolify Environment Variables
|
||||
# =========================================================
|
||||
# Copy these into Coolify's environment variable UI
|
||||
# for the breakpilot-compliance Docker Compose resource.
|
||||
# =========================================================
|
||||
|
||||
# --- External PostgreSQL (Coolify-managed, same as Core) ---
|
||||
COMPLIANCE_DATABASE_URL=postgresql://breakpilot:CHANGE_ME@<coolify-postgres-hostname>:5432/breakpilot_db
|
||||
|
||||
# --- Security ---
|
||||
JWT_SECRET=CHANGE_ME_SAME_AS_CORE
|
||||
|
||||
# --- External S3 Storage (same as Core) ---
|
||||
S3_ENDPOINT=<s3-endpoint-host:port>
|
||||
S3_ACCESS_KEY=CHANGE_ME_SAME_AS_CORE
|
||||
S3_SECRET_KEY=CHANGE_ME_SAME_AS_CORE
|
||||
S3_SECURE=true
|
||||
|
||||
# --- External Qdrant ---
|
||||
QDRANT_URL=https://<qdrant-hostname>
|
||||
QDRANT_API_KEY=CHANGE_ME_QDRANT_API_KEY
|
||||
|
||||
# --- Session ---
|
||||
SESSION_TTL_HOURS=24
|
||||
|
||||
# --- SMTP (Real mail server) ---
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=compliance@breakpilot.ai
|
||||
SMTP_PASSWORD=CHANGE_ME_SMTP_PASSWORD
|
||||
SMTP_FROM_NAME=BreakPilot Compliance
|
||||
SMTP_FROM_ADDR=compliance@breakpilot.ai
|
||||
|
||||
# --- LLM Configuration ---
|
||||
COMPLIANCE_LLM_PROVIDER=anthropic
|
||||
SELF_HOSTED_LLM_URL=
|
||||
SELF_HOSTED_LLM_MODEL=
|
||||
COMPLIANCE_LLM_MAX_TOKENS=4096
|
||||
COMPLIANCE_LLM_TEMPERATURE=0.3
|
||||
COMPLIANCE_LLM_TIMEOUT=120
|
||||
ANTHROPIC_API_KEY=CHANGE_ME_ANTHROPIC_KEY
|
||||
ANTHROPIC_DEFAULT_MODEL=claude-sonnet-4-5-20250929
|
||||
|
||||
# --- Ollama (optional) ---
|
||||
OLLAMA_URL=
|
||||
OLLAMA_DEFAULT_MODEL=
|
||||
COMPLIANCE_LLM_MODEL=
|
||||
|
||||
# --- LLM Fallback ---
|
||||
LLM_FALLBACK_PROVIDER=
|
||||
|
||||
# --- PII & Audit ---
|
||||
PII_REDACTION_ENABLED=true
|
||||
PII_REDACTION_LEVEL=standard
|
||||
AUDIT_RETENTION_DAYS=365
|
||||
AUDIT_LOG_PROMPTS=true
|
||||
|
||||
# --- Frontend URLs (build args) ---
|
||||
NEXT_PUBLIC_API_URL=https://api-compliance.breakpilot.ai
|
||||
NEXT_PUBLIC_SDK_URL=https://sdk.breakpilot.ai
|
||||
@@ -1,12 +1,16 @@
|
||||
# Gitea Actions CI Pipeline
|
||||
# Gitea Actions CI/CD Pipeline
|
||||
# BreakPilot Compliance
|
||||
#
|
||||
# Services:
|
||||
# Go: ai-compliance-sdk
|
||||
# Python: backend-compliance, document-crawler, dsms-gateway
|
||||
# Node.js: admin-compliance, developer-portal
|
||||
#
|
||||
# Workflow:
|
||||
# Push auf main → Tests → Deploy (Coolify)
|
||||
# Pull Request → Lint + Tests (kein Deploy)
|
||||
|
||||
name: CI
|
||||
name: CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -164,3 +168,42 @@ jobs:
|
||||
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
||||
pip install --quiet --no-cache-dir pytest pytest-asyncio
|
||||
python -m pytest test_main.py -v --tb=short
|
||||
|
||||
# ========================================
|
||||
# Validate Canonical Controls
|
||||
# ========================================
|
||||
|
||||
validate-canonical-controls:
|
||||
runs-on: docker
|
||||
container: python:3.12-slim
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Validate controls
|
||||
run: |
|
||||
python scripts/validate-controls.py
|
||||
|
||||
# ========================================
|
||||
# Deploy via Coolify (nur main, kein PR)
|
||||
# ========================================
|
||||
|
||||
deploy-coolify:
|
||||
name: Deploy
|
||||
runs-on: docker
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs:
|
||||
- test-go-ai-compliance
|
||||
- test-python-backend-compliance
|
||||
- test-python-document-crawler
|
||||
- test-python-dsms-gateway
|
||||
- validate-canonical-controls
|
||||
container:
|
||||
image: alpine:latest
|
||||
steps:
|
||||
- name: Trigger Coolify deploy
|
||||
run: |
|
||||
apk add --no-cache curl
|
||||
curl -sf "${{ secrets.COOLIFY_WEBHOOK }}" \
|
||||
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
|
||||
|
||||
115
.gitea/workflows/rag-ingest.yaml
Normal file
115
.gitea/workflows/rag-ingest.yaml
Normal file
@@ -0,0 +1,115 @@
|
||||
# Gitea Actions — RAG Legal Corpus Ingestion
|
||||
#
|
||||
# Manuell triggerbarer Workflow zur Ingestion von Rechtstexten in Qdrant.
|
||||
# Trigger: Gitea UI → Actions → "RAG Ingestion" → Run
|
||||
#
|
||||
# Phasen: gesetze, eu, templates, datenschutz, verbraucherschutz, verify, version, all
|
||||
#
|
||||
# Voraussetzung: RAG-Service und Qdrant muessen auf Coolify laufen.
|
||||
# Die BreakPilot-Services muessen deployed sein (ci.yaml deploy-coolify).
|
||||
|
||||
name: RAG Ingestion
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
phase:
|
||||
description: 'Ingestion Phase (gesetze, eu, templates, datenschutz, verbraucherschutz, dach, security, verify, version, all)'
|
||||
required: true
|
||||
default: 'verbraucherschutz'
|
||||
|
||||
jobs:
|
||||
ingest:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
steps:
|
||||
- name: Setup
|
||||
run: |
|
||||
apk add --no-cache git curl bash > /dev/null 2>&1
|
||||
|
||||
- name: Checkout
|
||||
run: |
|
||||
git clone --depth 1 --branch main ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
|
||||
- name: Run Ingestion
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PHASE="${{ github.event.inputs.phase }}"
|
||||
|
||||
echo "=== RAG Ingestion: Phase ${PHASE} ==="
|
||||
echo ""
|
||||
|
||||
# Pruefen ob Services laufen
|
||||
echo "--- BreakPilot Container ---"
|
||||
docker ps --filter name=bp- --format "{{.Names}}: {{.Status}}" 2>/dev/null || true
|
||||
echo ""
|
||||
|
||||
# Netzwerk finden in dem die bp-Services laufen
|
||||
BP_NETWORK=$(docker inspect bp-core-rag-service --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}' 2>/dev/null || echo "")
|
||||
if [ -z "$BP_NETWORK" ]; then
|
||||
BP_NETWORK=$(docker inspect bp-compliance-backend --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{end}}' 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
if [ -z "$BP_NETWORK" ]; then
|
||||
echo "FEHLER: Keine BreakPilot-Container gefunden."
|
||||
echo "Bitte zuerst deployen (CI/CD Pipeline oder manuell)."
|
||||
echo ""
|
||||
echo "Verfuegbare Container:"
|
||||
docker ps --format " {{.Names}}" 2>/dev/null || true
|
||||
echo ""
|
||||
echo "Verfuegbare Netzwerke:"
|
||||
docker network ls --format " {{.Name}}" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "BreakPilot Netzwerk: $BP_NETWORK"
|
||||
echo ""
|
||||
|
||||
# Ingestion-Container erstellen (noch nicht starten),
|
||||
# dann Scripts aus dem Checkout per docker cp hineinkopieren.
|
||||
# So verwenden wir IMMER die neueste Version der Scripts,
|
||||
# unabhaengig vom Deploy-Dir auf dem Host.
|
||||
CONTAINER_ID=$(docker create \
|
||||
--network "$BP_NETWORK" \
|
||||
-e "WORK_DIR=/tmp/rag-ingestion" \
|
||||
-e "RAG_URL=http://bp-core-rag-service:8097/api/v1/documents/upload" \
|
||||
-e "QDRANT_URL=https://qdrant-dev.breakpilot.ai" \
|
||||
-e "QDRANT_API_KEY=z9cKbT74vl1aKPD1QGIlKWfET47VH93u" \
|
||||
-e "SDK_URL=http://bp-compliance-ai-sdk:8090" \
|
||||
alpine:3.19 \
|
||||
sh -c "
|
||||
apk add --no-cache curl bash coreutils git python3 unzip > /dev/null 2>&1
|
||||
mkdir -p /tmp/rag-ingestion/{pdfs,repos,texts}
|
||||
mkdir -p /workspace/scripts
|
||||
cp -r /workspace_scripts/* /workspace/scripts/ 2>/dev/null || true
|
||||
cd /workspace
|
||||
if [ '${PHASE}' = 'all' ]; then
|
||||
bash scripts/ingest-legal-corpus.sh
|
||||
elif [ '${PHASE}' = 'download' ]; then
|
||||
bash scripts/ingest-legal-corpus.sh --only download
|
||||
else
|
||||
echo '=== Running download phase first ==='
|
||||
bash scripts/ingest-legal-corpus.sh --only download
|
||||
echo ''
|
||||
echo '=== Running phase: ${PHASE} ==='
|
||||
bash scripts/ingest-legal-corpus.sh --only '${PHASE}'
|
||||
fi
|
||||
")
|
||||
|
||||
echo "Container: $CONTAINER_ID"
|
||||
|
||||
# Workspace-Dir im Container anlegen und Scripts hineinkopieren
|
||||
docker cp scripts "${CONTAINER_ID}:/workspace_scripts"
|
||||
echo "Scripts kopiert (aus Git-Checkout)"
|
||||
|
||||
# Container starten und Output streamen
|
||||
docker start -a "${CONTAINER_ID}" || EXITCODE=$?
|
||||
|
||||
# Container aufraeumen
|
||||
docker rm -f "${CONTAINER_ID}" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=== Ingestion abgeschlossen ==="
|
||||
|
||||
# Exit mit dem Original-Exitcode
|
||||
exit ${EXITCODE:-0}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -17,6 +17,9 @@ __pycache__/
|
||||
*.pyc
|
||||
venv/
|
||||
.venv/
|
||||
.coverage
|
||||
coverage.out
|
||||
test_*.db
|
||||
|
||||
# Docker
|
||||
backups/*.backup
|
||||
@@ -40,3 +43,4 @@ backups/*.backup
|
||||
*.mp3
|
||||
*.wav
|
||||
ai-compliance-sdk/server
|
||||
*.bak
|
||||
|
||||
@@ -22,6 +22,9 @@ ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_OLD_ADMIN_URL=$NEXT_PUBLIC_OLD_ADMIN_URL
|
||||
ENV NEXT_PUBLIC_SDK_URL=$NEXT_PUBLIC_SDK_URL
|
||||
|
||||
# Ensure public directory exists (Next.js standalone needs it)
|
||||
RUN mkdir -p public
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
@@ -34,8 +37,8 @@ WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
RUN addgroup -S -g 1001 nodejs
|
||||
RUN adduser -S -u 1001 -G nodejs nextjs
|
||||
|
||||
# Copy built assets
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
@@ -48,12 +48,12 @@ describe('Ingestion Script: ingest-industry-compliance.sh', () => {
|
||||
expect(scriptContent).toContain('chunk_strategy=recursive')
|
||||
})
|
||||
|
||||
it('should use chunk_size=512', () => {
|
||||
expect(scriptContent).toContain('chunk_size=512')
|
||||
it('should use chunk_size=1024', () => {
|
||||
expect(scriptContent).toContain('chunk_size=1024')
|
||||
})
|
||||
|
||||
it('should use chunk_overlap=50', () => {
|
||||
expect(scriptContent).toContain('chunk_overlap=50')
|
||||
it('should use chunk_overlap=128', () => {
|
||||
expect(scriptContent).toContain('chunk_overlap=128')
|
||||
})
|
||||
|
||||
it('should validate minimum file size', () => {
|
||||
|
||||
@@ -591,12 +591,43 @@ 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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,76 @@ 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
|
||||
*/
|
||||
@@ -221,10 +291,18 @@ 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, ...llmFindings]
|
||||
const allFindings = [...deterministicFindings, ...forbiddenFindings, ...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')
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}`)
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to fetch registration' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const body = await request.json()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to update registration' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to update status' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
32
admin-compliance/app/api/sdk/v1/ai-registration/route.ts
Normal file
32
admin-compliance/app/api/sdk/v1/ai-registration/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to fetch registrations' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const body = await request.json()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to create registration' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
349
admin-compliance/app/api/sdk/v1/canonical/route.ts
Normal file
349
admin-compliance/app/api/sdk/v1/canonical/route.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/canonical?endpoint=...
|
||||
*
|
||||
* Routes to backend canonical control endpoints:
|
||||
* endpoint=frameworks → GET /api/compliance/v1/canonical/frameworks
|
||||
* endpoint=controls → GET /api/compliance/v1/canonical/controls(?severity=...&domain=...)
|
||||
* endpoint=control&id= → GET /api/compliance/v1/canonical/controls/{id}
|
||||
* endpoint=sources → GET /api/compliance/v1/canonical/sources
|
||||
* endpoint=licenses → GET /api/compliance/v1/canonical/licenses
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const endpoint = searchParams.get('endpoint') || 'frameworks'
|
||||
|
||||
let backendPath: string
|
||||
|
||||
switch (endpoint) {
|
||||
case 'frameworks':
|
||||
backendPath = '/api/compliance/v1/canonical/frameworks'
|
||||
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()
|
||||
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) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'sources':
|
||||
backendPath = '/api/compliance/v1/canonical/sources'
|
||||
break
|
||||
|
||||
case 'licenses':
|
||||
backendPath = '/api/compliance/v1/canonical/licenses'
|
||||
break
|
||||
|
||||
// Generator endpoints
|
||||
case 'generate-jobs':
|
||||
backendPath = '/api/compliance/v1/canonical/generate/jobs'
|
||||
break
|
||||
|
||||
case 'generate-status': {
|
||||
const jobId = searchParams.get('jobId')
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: 'Missing jobId' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/generate/status/${encodeURIComponent(jobId)}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'review-queue': {
|
||||
const state = searchParams.get('release_state') || 'needs_review'
|
||||
backendPath = `/api/compliance/v1/canonical/generate/review-queue?release_state=${encodeURIComponent(state)}`
|
||||
break
|
||||
}
|
||||
|
||||
case 'processed-stats':
|
||||
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')
|
||||
const custParams = new URLSearchParams()
|
||||
if (custSeverity) custParams.set('severity', custSeverity)
|
||||
if (custDomain) custParams.set('domain', custDomain)
|
||||
const custQs = custParams.toString()
|
||||
backendPath = `/api/compliance/v1/canonical/controls-customer${custQs ? `?${custQs}` : ''}`
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: `Unknown endpoint: ${endpoint}` }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}${backendPath}`)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return NextResponse.json(null, { status: 404 })
|
||||
}
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Canonical control proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: POST /api/sdk/v1/canonical?endpoint=...
|
||||
*
|
||||
* endpoint=create-control → POST /api/compliance/v1/canonical/controls
|
||||
* endpoint=similarity-check&id= → POST /api/compliance/v1/canonical/controls/{id}/similarity-check
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const endpoint = searchParams.get('endpoint')
|
||||
const body = await request.json()
|
||||
|
||||
let backendPath: string
|
||||
|
||||
if (endpoint === 'create-control') {
|
||||
backendPath = '/api/compliance/v1/canonical/controls'
|
||||
} else if (endpoint === 'generate') {
|
||||
backendPath = '/api/compliance/v1/canonical/generate'
|
||||
} else if (endpoint === 'review') {
|
||||
const controlId = searchParams.get('id')
|
||||
if (!controlId) {
|
||||
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) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
backendPath = `/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}/similarity-check`
|
||||
} else {
|
||||
return NextResponse.json({ error: `Unknown POST endpoint: ${endpoint}` }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}${backendPath}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json(), { status: response.status })
|
||||
} catch (error) {
|
||||
console.error('Canonical control POST proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: PUT /api/sdk/v1/canonical?endpoint=update-control&id=AUTH-001
|
||||
*
|
||||
* Routes to: PUT /api/compliance/v1/canonical/controls/{id}
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const controlId = searchParams.get('id')
|
||||
if (!controlId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(await response.json())
|
||||
} catch (error) {
|
||||
console.error('Canonical control PUT proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy: DELETE /api/sdk/v1/canonical?id=AUTH-001
|
||||
*
|
||||
* Routes to: DELETE /api/compliance/v1/canonical/controls/{id}
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const controlId = searchParams.get('id')
|
||||
if (!controlId) {
|
||||
return NextResponse.json({ error: 'Missing control id' }, { status: 400 })
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/v1/canonical/controls/${encodeURIComponent(controlId)}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
|
||||
if (!response.ok && response.status !== 204) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
return new NextResponse(null, { status: 204 })
|
||||
} catch (error) {
|
||||
console.error('Canonical control DELETE proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 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')
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 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')
|
||||
}
|
||||
48
admin-compliance/app/api/sdk/v1/payment-compliance/route.ts
Normal file
48
admin-compliance/app/api/sdk/v1/payment-compliance/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const endpoint = searchParams.get('endpoint') || 'controls'
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
let path: string
|
||||
switch (endpoint) {
|
||||
case 'controls':
|
||||
const domain = searchParams.get('domain') || ''
|
||||
path = `/sdk/v1/payment-compliance/controls${domain ? `?domain=${domain}` : ''}`
|
||||
break
|
||||
case 'assessments':
|
||||
path = '/sdk/v1/payment-compliance/assessments'
|
||||
break
|
||||
default:
|
||||
path = '/sdk/v1/payment-compliance/controls'
|
||||
}
|
||||
|
||||
const resp = await fetch(`${SDK_URL}${path}`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to fetch' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const body = await request.json()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/assessments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to create' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender/${id}`)
|
||||
return NextResponse.json(await resp.json())
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action') || 'extract'
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender/${id}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
return NextResponse.json(await resp.json(), { status: resp.status })
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
})
|
||||
return NextResponse.json(await resp.json())
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const formData = await request.formData()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
body: formData,
|
||||
})
|
||||
return NextResponse.json(await resp.json(), { status: resp.status })
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Upload failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,18 @@ async function proxyRequest(
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
@@ -69,6 +80,19 @@ 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) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
/**
|
||||
* Proxy: /api/sdk/v1/ucca/decision-tree/... → Go Backend /sdk/v1/ucca/decision-tree/...
|
||||
*/
|
||||
async function proxyRequest(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
const { path } = await params
|
||||
const subPath = path ? path.join('/') : ''
|
||||
const search = request.nextUrl.search || ''
|
||||
const targetUrl = `${SDK_URL}/sdk/v1/ucca/decision-tree/${subPath}${search}`
|
||||
|
||||
const tenantID = request.headers.get('X-Tenant-ID') || DEFAULT_TENANT
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'X-Tenant-ID': tenantID,
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: request.method,
|
||||
headers,
|
||||
}
|
||||
|
||||
if (request.method === 'POST' || request.method === 'PUT' || request.method === 'PATCH') {
|
||||
const body = await request.json()
|
||||
headers['Content-Type'] = 'application/json'
|
||||
fetchOptions.body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error(`Decision tree proxy error [${request.method} ${subPath}]:`, errorText)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data, { status: response.status })
|
||||
} catch (error) {
|
||||
console.error('Decision tree proxy connection error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to AI compliance backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = proxyRequest
|
||||
export const POST = proxyRequest
|
||||
export const DELETE = proxyRequest
|
||||
36
admin-compliance/app/api/sdk/v1/ucca/decision-tree/route.ts
Normal file
36
admin-compliance/app/api/sdk/v1/ucca/decision-tree/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/ucca/decision-tree → Go Backend GET /sdk/v1/ucca/decision-tree
|
||||
* Returns the decision tree definition (questions, structure)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const tenantID = request.headers.get('X-Tenant-ID') || DEFAULT_TENANT
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SDK_URL}/sdk/v1/ucca/decision-tree`, {
|
||||
headers: { 'X-Tenant-ID': tenantID },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('Decision tree GET error:', errorText)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Decision tree proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to AI compliance backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import DecisionTreeWizard from '@/components/sdk/ai-act/DecisionTreeWizard'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
@@ -21,6 +22,8 @@ interface AISystem {
|
||||
assessmentResult: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
type TabId = 'overview' | 'decision-tree' | 'results'
|
||||
|
||||
// =============================================================================
|
||||
// LOADING SKELETON
|
||||
// =============================================================================
|
||||
@@ -306,12 +309,178 @@ function AISystemCard({
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SAVED RESULTS TAB
|
||||
// =============================================================================
|
||||
|
||||
interface SavedResult {
|
||||
id: string
|
||||
system_name: string
|
||||
system_description?: string
|
||||
high_risk_result: string
|
||||
gpai_result: { gpai_category: string; is_systemic_risk: boolean }
|
||||
combined_obligations: string[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
function SavedResultsTab() {
|
||||
const [results, setResults] = useState<SavedResult[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/ucca/decision-tree/results')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setResults(data.results || [])
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Ergebnis wirklich löschen?')) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/ucca/decision-tree/results/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
setResults(prev => prev.filter(r => r.id !== id))
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
const riskLabels: Record<string, string> = {
|
||||
unacceptable: 'Unzulässig',
|
||||
high_risk: 'Hochrisiko',
|
||||
limited_risk: 'Begrenztes Risiko',
|
||||
minimal_risk: 'Minimales Risiko',
|
||||
not_applicable: 'Nicht anwendbar',
|
||||
}
|
||||
|
||||
const riskColors: Record<string, string> = {
|
||||
unacceptable: 'bg-red-100 text-red-700',
|
||||
high_risk: 'bg-orange-100 text-orange-700',
|
||||
limited_risk: 'bg-yellow-100 text-yellow-700',
|
||||
minimal_risk: 'bg-green-100 text-green-700',
|
||||
not_applicable: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
|
||||
const gpaiLabels: Record<string, string> = {
|
||||
none: 'Kein GPAI',
|
||||
standard: 'GPAI Standard',
|
||||
systemic: 'GPAI Systemisch',
|
||||
}
|
||||
|
||||
const gpaiColors: Record<string, string> = {
|
||||
none: 'bg-gray-100 text-gray-500',
|
||||
standard: 'bg-blue-100 text-blue-700',
|
||||
systemic: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSkeleton />
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Ergebnisse vorhanden</h3>
|
||||
<p className="mt-2 text-gray-500">Nutzen Sie den Entscheidungsbaum, um KI-Systeme zu klassifizieren.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{results.map(r => (
|
||||
<div key={r.id} className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">{r.system_name}</h4>
|
||||
{r.system_description && (
|
||||
<p className="text-sm text-gray-500 mt-0.5">{r.system_description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${riskColors[r.high_risk_result] || 'bg-gray-100 text-gray-500'}`}>
|
||||
{riskLabels[r.high_risk_result] || r.high_risk_result}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${gpaiColors[r.gpai_result?.gpai_category] || 'bg-gray-100 text-gray-500'}`}>
|
||||
{gpaiLabels[r.gpai_result?.gpai_category] || 'Kein GPAI'}
|
||||
</span>
|
||||
{r.gpai_result?.is_systemic_risk && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">Systemisch</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-2">
|
||||
{r.combined_obligations?.length || 0} Pflichten · {new Date(r.created_at).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(r.id)}
|
||||
className="px-3 py-1 text-xs text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TABS
|
||||
// =============================================================================
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Übersicht',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'decision-tree',
|
||||
label: 'Entscheidungsbaum',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'results',
|
||||
label: 'Ergebnisse',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function AIActPage() {
|
||||
const { state } = useSDK()
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
const [systems, setSystems] = useState<AISystem[]>([])
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
@@ -354,7 +523,6 @@ export default function AIActPage() {
|
||||
const handleAddSystem = async (data: Omit<AISystem, 'id' | 'assessmentDate' | 'assessmentResult'>) => {
|
||||
setError(null)
|
||||
if (editingSystem) {
|
||||
// Edit existing system via PUT
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/ai/systems/${editingSystem.id}`, {
|
||||
method: 'PUT',
|
||||
@@ -380,14 +548,12 @@ export default function AIActPage() {
|
||||
setError('Speichern fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
// Fallback: update locally
|
||||
setSystems(prev => prev.map(s =>
|
||||
s.id === editingSystem.id ? { ...s, ...data } : s
|
||||
))
|
||||
}
|
||||
setEditingSystem(null)
|
||||
} else {
|
||||
// Create new system via POST
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/ai/systems', {
|
||||
method: 'POST',
|
||||
@@ -415,7 +581,6 @@ export default function AIActPage() {
|
||||
setError('Registrierung fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
// Fallback: add locally
|
||||
const newSystem: AISystem = {
|
||||
...data,
|
||||
id: `ai-${Date.now()}`,
|
||||
@@ -503,17 +668,37 @@ export default function AIActPage() {
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
KI-System registrieren
|
||||
</button>
|
||||
{activeTab === 'overview' && (
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
KI-System registrieren
|
||||
</button>
|
||||
)}
|
||||
</StepHeader>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg w-fit">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-white text-purple-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
@@ -522,90 +707,105 @@ export default function AIActPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit System Form */}
|
||||
{showAddForm && (
|
||||
<AddSystemForm
|
||||
onSubmit={handleAddSystem}
|
||||
onCancel={() => { setShowAddForm(false); setEditingSystem(null) }}
|
||||
initialData={editingSystem}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">KI-Systeme gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{systems.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||
<div className="text-sm text-orange-600">Hochrisiko</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{highRiskCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Konform</div>
|
||||
<div className="text-3xl font-bold text-green-600">{compliantCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Nicht klassifiziert</div>
|
||||
<div className="text-3xl font-bold text-gray-500">{unclassifiedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Pyramid */}
|
||||
<RiskPyramid systems={systems} />
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'high-risk', 'limited-risk', 'minimal-risk', 'unclassified', 'compliant', 'non-compliant'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'high-risk' ? 'Hochrisiko' :
|
||||
f === 'limited-risk' ? 'Begrenztes Risiko' :
|
||||
f === 'minimal-risk' ? 'Minimales Risiko' :
|
||||
f === 'unclassified' ? 'Nicht klassifiziert' :
|
||||
f === 'compliant' ? 'Konform' : 'Nicht konform'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && <LoadingSkeleton />}
|
||||
|
||||
{/* AI Systems List */}
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{filteredSystems.map(system => (
|
||||
<AISystemCard
|
||||
key={system.id}
|
||||
system={system}
|
||||
onAssess={() => handleAssess(system.id)}
|
||||
onEdit={() => handleEdit(system)}
|
||||
onDelete={() => handleDelete(system.id)}
|
||||
assessing={assessingId === system.id}
|
||||
{/* Tab: Overview */}
|
||||
{activeTab === 'overview' && (
|
||||
<>
|
||||
{/* Add/Edit System Form */}
|
||||
{showAddForm && (
|
||||
<AddSystemForm
|
||||
onSubmit={handleAddSystem}
|
||||
onCancel={() => { setShowAddForm(false); setEditingSystem(null) }}
|
||||
initialData={editingSystem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">KI-Systeme gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{systems.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||
<div className="text-sm text-orange-600">Hochrisiko</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{highRiskCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Konform</div>
|
||||
<div className="text-3xl font-bold text-green-600">{compliantCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Nicht klassifiziert</div>
|
||||
<div className="text-3xl font-bold text-gray-500">{unclassifiedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Pyramid */}
|
||||
<RiskPyramid systems={systems} />
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'high-risk', 'limited-risk', 'minimal-risk', 'unclassified', 'compliant', 'non-compliant'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'high-risk' ? 'Hochrisiko' :
|
||||
f === 'limited-risk' ? 'Begrenztes Risiko' :
|
||||
f === 'minimal-risk' ? 'Minimales Risiko' :
|
||||
f === 'unclassified' ? 'Nicht klassifiziert' :
|
||||
f === 'compliant' ? 'Konform' : 'Nicht konform'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && <LoadingSkeleton />}
|
||||
|
||||
{/* AI Systems List */}
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{filteredSystems.map(system => (
|
||||
<AISystemCard
|
||||
key={system.id}
|
||||
system={system}
|
||||
onAssess={() => handleAssess(system.id)}
|
||||
onEdit={() => handleEdit(system)}
|
||||
onDelete={() => handleDelete(system.id)}
|
||||
assessing={assessingId === system.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filteredSystems.length === 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine KI-Systeme gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder registrieren Sie ein neues KI-System.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && filteredSystems.length === 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine KI-Systeme gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder registrieren Sie ein neues KI-System.</p>
|
||||
</div>
|
||||
{/* Tab: Decision Tree */}
|
||||
{activeTab === 'decision-tree' && (
|
||||
<DecisionTreeWizard />
|
||||
)}
|
||||
|
||||
{/* Tab: Results */}
|
||||
{activeTab === 'results' && (
|
||||
<SavedResultsTab />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
491
admin-compliance/app/sdk/ai-registration/page.tsx
Normal file
491
admin-compliance/app/sdk/ai-registration/page.tsx
Normal file
@@ -0,0 +1,491 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
interface Registration {
|
||||
id: string
|
||||
system_name: string
|
||||
system_version: string
|
||||
risk_classification: string
|
||||
gpai_classification: string
|
||||
registration_status: string
|
||||
eu_database_id: string
|
||||
provider_name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||
draft: { bg: 'bg-gray-100', text: 'text-gray-700', label: 'Entwurf' },
|
||||
ready: { bg: 'bg-blue-100', text: 'text-blue-700', label: 'Bereit' },
|
||||
submitted: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Eingereicht' },
|
||||
registered: { bg: 'bg-green-100', text: 'text-green-700', label: 'Registriert' },
|
||||
update_required: { bg: 'bg-orange-100', text: 'text-orange-700', label: 'Update noetig' },
|
||||
withdrawn: { bg: 'bg-red-100', text: 'text-red-700', label: 'Zurueckgezogen' },
|
||||
}
|
||||
|
||||
const RISK_STYLES: Record<string, { bg: string; text: string }> = {
|
||||
high_risk: { bg: 'bg-red-100', text: 'text-red-700' },
|
||||
limited_risk: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
|
||||
minimal_risk: { bg: 'bg-green-100', text: 'text-green-700' },
|
||||
not_classified: { bg: 'bg-gray-100', text: 'text-gray-500' },
|
||||
}
|
||||
|
||||
const INITIAL_FORM = {
|
||||
system_name: '',
|
||||
system_version: '1.0',
|
||||
system_description: '',
|
||||
intended_purpose: '',
|
||||
provider_name: '',
|
||||
provider_legal_form: '',
|
||||
provider_address: '',
|
||||
provider_country: 'DE',
|
||||
eu_representative_name: '',
|
||||
eu_representative_contact: '',
|
||||
risk_classification: 'not_classified',
|
||||
annex_iii_category: '',
|
||||
gpai_classification: 'none',
|
||||
conformity_assessment_type: 'internal',
|
||||
notified_body_name: '',
|
||||
notified_body_id: '',
|
||||
ce_marking: false,
|
||||
training_data_summary: '',
|
||||
}
|
||||
|
||||
export default function AIRegistrationPage() {
|
||||
const [registrations, setRegistrations] = useState<Registration[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showWizard, setShowWizard] = useState(false)
|
||||
const [wizardStep, setWizardStep] = useState(1)
|
||||
const [form, setForm] = useState({ ...INITIAL_FORM })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => { loadRegistrations() }, [])
|
||||
|
||||
async function loadRegistrations() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const resp = await fetch('/api/sdk/v1/ai-registration')
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
setRegistrations(data.registrations || [])
|
||||
}
|
||||
} catch {
|
||||
setError('Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const resp = await fetch('/api/sdk/v1/ai-registration', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
if (resp.ok) {
|
||||
setShowWizard(false)
|
||||
setForm({ ...INITIAL_FORM })
|
||||
setWizardStep(1)
|
||||
loadRegistrations()
|
||||
} else {
|
||||
const data = await resp.json()
|
||||
setError(data.error || 'Fehler beim Erstellen')
|
||||
}
|
||||
} catch {
|
||||
setError('Netzwerkfehler')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/sdk/v1/ai-registration/${id}`)
|
||||
if (resp.ok) {
|
||||
const reg = await resp.json()
|
||||
// Build export JSON client-side
|
||||
const exportData = {
|
||||
schema_version: '1.0',
|
||||
submission_type: 'ai_system_registration',
|
||||
regulation: 'EU AI Act (EU) 2024/1689',
|
||||
article: 'Art. 49',
|
||||
provider: { name: reg.provider_name, address: reg.provider_address, country: reg.provider_country },
|
||||
system: { name: reg.system_name, version: reg.system_version, description: reg.system_description, purpose: reg.intended_purpose },
|
||||
classification: { risk_level: reg.risk_classification, annex_iii: reg.annex_iii_category, gpai: reg.gpai_classification },
|
||||
conformity: { type: reg.conformity_assessment_type, ce_marking: reg.ce_marking },
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `eu_ai_registration_${reg.system_name.replace(/\s+/g, '_')}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
} catch {
|
||||
setError('Export fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(id: string, status: string) {
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/ai-registration/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
loadRegistrations()
|
||||
} catch {
|
||||
setError('Status-Aenderung fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
const updateForm = (updates: Partial<typeof form>) => setForm(prev => ({ ...prev, ...updates }))
|
||||
|
||||
const STEPS = [
|
||||
{ id: 1, title: 'Anbieter', desc: 'Unternehmensangaben' },
|
||||
{ id: 2, title: 'System', desc: 'KI-System Details' },
|
||||
{ id: 3, title: 'Klassifikation', desc: 'Risikoeinstufung' },
|
||||
{ id: 4, title: 'Konformitaet', desc: 'CE & Notified Body' },
|
||||
{ id: 5, title: 'Trainingsdaten', desc: 'Datenzusammenfassung' },
|
||||
{ id: 6, title: 'Pruefung', desc: 'Zusammenfassung & Export' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">EU AI Database Registrierung</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Art. 49 KI-Verordnung (EU) 2024/1689 — Registrierung von Hochrisiko-KI-Systemen</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowWizard(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
+ Neue Registrierung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-2 underline">Schliessen</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||
{['draft', 'ready', 'submitted', 'registered'].map(status => {
|
||||
const count = registrations.filter(r => r.registration_status === status).length
|
||||
const style = STATUS_STYLES[status]
|
||||
return (
|
||||
<div key={status} className={`p-4 rounded-xl border ${style.bg}`}>
|
||||
<div className={`text-2xl font-bold ${style.text}`}>{count}</div>
|
||||
<div className="text-sm text-gray-600">{style.label}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Registrations List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Lade...</div>
|
||||
) : registrations.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<p className="text-lg mb-2">Noch keine Registrierungen</p>
|
||||
<p className="text-sm">Erstelle eine neue Registrierung fuer dein Hochrisiko-KI-System.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{registrations.map(reg => {
|
||||
const status = STATUS_STYLES[reg.registration_status] || STATUS_STYLES.draft
|
||||
const risk = RISK_STYLES[reg.risk_classification] || RISK_STYLES.not_classified
|
||||
return (
|
||||
<div key={reg.id} className="bg-white rounded-xl border border-gray-200 p-6 hover:border-purple-300 transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{reg.system_name}</h3>
|
||||
<span className="text-sm text-gray-400">v{reg.system_version}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${status.bg} ${status.text}`}>{status.label}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${risk.bg} ${risk.text}`}>{reg.risk_classification.replace('_', ' ')}</span>
|
||||
{reg.gpai_classification !== 'none' && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700">GPAI: {reg.gpai_classification}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{reg.provider_name && <span>{reg.provider_name} · </span>}
|
||||
{reg.eu_database_id && <span>EU-ID: {reg.eu_database_id} · </span>}
|
||||
<span>{new Date(reg.created_at).toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => handleExport(reg.id)} className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
JSON Export
|
||||
</button>
|
||||
{reg.registration_status === 'draft' && (
|
||||
<button onClick={() => handleStatusChange(reg.id, 'ready')} className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
Bereit markieren
|
||||
</button>
|
||||
)}
|
||||
{reg.registration_status === 'ready' && (
|
||||
<button onClick={() => handleStatusChange(reg.id, 'submitted')} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700">
|
||||
Als eingereicht markieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wizard Modal */}
|
||||
{showWizard && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900">Neue EU AI Registrierung</h2>
|
||||
<button onClick={() => { setShowWizard(false); setWizardStep(1) }} className="text-gray-400 hover:text-gray-600 text-2xl">×</button>
|
||||
</div>
|
||||
{/* Step Indicator */}
|
||||
<div className="flex gap-1">
|
||||
{STEPS.map(step => (
|
||||
<button key={step.id} onClick={() => setWizardStep(step.id)}
|
||||
className={`flex-1 py-2 text-xs rounded-lg transition-all ${
|
||||
wizardStep === step.id ? 'bg-purple-100 text-purple-700 font-medium' :
|
||||
wizardStep > step.id ? 'bg-green-50 text-green-700' : 'bg-gray-50 text-gray-400'
|
||||
}`}>
|
||||
{wizardStep > step.id ? '✓ ' : ''}{step.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Step 1: Provider */}
|
||||
{wizardStep === 1 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Anbieter-Informationen</h3>
|
||||
<p className="text-sm text-gray-500">Angaben zum Anbieter des KI-Systems gemaess Art. 49 KI-VO.</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Firmenname *</label>
|
||||
<input value={form.provider_name} onChange={e => updateForm({ provider_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Acme GmbH" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsform</label>
|
||||
<input value={form.provider_legal_form} onChange={e => updateForm({ provider_legal_form: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="GmbH" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
|
||||
<input value={form.provider_address} onChange={e => updateForm({ provider_address: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Musterstr. 1, 20095 Hamburg" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Land</label>
|
||||
<select value={form.provider_country} onChange={e => updateForm({ provider_country: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="DE">Deutschland</option>
|
||||
<option value="AT">Oesterreich</option>
|
||||
<option value="CH">Schweiz</option>
|
||||
<option value="OTHER">Anderes Land</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">EU-Repraesentant (falls Non-EU)</label>
|
||||
<input value={form.eu_representative_name} onChange={e => updateForm({ eu_representative_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 2: System */}
|
||||
{wizardStep === 2 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">KI-System Details</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Systemname *</label>
|
||||
<input value={form.system_name} onChange={e => updateForm({ system_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="z.B. HR Copilot" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Version</label>
|
||||
<input value={form.system_version} onChange={e => updateForm({ system_version: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="1.0" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Systembeschreibung</label>
|
||||
<textarea value={form.system_description} onChange={e => updateForm({ system_description: e.target.value })} rows={3}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Beschreibe was das KI-System tut..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Einsatzzweck (Intended Purpose)</label>
|
||||
<textarea value={form.intended_purpose} onChange={e => updateForm({ intended_purpose: e.target.value })} rows={2}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Wofuer wird das System eingesetzt?" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 3: Classification */}
|
||||
{wizardStep === 3 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Risiko-Klassifikation</h3>
|
||||
<p className="text-sm text-gray-500">Basierend auf dem AI Act Decision Tree oder manueller Einstufung.</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Risikoklasse</label>
|
||||
<select value={form.risk_classification} onChange={e => updateForm({ risk_classification: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="not_classified">Noch nicht klassifiziert</option>
|
||||
<option value="minimal_risk">Minimal Risk</option>
|
||||
<option value="limited_risk">Limited Risk</option>
|
||||
<option value="high_risk">High Risk</option>
|
||||
</select>
|
||||
</div>
|
||||
{form.risk_classification === 'high_risk' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Annex III Kategorie</label>
|
||||
<select value={form.annex_iii_category} onChange={e => updateForm({ annex_iii_category: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="">Bitte waehlen...</option>
|
||||
<option value="biometric">1. Biometrische Identifizierung</option>
|
||||
<option value="critical_infrastructure">2. Kritische Infrastruktur</option>
|
||||
<option value="education">3. Bildung und Berufsausbildung</option>
|
||||
<option value="employment">4. Beschaeftigung und Arbeitnehmerverwaltung</option>
|
||||
<option value="essential_services">5. Zugang zu wesentlichen Diensten</option>
|
||||
<option value="law_enforcement">6. Strafverfolgung</option>
|
||||
<option value="migration">7. Migration und Grenzkontrolle</option>
|
||||
<option value="justice">8. Rechtspflege und Demokratie</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">GPAI Klassifikation</label>
|
||||
<select value={form.gpai_classification} onChange={e => updateForm({ gpai_classification: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="none">Kein GPAI</option>
|
||||
<option value="standard">GPAI (Standard)</option>
|
||||
<option value="systemic">GPAI mit systemischem Risiko</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
|
||||
<strong>Tipp:</strong> Nutze den <a href="/sdk/ai-act" className="underline">AI Act Decision Tree</a> fuer eine strukturierte Klassifikation.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 4: Conformity */}
|
||||
{wizardStep === 4 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Konformitaetsbewertung</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Art der Konformitaetsbewertung</label>
|
||||
<select value={form.conformity_assessment_type} onChange={e => updateForm({ conformity_assessment_type: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="not_required">Nicht erforderlich</option>
|
||||
<option value="internal">Interne Konformitaetsbewertung</option>
|
||||
<option value="third_party">Drittpartei-Bewertung (Notified Body)</option>
|
||||
</select>
|
||||
</div>
|
||||
{form.conformity_assessment_type === 'third_party' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notified Body Name</label>
|
||||
<input value={form.notified_body_name} onChange={e => updateForm({ notified_body_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notified Body ID</label>
|
||||
<input value={form.notified_body_id} onChange={e => updateForm({ notified_body_id: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.ce_marking} onChange={e => updateForm({ ce_marking: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300 text-purple-600" />
|
||||
<span className="text-sm font-medium text-gray-900">CE-Kennzeichnung angebracht</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 5: Training Data */}
|
||||
{wizardStep === 5 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Trainingsdaten-Zusammenfassung</h3>
|
||||
<p className="text-sm text-gray-500">Art. 10 KI-VO — Keine vollstaendige Offenlegung, sondern Kategorien und Herkunft.</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Zusammenfassung der Trainingsdaten</label>
|
||||
<textarea value={form.training_data_summary} onChange={e => updateForm({ training_data_summary: e.target.value })} rows={5}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="Beschreibe die verwendeten Datenquellen: - Oeffentliche Daten (z.B. Wikipedia, Common Crawl) - Lizenzierte Daten (z.B. Fachpublikationen) - Synthetische Daten - Unternehmensinterne Daten" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 6: Review */}
|
||||
{wizardStep === 6 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Zusammenfassung</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div><span className="text-gray-500">Anbieter:</span> <strong>{form.provider_name || '–'}</strong></div>
|
||||
<div><span className="text-gray-500">Land:</span> <strong>{form.provider_country}</strong></div>
|
||||
<div><span className="text-gray-500">System:</span> <strong>{form.system_name || '–'}</strong></div>
|
||||
<div><span className="text-gray-500">Version:</span> <strong>{form.system_version}</strong></div>
|
||||
<div><span className="text-gray-500">Risiko:</span> <strong>{form.risk_classification}</strong></div>
|
||||
<div><span className="text-gray-500">GPAI:</span> <strong>{form.gpai_classification}</strong></div>
|
||||
<div><span className="text-gray-500">Konformitaet:</span> <strong>{form.conformity_assessment_type}</strong></div>
|
||||
<div><span className="text-gray-500">CE:</span> <strong>{form.ce_marking ? 'Ja' : 'Nein'}</strong></div>
|
||||
</div>
|
||||
{form.intended_purpose && (
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<span className="text-gray-500">Zweck:</span> {form.intended_purpose}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
|
||||
<strong>Hinweis:</strong> Die EU AI Datenbank befindet sich noch im Aufbau. Die Registrierung wird lokal gespeichert und kann spaeter uebermittelt werden.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="p-6 border-t flex justify-between">
|
||||
<button onClick={() => wizardStep > 1 ? setWizardStep(wizardStep - 1) : setShowWizard(false)}
|
||||
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50">
|
||||
{wizardStep === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||
</button>
|
||||
{wizardStep < 6 ? (
|
||||
<button onClick={() => setWizardStep(wizardStep + 1)}
|
||||
disabled={wizardStep === 2 && !form.system_name}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
Weiter
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleSubmit} disabled={submitting || !form.system_name}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||
{submitting ? 'Speichere...' : 'Registrierung erstellen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -160,6 +160,8 @@ 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',
|
||||
@@ -178,6 +180,10 @@ 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',
|
||||
|
||||
468
admin-compliance/app/sdk/assertions/page.tsx
Normal file
468
admin-compliance/app/sdk/assertions/page.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
'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">
|
||||
“{assertion.sentence_text}”
|
||||
</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">×</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>
|
||||
)
|
||||
}
|
||||
413
admin-compliance/app/sdk/atomic-controls/page.tsx
Normal file
413
admin-compliance/app/sdk/atomic-controls/page.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import {
|
||||
CompanyProfile,
|
||||
@@ -34,12 +34,12 @@ const BASE_WIZARD_STEPS = [
|
||||
{ id: 3, name: 'Firmengroesse', description: 'Mitarbeiter und Umsatz' },
|
||||
{ id: 4, name: 'Standorte', description: 'Hauptsitz und Zielmaerkte' },
|
||||
{ id: 5, name: 'Datenschutz', description: 'Rollen und DSB' },
|
||||
{ id: 6, name: 'Rechtlicher Rahmen', description: 'Regulierungen und Prüfzyklen' },
|
||||
{ id: 6, name: 'Zertifizierungen & Kontakte', description: 'Bestehende und angestrebte Zertifizierungen' },
|
||||
]
|
||||
|
||||
const MACHINE_BUILDER_STEP = { id: 7, name: 'Produkt & Maschine', description: 'Software, KI und CE in Ihrem Produkt' }
|
||||
|
||||
function getWizardSteps(industry: string) {
|
||||
function getWizardSteps(industry: string | string[]) {
|
||||
if (isMachineBuilderIndustry(industry)) {
|
||||
return [...BASE_WIZARD_STEPS, MACHINE_BUILDER_STEP]
|
||||
}
|
||||
@@ -73,20 +73,35 @@ const LEGAL_FORM_LABELS: Record<LegalForm, string> = {
|
||||
|
||||
const INDUSTRIES = [
|
||||
'Technologie / IT',
|
||||
'IT Dienstleistungen',
|
||||
'E-Commerce / Handel',
|
||||
'Finanzdienstleistungen',
|
||||
'Versicherungen',
|
||||
'Gesundheitswesen',
|
||||
'Pharma',
|
||||
'Bildung',
|
||||
'Beratung / Consulting',
|
||||
'Marketing / Agentur',
|
||||
'Produktion / Industrie',
|
||||
'Logistik / Transport',
|
||||
'Immobilien',
|
||||
'Bau',
|
||||
'Energie',
|
||||
'Automobil',
|
||||
'Luft- und Raumfahrt',
|
||||
'Maschinenbau',
|
||||
'Anlagenbau',
|
||||
'Automatisierung',
|
||||
'Robotik',
|
||||
'Messtechnik',
|
||||
'Agrar',
|
||||
'Chemie',
|
||||
'Minen / Bergbau',
|
||||
'Telekommunikation',
|
||||
'Medien / Verlage',
|
||||
'Gastronomie / Hotellerie',
|
||||
'Recht / Kanzlei',
|
||||
'Oeffentlicher Dienst',
|
||||
'Sonstige',
|
||||
]
|
||||
|
||||
@@ -98,8 +113,10 @@ const MACHINE_BUILDER_INDUSTRIES = [
|
||||
'Messtechnik',
|
||||
]
|
||||
|
||||
const isMachineBuilderIndustry = (industry: string) =>
|
||||
MACHINE_BUILDER_INDUSTRIES.includes(industry)
|
||||
const isMachineBuilderIndustry = (industry: string | string[]) => {
|
||||
const industries = Array.isArray(industry) ? industry : [industry]
|
||||
return industries.some(i => MACHINE_BUILDER_INDUSTRIES.includes(i))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STEP COMPONENTS
|
||||
@@ -146,23 +163,44 @@ function StepBasicInfo({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">Branche</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Branche(n)</label>
|
||||
<p className="text-sm text-gray-500 mb-3">Mehrfachauswahl moeglich</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{INDUSTRIES.map(industry => (
|
||||
<button
|
||||
key={industry}
|
||||
type="button"
|
||||
onClick={() => onChange({ industry })}
|
||||
className={`p-3 rounded-lg border-2 text-sm text-left transition-all ${
|
||||
data.industry === industry
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
|
||||
: 'border-gray-200 hover:border-purple-300 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{industry}
|
||||
</button>
|
||||
))}
|
||||
{INDUSTRIES.map(ind => {
|
||||
const selected = (data.industry || []).includes(ind)
|
||||
return (
|
||||
<button
|
||||
key={ind}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = data.industry || []
|
||||
const updated = selected
|
||||
? current.filter(i => i !== ind)
|
||||
: [...current, ind]
|
||||
onChange({ industry: updated })
|
||||
}}
|
||||
className={`p-3 rounded-lg border-2 text-sm text-left transition-all ${
|
||||
selected
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
|
||||
: 'border-gray-200 hover:border-purple-300 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{ind}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{(data.industry || []).includes('Sonstige') && (
|
||||
<div className="mt-3">
|
||||
<input
|
||||
type="text"
|
||||
value={data.industryOther || ''}
|
||||
onChange={e => onChange({ industryOther: e.target.value })}
|
||||
placeholder="Ihre Branche eingeben..."
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -843,16 +881,25 @@ const INDUSTRY_DEPARTMENTS: Record<string, ActivityDepartment[]> = {
|
||||
}
|
||||
|
||||
// Compute which departments to show based on company context
|
||||
function getRelevantDepartments(industry: string, businessModel: string | undefined, companySize: string | undefined): ActivityDepartment[] {
|
||||
function getRelevantDepartments(industry: string | string[], businessModel: string | undefined, companySize: string | undefined): ActivityDepartment[] {
|
||||
const departments: ActivityDepartment[] = [...UNIVERSAL_DEPARTMENTS]
|
||||
|
||||
// Always show optional departments — user can choose
|
||||
departments.push(...OPTIONAL_DEPARTMENTS)
|
||||
|
||||
// Add industry-specific departments
|
||||
const industryDepts = INDUSTRY_DEPARTMENTS[industry]
|
||||
if (industryDepts) {
|
||||
departments.push(...industryDepts)
|
||||
// Add industry-specific departments (support multi-select)
|
||||
const industries = Array.isArray(industry) ? industry : [industry]
|
||||
const addedIds = new Set<string>()
|
||||
for (const ind of industries) {
|
||||
const industryDepts = INDUSTRY_DEPARTMENTS[ind]
|
||||
if (industryDepts) {
|
||||
for (const dept of industryDepts) {
|
||||
if (!addedIds.has(dept.id)) {
|
||||
addedIds.add(dept.id)
|
||||
departments.push(dept)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return departments
|
||||
@@ -898,7 +945,7 @@ function StepProcessing({
|
||||
onChange: (updates: Record<string, unknown>) => void
|
||||
}) {
|
||||
const activities: ProcessingActivity[] = (data as any).processingSystems || []
|
||||
const industry = data.industry || ''
|
||||
const industry = data.industry || []
|
||||
const [expandedActivity, setExpandedActivity] = useState<string | null>(null)
|
||||
const [collapsedDepts, setCollapsedDepts] = useState<Set<string>>(new Set())
|
||||
const [showExtraCategories, setShowExtraCategories] = useState<Set<string>>(new Set())
|
||||
@@ -1645,14 +1692,67 @@ function StepAISystems({
|
||||
// STEP 6: RECHTLICHER RAHMEN (was Step 8, renumbered)
|
||||
// =============================================================================
|
||||
|
||||
const CERTIFICATIONS = [
|
||||
{ id: 'iso27001', label: 'ISO 27001', desc: 'Informationssicherheits-Managementsystem' },
|
||||
{ id: 'iso27701', label: 'ISO 27701', desc: 'Datenschutz-Managementsystem' },
|
||||
{ id: 'iso9001', label: 'ISO 9001', desc: 'Qualitaetsmanagement' },
|
||||
{ id: 'iso14001', label: 'ISO 14001', desc: 'Umweltmanagement' },
|
||||
{ id: 'iso22301', label: 'ISO 22301', desc: 'Business Continuity Management' },
|
||||
{ id: 'iso42001', label: 'ISO 42001', desc: 'KI-Managementsystem' },
|
||||
{ id: 'tisax', label: 'TISAX', desc: 'Trusted Information Security Assessment Exchange (Automotive)' },
|
||||
{ id: 'soc2', label: 'SOC 2', desc: 'Service Organization Controls (Typ I/II)' },
|
||||
{ id: 'c5', label: 'C5', desc: 'Cloud Computing Compliance Criteria Catalogue (BSI)' },
|
||||
{ id: 'bsi_grundschutz', label: 'BSI IT-Grundschutz', desc: 'IT-Grundschutz-Zertifikat oder Testat' },
|
||||
{ id: 'pci_dss', label: 'PCI DSS', desc: 'Payment Card Industry Data Security Standard' },
|
||||
{ id: 'hipaa', label: 'HIPAA', desc: 'Health Insurance Portability and Accountability Act' },
|
||||
{ id: 'other', label: 'Sonstige', desc: 'Andere Zertifizierungen' },
|
||||
]
|
||||
|
||||
interface CertificationEntry {
|
||||
certId: string
|
||||
certifier?: string
|
||||
lastDate?: string
|
||||
customName?: string
|
||||
}
|
||||
|
||||
function StepLegalFramework({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile> & { subjectToNis2?: boolean; subjectToAiAct?: boolean; subjectToIso27001?: boolean; supervisoryAuthority?: string; reviewCycleMonths?: number; technicalContacts?: { name: string; role: string; email: string }[] }
|
||||
data: Partial<CompanyProfile>
|
||||
onChange: (updates: Record<string, unknown>) => void
|
||||
}) {
|
||||
const contacts = (data as any).technicalContacts || []
|
||||
const existingCerts: CertificationEntry[] = (data as any).existingCertifications || []
|
||||
const targetCerts: string[] = (data as any).targetCertifications || []
|
||||
const targetCertOther: string = (data as any).targetCertificationOther || ''
|
||||
|
||||
// Toggle existing certification
|
||||
const toggleExistingCert = (certId: string) => {
|
||||
const exists = existingCerts.find((c: CertificationEntry) => c.certId === certId)
|
||||
if (exists) {
|
||||
onChange({ existingCertifications: existingCerts.filter((c: CertificationEntry) => c.certId !== certId) })
|
||||
} else {
|
||||
onChange({ existingCertifications: [...existingCerts, { certId }] })
|
||||
}
|
||||
}
|
||||
|
||||
const updateExistingCert = (certId: string, updates: Partial<CertificationEntry>) => {
|
||||
onChange({
|
||||
existingCertifications: existingCerts.map((c: CertificationEntry) =>
|
||||
c.certId === certId ? { ...c, ...updates } : c
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
// Toggle target certification
|
||||
const toggleTargetCert = (certId: string) => {
|
||||
if (targetCerts.includes(certId)) {
|
||||
onChange({ targetCertifications: targetCerts.filter((c: string) => c !== certId) })
|
||||
} else {
|
||||
onChange({ targetCertifications: [...targetCerts, certId] })
|
||||
}
|
||||
}
|
||||
|
||||
const addContact = () => {
|
||||
onChange({ technicalContacts: [...contacts, { name: '', role: '', email: '' }] })
|
||||
@@ -1668,78 +1768,115 @@ function StepLegalFramework({
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Regulatory Flags */}
|
||||
{/* Bestehende Zertifizierungen */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-4">Regulatorischer Rahmen</h3>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ key: 'subjectToNis2', label: 'NIS2-Richtlinie', desc: 'Ihr Unternehmen fällt unter die NIS2-Richtlinie (Netzwerk- und Informationssicherheit)' },
|
||||
{ key: 'subjectToAiAct', label: 'EU AI Act', desc: 'Ihr Unternehmen setzt KI-Systeme ein, die unter den AI Act fallen' },
|
||||
{ key: 'subjectToIso27001', label: 'ISO 27001', desc: 'Ihr Unternehmen strebt ISO 27001 Zertifizierung an oder ist bereits zertifiziert' },
|
||||
].map(item => (
|
||||
<label
|
||||
key={item.key}
|
||||
className={`flex items-start gap-4 p-4 rounded-xl border-2 cursor-pointer transition-all ${
|
||||
(data as any)[item.key] ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-purple-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(data as any)[item.key] ?? false}
|
||||
onChange={e => onChange({ [item.key]: e.target.checked })}
|
||||
className="mt-1 w-5 h-5 text-purple-600 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{item.label}</div>
|
||||
<div className="text-sm text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-1">Bestehende Zertifizierungen</h3>
|
||||
<p className="text-sm text-gray-500 mb-3">Ueber welche Zertifizierungen verfuegt Ihr Unternehmen aktuell? Mehrfachauswahl moeglich.</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{CERTIFICATIONS.map(cert => {
|
||||
const selected = existingCerts.some((c: CertificationEntry) => c.certId === cert.id)
|
||||
return (
|
||||
<button
|
||||
key={cert.id}
|
||||
type="button"
|
||||
onClick={() => toggleExistingCert(cert.id)}
|
||||
className={`p-3 rounded-lg border-2 text-left transition-all ${
|
||||
selected
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700'
|
||||
: 'border-gray-200 hover:border-purple-300 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm">{cert.label}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{cert.desc}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Details fuer ausgewaehlte Zertifizierungen */}
|
||||
{existingCerts.length > 0 && (
|
||||
<div className="mt-4 space-y-3">
|
||||
{existingCerts.map((entry: CertificationEntry) => {
|
||||
const cert = CERTIFICATIONS.find(c => c.id === entry.certId)
|
||||
const label = cert?.label || entry.certId
|
||||
return (
|
||||
<div key={entry.certId} className="p-4 bg-purple-50 border border-purple-200 rounded-lg">
|
||||
<div className="font-medium text-sm text-purple-800 mb-2">
|
||||
{entry.certId === 'other' ? 'Sonstige Zertifizierung' : label}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{entry.certId === 'other' && (
|
||||
<input
|
||||
type="text"
|
||||
value={entry.customName || ''}
|
||||
onChange={e => updateExistingCert(entry.certId, { customName: e.target.value })}
|
||||
placeholder="Name der Zertifizierung"
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={entry.certifier || ''}
|
||||
onChange={e => updateExistingCert(entry.certId, { certifier: e.target.value })}
|
||||
placeholder="Zertifizierer (z.B. TÜV, DEKRA)"
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={entry.lastDate || ''}
|
||||
onChange={e => updateExistingCert(entry.certId, { lastDate: e.target.value })}
|
||||
title="Datum der letzten Zertifizierung"
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Supervisory Authority & Review Cycle */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Aufsichtsbehörde</label>
|
||||
<select
|
||||
value={(data as any).supervisoryAuthority || ''}
|
||||
onChange={e => onChange({ supervisoryAuthority: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Bitte wählen...</option>
|
||||
<option value="LfDI BW">LfDI Baden-Württemberg</option>
|
||||
<option value="BayLDA">BayLDA Bayern</option>
|
||||
<option value="BlnBDI">BlnBDI Berlin</option>
|
||||
<option value="LDA BB">LDA Brandenburg</option>
|
||||
<option value="LfDI HB">LfDI Bremen</option>
|
||||
<option value="HmbBfDI">HmbBfDI Hamburg</option>
|
||||
<option value="HBDI">HBDI Hessen</option>
|
||||
<option value="LfDI MV">LfDI Mecklenburg-Vorpommern</option>
|
||||
<option value="LfD NI">LfD Niedersachsen</option>
|
||||
<option value="LDI NRW">LDI NRW</option>
|
||||
<option value="LfDI RP">LfDI Rheinland-Pfalz</option>
|
||||
<option value="UDZ SL">UDZ Saarland</option>
|
||||
<option value="SächsDSB">Sächsischer DSB</option>
|
||||
<option value="LfD LSA">LfD Sachsen-Anhalt</option>
|
||||
<option value="ULD SH">ULD Schleswig-Holstein</option>
|
||||
<option value="TLfDI">TLfDI Thüringen</option>
|
||||
<option value="BfDI">BfDI (Bund)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Prüfzyklus (Monate)</label>
|
||||
<select
|
||||
value={(data as any).reviewCycleMonths || 12}
|
||||
onChange={e => onChange({ reviewCycleMonths: parseInt(e.target.value) })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value={3}>Vierteljährlich (3 Monate)</option>
|
||||
<option value={6}>Halbjährlich (6 Monate)</option>
|
||||
<option value={12}>Jährlich (12 Monate)</option>
|
||||
<option value={24}>Zweijährlich (24 Monate)</option>
|
||||
</select>
|
||||
{/* Angestrebte Zertifizierungen */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-1">Streben Sie eine Zertifizierung an?</h3>
|
||||
<p className="text-sm text-gray-500 mb-3">Welche Zertifizierungen planen Sie? Mehrfachauswahl moeglich.</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{CERTIFICATIONS.map(cert => {
|
||||
const selected = targetCerts.includes(cert.id)
|
||||
// Bereits bestehende Zertifizierungen ausgrauen
|
||||
const alreadyHas = existingCerts.some((c: CertificationEntry) => c.certId === cert.id)
|
||||
return (
|
||||
<button
|
||||
key={cert.id}
|
||||
type="button"
|
||||
onClick={() => !alreadyHas && toggleTargetCert(cert.id)}
|
||||
disabled={alreadyHas}
|
||||
className={`p-3 rounded-lg border-2 text-left transition-all ${
|
||||
alreadyHas
|
||||
? 'border-gray-100 bg-gray-50 text-gray-400 cursor-not-allowed'
|
||||
: selected
|
||||
? 'border-green-500 bg-green-50 text-green-700'
|
||||
: 'border-gray-200 hover:border-green-300 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm">{cert.label}</div>
|
||||
{alreadyHas && <div className="text-xs mt-0.5">Bereits vorhanden</div>}
|
||||
{!alreadyHas && <div className="text-xs text-gray-500 mt-0.5">{cert.desc}</div>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{targetCerts.includes('other') && (
|
||||
<div className="mt-3">
|
||||
<input
|
||||
type="text"
|
||||
value={targetCertOther}
|
||||
onChange={e => onChange({ targetCertificationOther: e.target.value })}
|
||||
placeholder="Name der angestrebten Zertifizierung"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Technical Contacts */}
|
||||
@@ -2196,67 +2333,6 @@ function StepMachineBuilder({
|
||||
// GENERATE DOCUMENTS BUTTON
|
||||
// =============================================================================
|
||||
|
||||
const DOC_TYPES = [
|
||||
{ id: 'dsfa', label: 'DSFA', desc: 'Datenschutz-Folgenabschätzung' },
|
||||
{ id: 'vvt', label: 'VVT', desc: 'Verarbeitungsverzeichnis' },
|
||||
{ id: 'tom', label: 'TOM', desc: 'Technisch-Organisatorische Maßnahmen' },
|
||||
{ id: 'loeschfristen', label: 'Löschfristen', desc: 'Löschfristen-Katalog' },
|
||||
{ id: 'obligation', label: 'Pflichten', desc: 'Compliance-Pflichten' },
|
||||
]
|
||||
|
||||
function GenerateDocumentsButton() {
|
||||
const [generating, setGenerating] = useState<string | null>(null)
|
||||
const [results, setResults] = useState<Record<string, { ok: boolean; count: number }>>({})
|
||||
|
||||
const handleGenerate = async (docType: string) => {
|
||||
setGenerating(docType)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/generation/apply/${docType}`, { method: 'POST' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setResults(prev => ({ ...prev, [docType]: { ok: true, count: data.change_requests_created || 0 } }))
|
||||
} else {
|
||||
setResults(prev => ({ ...prev, [docType]: { ok: false, count: 0 } }))
|
||||
}
|
||||
} catch {
|
||||
setResults(prev => ({ ...prev, [docType]: { ok: false, count: 0 } }))
|
||||
} finally {
|
||||
setGenerating(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{DOC_TYPES.map(dt => (
|
||||
<div key={dt.id} className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-900">{dt.label}</span>
|
||||
<span className="text-xs text-gray-500 ml-1">({dt.desc})</span>
|
||||
</div>
|
||||
{results[dt.id] ? (
|
||||
<span className={`text-xs px-2 py-1 rounded ${results[dt.id].ok ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||
{results[dt.id].ok ? `${results[dt.id].count} CR erstellt` : 'Fehler'}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleGenerate(dt.id)}
|
||||
disabled={generating !== null}
|
||||
className="px-3 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{generating === dt.id ? 'Generiere...' : 'Generieren'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(results).length > 0 && (
|
||||
<a href="/sdk/change-requests" className="block text-center text-sm text-purple-600 hover:text-purple-800 font-medium mt-3">
|
||||
Zur Änderungsanfragen-Inbox →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
@@ -2269,7 +2345,8 @@ export default function CompanyProfilePage() {
|
||||
const [formData, setFormData] = useState<Partial<CompanyProfile>>({
|
||||
companyName: '',
|
||||
legalForm: undefined,
|
||||
industry: '',
|
||||
industry: [],
|
||||
industryOther: '',
|
||||
foundedYear: null,
|
||||
businessModel: undefined,
|
||||
offerings: [],
|
||||
@@ -2297,8 +2374,8 @@ export default function CompanyProfilePage() {
|
||||
completedAt: null,
|
||||
})
|
||||
|
||||
const showMachineBuilderStep = isMachineBuilderIndustry(formData.industry || '')
|
||||
const wizardSteps = getWizardSteps(formData.industry || '')
|
||||
const showMachineBuilderStep = isMachineBuilderIndustry(formData.industry || [])
|
||||
const wizardSteps = getWizardSteps(formData.industry || [])
|
||||
const totalSteps = wizardSteps.length
|
||||
const lastStep = wizardSteps[wizardSteps.length - 1].id
|
||||
|
||||
@@ -2325,7 +2402,8 @@ export default function CompanyProfilePage() {
|
||||
const backendProfile: Partial<CompanyProfile> = {
|
||||
companyName: data.company_name || '',
|
||||
legalForm: data.legal_form || undefined,
|
||||
industry: data.industry || '',
|
||||
industry: Array.isArray(data.industry) ? data.industry : (data.industry ? [data.industry] : []),
|
||||
industryOther: data.industry_other || '',
|
||||
foundedYear: data.founded_year || undefined,
|
||||
businessModel: data.business_model || undefined,
|
||||
offerings: data.offerings || [],
|
||||
@@ -2352,17 +2430,18 @@ export default function CompanyProfilePage() {
|
||||
processingSystems: data.processing_systems || [],
|
||||
aiSystems: data.ai_systems || [],
|
||||
technicalContacts: data.technical_contacts || [],
|
||||
subjectToNis2: data.subject_to_nis2 || false,
|
||||
subjectToAiAct: data.subject_to_ai_act || false,
|
||||
subjectToIso27001: data.subject_to_iso27001 || false,
|
||||
supervisoryAuthority: data.supervisory_authority || '',
|
||||
existingCertifications: data.existing_certifications || [],
|
||||
targetCertifications: data.target_certifications || [],
|
||||
targetCertificationOther: data.target_certification_other || '',
|
||||
reviewCycleMonths: data.review_cycle_months || 12,
|
||||
repos: data.repos || [],
|
||||
documentSources: data.document_sources || [],
|
||||
} as any
|
||||
setFormData(backendProfile)
|
||||
setCompanyProfile(backendProfile as CompanyProfile)
|
||||
if (backendProfile.isComplete) {
|
||||
setCurrentStep(6)
|
||||
setCurrentStep(99)
|
||||
dispatch({ type: 'COMPLETE_STEP', payload: 'company-profile' })
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -2375,7 +2454,7 @@ export default function CompanyProfilePage() {
|
||||
if (!cancelled && state.companyProfile) {
|
||||
setFormData(state.companyProfile)
|
||||
if (state.companyProfile.isComplete) {
|
||||
setCurrentStep(6)
|
||||
setCurrentStep(99)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2390,12 +2469,49 @@ export default function CompanyProfilePage() {
|
||||
setFormData(prev => ({ ...prev, ...updates }))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-save: sync formData to SDK context (debounced) so data survives navigation.
|
||||
// This mirrors the pattern used by compliance-scope/page.tsx.
|
||||
// ---------------------------------------------------------------------------
|
||||
const autoSaveRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const initialLoadDone = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Skip the initial load — only auto-save after user has started editing
|
||||
if (!initialLoadDone.current) {
|
||||
// Mark initial load done after first formData update (from backend or SDK state)
|
||||
if (formData.companyName !== undefined) {
|
||||
initialLoadDone.current = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Don't auto-save drafts if profile is already completed (step 99)
|
||||
if (currentStep === 99) return
|
||||
|
||||
// Debounce: sync to SDK context after 500ms of inactivity
|
||||
if (autoSaveRef.current) clearTimeout(autoSaveRef.current)
|
||||
autoSaveRef.current = setTimeout(() => {
|
||||
// Only sync if there's meaningful data (not just defaults)
|
||||
const hasData = formData.companyName || (formData.industry && formData.industry.length > 0)
|
||||
if (hasData) {
|
||||
setCompanyProfile({ ...formData, isComplete: false, completedAt: null } as CompanyProfile)
|
||||
}
|
||||
}, 500)
|
||||
|
||||
return () => {
|
||||
if (autoSaveRef.current) clearTimeout(autoSaveRef.current)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formData, currentStep])
|
||||
|
||||
// Shared payload builder for draft saves and final save (DRY)
|
||||
const buildProfilePayload = (isComplete: boolean) => ({
|
||||
project_id: projectId || null,
|
||||
company_name: formData.companyName || '',
|
||||
legal_form: formData.legalForm || 'GmbH',
|
||||
industry: formData.industry || '',
|
||||
industry: formData.industry || [],
|
||||
industry_other: formData.industryOther || '',
|
||||
founded_year: formData.foundedYear || null,
|
||||
business_model: formData.businessModel || 'B2B',
|
||||
offerings: formData.offerings || [],
|
||||
@@ -2422,10 +2538,9 @@ export default function CompanyProfilePage() {
|
||||
processing_systems: (formData as any).processingSystems || [],
|
||||
ai_systems: (formData as any).aiSystems || [],
|
||||
technical_contacts: (formData as any).technicalContacts || [],
|
||||
subject_to_nis2: (formData as any).subjectToNis2 || false,
|
||||
subject_to_ai_act: (formData as any).subjectToAiAct || false,
|
||||
subject_to_iso27001: (formData as any).subjectToIso27001 || false,
|
||||
supervisory_authority: (formData as any).supervisoryAuthority || '',
|
||||
existing_certifications: (formData as any).existingCertifications || [],
|
||||
target_certifications: (formData as any).targetCertifications || [],
|
||||
target_certification_other: (formData as any).targetCertificationOther || '',
|
||||
review_cycle_months: (formData as any).reviewCycleMonths || 12,
|
||||
repos: (formData as any).repos || [],
|
||||
document_sources: (formData as any).documentSources || [],
|
||||
@@ -2458,7 +2573,42 @@ export default function CompanyProfilePage() {
|
||||
} : {}),
|
||||
})
|
||||
|
||||
// Auto-save draft to backend (fire-and-forget, non-blocking)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-save draft to backend (debounced, 2s after last change)
|
||||
// ---------------------------------------------------------------------------
|
||||
const backendAutoSaveRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialLoadDone.current) return
|
||||
|
||||
// Don't auto-save drafts if profile is already completed
|
||||
if (currentStep === 99) return
|
||||
|
||||
const hasData = formData.companyName || (formData.industry && formData.industry.length > 0)
|
||||
if (!hasData) return
|
||||
|
||||
if (backendAutoSaveRef.current) clearTimeout(backendAutoSaveRef.current)
|
||||
backendAutoSaveRef.current = setTimeout(async () => {
|
||||
try {
|
||||
await fetch(profileApiUrl(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildProfilePayload(false)),
|
||||
})
|
||||
setDraftSaveStatus('saved')
|
||||
if (draftSaveTimerRef.current) clearTimeout(draftSaveTimerRef.current)
|
||||
draftSaveTimerRef.current = setTimeout(() => setDraftSaveStatus('idle'), 3000)
|
||||
} catch {
|
||||
// Silent fail for auto-save — user can still manually save via Next
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
return () => {
|
||||
if (backendAutoSaveRef.current) clearTimeout(backendAutoSaveRef.current)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formData, currentStep])
|
||||
|
||||
const [draftSaveStatus, setDraftSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const draftSaveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
@@ -2470,6 +2620,8 @@ export default function CompanyProfilePage() {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildProfilePayload(false)),
|
||||
})
|
||||
// Sync draft to Redux so it persists across navigation
|
||||
setCompanyProfile({ ...formData, isComplete: false, completedAt: null } as CompanyProfile)
|
||||
setDraftSaveStatus('saved')
|
||||
// Reset status after 3 seconds
|
||||
if (draftSaveTimerRef.current) clearTimeout(draftSaveTimerRef.current)
|
||||
@@ -2500,17 +2652,17 @@ export default function CompanyProfilePage() {
|
||||
}
|
||||
|
||||
const completeAndSaveProfile = async () => {
|
||||
// Cancel any pending auto-save timers to prevent them from overwriting isComplete
|
||||
if (autoSaveRef.current) clearTimeout(autoSaveRef.current)
|
||||
if (backendAutoSaveRef.current) clearTimeout(backendAutoSaveRef.current)
|
||||
|
||||
const completeProfile: CompanyProfile = {
|
||||
...formData,
|
||||
isComplete: true,
|
||||
completedAt: new Date(),
|
||||
} as CompanyProfile
|
||||
|
||||
setCompanyProfile(completeProfile)
|
||||
dispatch({ type: 'COMPLETE_STEP', payload: 'company-profile' })
|
||||
dispatch({ type: 'SET_STATE', payload: { projectVersion: (state.projectVersion || 0) + 1 } })
|
||||
|
||||
// Also persist to dedicated backend endpoint
|
||||
// Persist to backend FIRST (with isComplete=true)
|
||||
try {
|
||||
await fetch(profileApiUrl(), {
|
||||
method: 'POST',
|
||||
@@ -2521,7 +2673,12 @@ export default function CompanyProfilePage() {
|
||||
console.error('Failed to save company profile to backend:', err)
|
||||
}
|
||||
|
||||
goToNextStep()
|
||||
// Then update SDK context (after backend is done, no race condition)
|
||||
setCompanyProfile(completeProfile)
|
||||
dispatch({ type: 'COMPLETE_STEP', payload: 'company-profile' })
|
||||
dispatch({ type: 'SET_STATE', payload: { projectVersion: (state.projectVersion || 0) + 1 } })
|
||||
|
||||
setCurrentStep(99) // Show summary
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
@@ -2542,7 +2699,8 @@ export default function CompanyProfilePage() {
|
||||
setFormData({
|
||||
companyName: '',
|
||||
legalForm: undefined,
|
||||
industry: '',
|
||||
industry: [],
|
||||
industryOther: '',
|
||||
foundedYear: null,
|
||||
businessModel: undefined,
|
||||
offerings: [],
|
||||
@@ -2600,6 +2758,114 @@ export default function CompanyProfilePage() {
|
||||
|
||||
const isLastStep = currentStep === lastStep || (currentStep === 6 && !showMachineBuilderStep)
|
||||
|
||||
// =========================================================================
|
||||
// SUMMARY VIEW (Step 99) — shown after profile completion
|
||||
// =========================================================================
|
||||
if (currentStep === 99) {
|
||||
const summaryItems = [
|
||||
{ label: 'Firmenname', value: formData.companyName },
|
||||
{ label: 'Rechtsform', value: formData.legalForm ? LEGAL_FORM_LABELS[formData.legalForm] : undefined },
|
||||
{ label: 'Branche', value: formData.industry?.join(', ') },
|
||||
{ label: 'Geschaeftsmodell', value: formData.businessModel ? BUSINESS_MODEL_LABELS[formData.businessModel]?.short : undefined },
|
||||
{ label: 'Unternehmensgroesse', value: formData.companySize ? COMPANY_SIZE_LABELS[formData.companySize] : undefined },
|
||||
{ label: 'Mitarbeiter', value: formData.employeeCount },
|
||||
{ label: 'Hauptsitz', value: [formData.headquartersZip, formData.headquartersCity, formData.headquartersCountry === 'DE' ? 'Deutschland' : formData.headquartersCountry].filter(Boolean).join(', ') },
|
||||
{ label: 'Zielmaerkte', value: formData.targetMarkets?.map(m => TARGET_MARKET_LABELS[m] || m).join(', ') },
|
||||
{ label: 'Verantwortlicher', value: formData.isDataController ? 'Ja' : 'Nein' },
|
||||
{ label: 'Auftragsverarbeiter', value: formData.isDataProcessor ? 'Ja' : 'Nein' },
|
||||
{ label: 'DSB', value: formData.dpoName || 'Nicht angegeben' },
|
||||
].filter(item => item.value && item.value.length > 0)
|
||||
|
||||
const missingFields: string[] = []
|
||||
if (!formData.companyName) missingFields.push('Firmenname')
|
||||
if (!formData.legalForm) missingFields.push('Rechtsform')
|
||||
if (!formData.industry || formData.industry.length === 0) missingFields.push('Branche')
|
||||
if (!formData.businessModel) missingFields.push('Geschaeftsmodell')
|
||||
if (!formData.companySize) missingFields.push('Unternehmensgroesse')
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Unternehmensprofil</h1>
|
||||
</div>
|
||||
|
||||
{/* Success Banner */}
|
||||
<div className={`rounded-xl border-2 p-6 mb-6 ${
|
||||
formData.isComplete
|
||||
? 'bg-green-50 border-green-300'
|
||||
: 'bg-yellow-50 border-yellow-300'
|
||||
}`}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`flex-shrink-0 w-12 h-12 rounded-full flex items-center justify-center ${
|
||||
formData.isComplete ? 'bg-green-200' : 'bg-yellow-200'
|
||||
}`}>
|
||||
<span className="text-2xl">{formData.isComplete ? '\u2713' : '!'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className={`text-xl font-bold ${formData.isComplete ? 'text-green-800' : 'text-yellow-800'}`}>
|
||||
{formData.isComplete
|
||||
? 'Profil erfolgreich abgeschlossen'
|
||||
: 'Profil unvollstaendig'
|
||||
}
|
||||
</h2>
|
||||
<p className={`mt-1 ${formData.isComplete ? 'text-green-700' : 'text-yellow-700'}`}>
|
||||
{formData.isComplete
|
||||
? 'Alle Angaben wurden gespeichert. Sie koennen jetzt mit der Scope-Analyse fortfahren.'
|
||||
: `Es fehlen noch Angaben: ${missingFields.join(', ')}.`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile Summary */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Zusammenfassung</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{summaryItems.map(item => (
|
||||
<div key={item.label} className="flex flex-col">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">{item.label}</span>
|
||||
<span className="text-sm text-gray-900 mt-0.5">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
onClick={() => setCurrentStep(1)}
|
||||
className="px-6 py-3 text-gray-600 hover:text-gray-900 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Profil bearbeiten
|
||||
</button>
|
||||
|
||||
{formData.isComplete ? (
|
||||
<button
|
||||
onClick={() => goToNextStep()}
|
||||
className="px-8 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium"
|
||||
>
|
||||
Weiter zu Scope
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setCurrentStep(1)}
|
||||
className="px-8 py-3 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 font-medium"
|
||||
>
|
||||
Fehlende Angaben ergaenzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// WIZARD VIEW (Steps 1-7)
|
||||
// =========================================================================
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
@@ -2714,16 +2980,6 @@ export default function CompanyProfilePage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Generate Documents CTA (only when profile is complete) */}
|
||||
{formData.isComplete && (
|
||||
<div className="mt-6 bg-gradient-to-br from-purple-50 to-indigo-50 rounded-xl border border-purple-200 p-6">
|
||||
<h3 className="font-semibold text-purple-900 mb-2">Dokumente generieren</h3>
|
||||
<p className="text-sm text-purple-700 mb-4">
|
||||
Basierend auf Ihrem Profil können DSFA, VVT, TOM, Löschfristen und Pflichten automatisch als Entwürfe generiert werden.
|
||||
</p>
|
||||
<GenerateDocumentsButton />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader/StepHeader'
|
||||
import {
|
||||
@@ -37,14 +37,31 @@ const TABS: { id: TabId; label: string; icon: string }[] = [
|
||||
export default function ComplianceScopePage() {
|
||||
const { state: sdkState, dispatch } = useSDK()
|
||||
|
||||
// Project-specific storage key
|
||||
const projectStorageKey = sdkState.projectId
|
||||
? `${STORAGE_KEY}_${sdkState.projectId}`
|
||||
: STORAGE_KEY
|
||||
|
||||
// Active tab state
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
|
||||
// Migrate old decision format: drop decision if it has old-format fields
|
||||
const migrateState = (state: ComplianceScopeState): ComplianceScopeState => {
|
||||
if (state.decision) {
|
||||
const d = state.decision as Record<string, unknown>
|
||||
// Old format had 'level' instead of 'determinedLevel', or docs with 'isMandatory'
|
||||
if (d.level || !d.determinedLevel) {
|
||||
return { ...state, decision: null }
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// Local scope state
|
||||
const [scopeState, setScopeState] = useState<ComplianceScopeState>(() => {
|
||||
// Try to load from SDK context first
|
||||
if (sdkState.complianceScope) {
|
||||
return sdkState.complianceScope
|
||||
return migrateState(sdkState.complianceScope)
|
||||
}
|
||||
return createEmptyScopeState()
|
||||
})
|
||||
@@ -53,36 +70,41 @@ export default function ComplianceScopePage() {
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isEvaluating, setIsEvaluating] = useState(false)
|
||||
|
||||
// Guard against save-loop: tracks whether we're syncing FROM SDK → local state
|
||||
const syncingFromSdk = useRef(false)
|
||||
|
||||
// Regulation assessment state
|
||||
const [applicableRegulations, setApplicableRegulations] = useState<ApplicableRegulation[]>([])
|
||||
const [supervisoryAuthorities, setSupervisoryAuthorities] = useState<SupervisoryAuthorityInfo[]>([])
|
||||
const [regulationAssessmentLoading, setRegulationAssessmentLoading] = useState(false)
|
||||
|
||||
// Load from SDK context first (persisted via State API), then localStorage as fallback.
|
||||
// Runs ONCE on mount only — empty deps breaks the dispatch→sdkState→setScopeState→dispatch loop.
|
||||
// Sync from SDK context when it becomes available (handles async loading).
|
||||
// The SDK context loads state from server/localStorage asynchronously, so
|
||||
// sdkState.complianceScope may arrive AFTER this page has already mounted.
|
||||
useEffect(() => {
|
||||
try {
|
||||
// Priority 1: SDK context (loaded from PostgreSQL via State API)
|
||||
const ctxScope = sdkState.complianceScope
|
||||
if (ctxScope && ctxScope.answers?.length > 0) {
|
||||
setScopeState(ctxScope)
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(ctxScope))
|
||||
} else {
|
||||
// Priority 2: localStorage fallback
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
const ctxScope = sdkState.complianceScope
|
||||
if (ctxScope && ctxScope.answers?.length > 0) {
|
||||
syncingFromSdk.current = true
|
||||
setScopeState(migrateState(ctxScope))
|
||||
setIsLoading(false)
|
||||
} else if (isLoading) {
|
||||
// SDK has no scope data — try localStorage fallback, then give up
|
||||
try {
|
||||
const stored = localStorage.getItem(projectStorageKey)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as ComplianceScopeState
|
||||
setScopeState(parsed)
|
||||
dispatch({ type: 'SET_COMPLIANCE_SCOPE', payload: parsed })
|
||||
const parsed = migrateState(JSON.parse(stored) as ComplianceScopeState)
|
||||
if (parsed.answers?.length > 0) {
|
||||
setScopeState(parsed)
|
||||
dispatch({ type: 'SET_COMPLIANCE_SCOPE', payload: parsed })
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load compliance scope from localStorage:', error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load compliance scope state:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
}, [sdkState.complianceScope])
|
||||
|
||||
// Fetch regulation assessment if decision exists on mount
|
||||
useEffect(() => {
|
||||
@@ -92,11 +114,16 @@ export default function ComplianceScopePage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isLoading])
|
||||
|
||||
// Save to localStorage and SDK context whenever state changes
|
||||
// Save to localStorage and SDK context whenever LOCAL state changes (user edits).
|
||||
// Guarded: don't save empty state, and don't echo back what we just loaded from SDK.
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
if (!isLoading && scopeState.answers.length > 0) {
|
||||
if (syncingFromSdk.current) {
|
||||
syncingFromSdk.current = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(scopeState))
|
||||
localStorage.setItem(projectStorageKey, JSON.stringify(scopeState))
|
||||
dispatch({ type: 'SET_COMPLIANCE_SCOPE', payload: scopeState })
|
||||
} catch (error) {
|
||||
console.error('Failed to save compliance scope state:', error)
|
||||
@@ -184,7 +211,7 @@ export default function ComplianceScopePage() {
|
||||
const emptyState = createEmptyScopeState()
|
||||
setScopeState(emptyState)
|
||||
setActiveTab('overview')
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
localStorage.removeItem(projectStorageKey)
|
||||
}, [])
|
||||
|
||||
// Calculate completion statistics
|
||||
@@ -214,6 +241,13 @@ export default function ComplianceScopePage() {
|
||||
return completionStats.isComplete
|
||||
}, [completionStats.isComplete])
|
||||
|
||||
// Mark sidebar step as complete when all required questions answered AND decision exists
|
||||
useEffect(() => {
|
||||
if (completionStats.isComplete && scopeState.decision) {
|
||||
dispatch({ type: 'COMPLETE_STEP', payload: 'compliance-scope' })
|
||||
}
|
||||
}, [completionStats.isComplete, scopeState.decision, dispatch])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
@@ -429,13 +463,13 @@ export default function ComplianceScopePage() {
|
||||
{scopeState.decision && (
|
||||
<>
|
||||
<div>
|
||||
<span className="font-semibold">Level:</span> {scopeState.decision.level}
|
||||
<span className="font-semibold">Level:</span> {scopeState.decision.determinedLevel}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Score:</span> {scopeState.decision.score}
|
||||
<span className="font-semibold">Score:</span> {scopeState.decision.scores?.composite_score}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Hard Triggers:</span> {scopeState.decision.hardTriggers.length}
|
||||
<span className="font-semibold">Hard Triggers:</span> {scopeState.decision.triggeredHardTriggers.length}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
322
admin-compliance/app/sdk/control-library/__tests__/page.test.tsx
Normal file
322
admin-compliance/app/sdk/control-library/__tests__/page.test.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,878 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
ArrowLeft, ChevronLeft, SkipForward, Scale,
|
||||
} from 'lucide-react'
|
||||
import { CanonicalControl, BACKEND_URL } from './helpers'
|
||||
import { ControlPanel } from './ReviewCompare'
|
||||
|
||||
interface V1Match {
|
||||
matched_control_id: string
|
||||
matched_title: string
|
||||
matched_objective: string
|
||||
matched_severity: string
|
||||
matched_category: string
|
||||
matched_source: string | null
|
||||
matched_article: string | null
|
||||
matched_source_citation: Record<string, string> | null
|
||||
similarity_score: number
|
||||
match_rank: number
|
||||
match_method: string
|
||||
}
|
||||
|
||||
interface V1CompareViewProps {
|
||||
v1Control: CanonicalControl
|
||||
matches: V1Match[]
|
||||
onBack: () => void
|
||||
onNavigateToControl?: (controlId: string) => void
|
||||
}
|
||||
|
||||
export function V1CompareView({ v1Control, matches, onBack, onNavigateToControl }: V1CompareViewProps) {
|
||||
const [currentMatchIndex, setCurrentMatchIndex] = useState(0)
|
||||
const [matchedControl, setMatchedControl] = useState<CanonicalControl | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const currentMatch = matches[currentMatchIndex]
|
||||
|
||||
// Load the full matched control when index changes
|
||||
useEffect(() => {
|
||||
if (!currentMatch) return
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=control&id=${encodeURIComponent(currentMatch.matched_control_id)}`)
|
||||
if (res.ok) {
|
||||
setMatchedControl(await res.json())
|
||||
} else {
|
||||
setMatchedControl(null)
|
||||
}
|
||||
} catch {
|
||||
setMatchedControl(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [currentMatch])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={onBack} className="text-gray-400 hover:text-gray-600">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Scale className="w-4 h-4 text-orange-500" />
|
||||
<span className="text-sm font-semibold text-gray-900">V1-Vergleich</span>
|
||||
{currentMatch && (
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
|
||||
currentMatch.similarity_score >= 0.85 ? 'bg-green-100 text-green-700' :
|
||||
currentMatch.similarity_score >= 0.80 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{(currentMatch.similarity_score * 100).toFixed(1)}% Aehnlichkeit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentMatchIndex(Math.max(0, currentMatchIndex - 1))}
|
||||
disabled={currentMatchIndex === 0}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-xs text-gray-500 font-medium">
|
||||
{currentMatchIndex + 1} / {matches.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentMatchIndex(Math.min(matches.length - 1, currentMatchIndex + 1))}
|
||||
disabled={currentMatchIndex >= matches.length - 1}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||
>
|
||||
<SkipForward className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigate to matched control */}
|
||||
{onNavigateToControl && matchedControl && (
|
||||
<button
|
||||
onClick={() => { onBack(); onNavigateToControl(matchedControl.control_id) }}
|
||||
className="px-3 py-1.5 text-sm text-purple-600 border border-purple-300 rounded-lg hover:bg-purple-50"
|
||||
>
|
||||
Zum Control
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source info bar */}
|
||||
{currentMatch && (currentMatch.matched_source || currentMatch.matched_article) && (
|
||||
<div className="px-6 py-2 bg-blue-50 border-b border-blue-200 flex items-center gap-2 text-sm">
|
||||
<Scale className="w-3.5 h-3.5 text-blue-600" />
|
||||
{currentMatch.matched_source && (
|
||||
<span className="font-semibold text-blue-900">{currentMatch.matched_source}</span>
|
||||
)}
|
||||
{currentMatch.matched_article && (
|
||||
<span className="text-blue-700">{currentMatch.matched_article}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Side-by-Side Panels */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left: V1 Eigenentwicklung */}
|
||||
<div className="w-1/2 border-r border-gray-200 overflow-y-auto">
|
||||
<ControlPanel ctrl={v1Control} label="Eigenentwicklung" highlight />
|
||||
</div>
|
||||
|
||||
{/* Right: Regulatory match */}
|
||||
<div className="w-1/2 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-purple-600 border-t-transparent" />
|
||||
</div>
|
||||
) : matchedControl ? (
|
||||
<ControlPanel ctrl={matchedControl} label="Regulatorisch gedeckt" />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-400 text-sm">
|
||||
Control konnte nicht geladen werden
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
408
admin-compliance/app/sdk/control-library/components/helpers.tsx
Normal file
408
admin-compliance/app/sdk/control-library/components/helpers.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
1000
admin-compliance/app/sdk/control-library/page.tsx
Normal file
1000
admin-compliance/app/sdk/control-library/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
739
admin-compliance/app/sdk/control-provenance/page.tsx
Normal file
739
admin-compliance/app/sdk/control-provenance/page.tsx
Normal file
@@ -0,0 +1,739 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Shield, BookOpen, ExternalLink, CheckCircle2, AlertTriangle,
|
||||
Lock, Scale, FileText, Eye, ArrowLeft,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
// =============================================================================
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STATIC PROVENANCE DOCUMENTATION
|
||||
// =============================================================================
|
||||
|
||||
const PROVENANCE_SECTIONS = [
|
||||
{
|
||||
id: 'methodology',
|
||||
title: 'Methodik der Control-Erstellung',
|
||||
content: `## Unabhaengige Formulierung
|
||||
|
||||
Alle Controls in der Canonical Control Library wurden **eigenstaendig formuliert** und folgen einer
|
||||
**unabhaengigen Taxonomie**. Es werden keine proprietaeren Bezeichner, Nummern oder Strukturen
|
||||
aus geschuetzten Quellen uebernommen.
|
||||
|
||||
### Dreistufiger Prozess
|
||||
|
||||
1. **Offene Recherche** — Identifikation von Security-Anforderungen aus oeffentlichen, frei zugaenglichen
|
||||
Frameworks (OWASP, NIST, ENISA). Jede Anforderung wird aus mindestens 2 unabhaengigen offenen Quellen belegt.
|
||||
|
||||
2. **Eigenstaendige Formulierung** — Jedes Control wird mit eigener Sprache, eigener Struktur und eigener
|
||||
Taxonomie (z.B. AUTH-001, NET-001) verfasst. Kein Copy-Paste, keine Paraphrase geschuetzter Texte.
|
||||
|
||||
3. **Too-Close-Pruefung** — Automatisierte Aehnlichkeitspruefung gegen Quelltexte mit 5 Metriken
|
||||
(Token Overlap, N-Gram Jaccard, Embedding Cosine, LCS Ratio, Exact-Phrase). Nur Controls mit
|
||||
Status PASS oder WARN (+ Human Review) werden freigegeben.
|
||||
|
||||
### Rechtliche Grundlage
|
||||
|
||||
- **UrhG §44b** — Text & Data Mining erlaubt fuer Analyse; Kopien werden danach geloescht
|
||||
- **UrhG §23** — Hinreichender Abstand zum Originalwerk durch eigene Formulierung
|
||||
- **BSI Nutzungsbedingungen** — Kommerzielle Nutzung nur mit Zustimmung; wir nutzen BSI-Dokumente
|
||||
ausschliesslich als Analysegrundlage, nicht im Produkt`,
|
||||
},
|
||||
{
|
||||
id: 'filters',
|
||||
title: 'Filter in der Control Library',
|
||||
content: `## Dropdown-Filter
|
||||
|
||||
Die Control Library bietet 7 Filter-Dropdowns, um die ueber 3.000 Controls effizient zu durchsuchen:
|
||||
|
||||
### Schweregrad (Severity)
|
||||
|
||||
| Stufe | Farbe | Bedeutung |
|
||||
|-------|-------|-----------|
|
||||
| **Kritisch** | Rot | Sicherheitskritische Controls — Verstoesse fuehren zu schwerwiegenden Risiken |
|
||||
| **Hoch** | Orange | Wichtige Controls — sollten zeitnah umgesetzt werden |
|
||||
| **Mittel** | Gelb | Standardmaessige Controls — empfohlene Umsetzung |
|
||||
| **Niedrig** | Gruen | Nice-to-have Controls — zusaetzliche Haertung |
|
||||
|
||||
### Domain
|
||||
|
||||
Das Praefix der Control-ID (z.B. \`AUTH-001\`, \`SEC-042\`). Kennzeichnet den thematischen Bereich.
|
||||
Die haeufigsten Domains:
|
||||
|
||||
| Domain | Anzahl | Thema |
|
||||
|--------|--------|-------|
|
||||
| SEC | ~700 | Allgemeine Sicherheit, Systemhaertung |
|
||||
| COMP | ~470 | Compliance, Regulierung, Nachweispflichten |
|
||||
| DATA | ~400 | Datenschutz, Datenklassifizierung, DSGVO |
|
||||
| AI | ~290 | KI-Regulierung (AI Act, Transparenz, Erklaerbarkeit) |
|
||||
| LOG | ~230 | Logging, Monitoring, SIEM |
|
||||
| AUTH | ~200 | Authentifizierung, Zugriffskontrolle |
|
||||
| NET | ~150 | Netzwerksicherheit, Transport, Firewall |
|
||||
| CRYP | ~90 | Kryptographie, Schluesselmanagement |
|
||||
| ACC | ~25 | Zugriffskontrolle (Access Control) |
|
||||
| INC | ~25 | Incident Response, Vorfallmanagement |
|
||||
|
||||
Zusaetzlich existieren spezialisierte Domains wie CRA, ARC (Architektur), API, PKI, SUP (Supply Chain) u.v.m.
|
||||
|
||||
### Status (Release State)
|
||||
|
||||
| Status | Bedeutung |
|
||||
|--------|-----------|
|
||||
| **Draft** | Entwurf — noch nicht freigegeben |
|
||||
| **Approved** | Freigegeben fuer Kunden |
|
||||
| **Review noetig** | Muss manuell geprueft werden |
|
||||
| **Zu aehnlich** | Too-Close-Check hat Warnung ausgeloest |
|
||||
| **Duplikat** | Wurde als Duplikat eines anderen Controls erkannt |
|
||||
|
||||
### Nachweis (Verification Method)
|
||||
|
||||
| Methode | Farbe | Beschreibung |
|
||||
|---------|-------|-------------|
|
||||
| **Code Review** | Blau | Nachweis durch Quellcode-Inspektion |
|
||||
| **Dokument** | Amber | Nachweis durch Richtlinien, Prozesse, Schulungen |
|
||||
| **Tool** | Teal | Nachweis durch automatisierte Scans/Monitoring |
|
||||
| **Hybrid** | Lila | Kombination aus mehreren Methoden |
|
||||
|
||||
### Kategorie
|
||||
|
||||
Thematische Einordnung (17 Kategorien). Kategorien sind **thematisch**, Domains **strukturell**.
|
||||
Ein AUTH-Control kann z.B. die Kategorie "Netzwerksicherheit" haben.
|
||||
|
||||
### Zielgruppe (Target Audience)
|
||||
|
||||
| Zielgruppe | Bedeutung |
|
||||
|------------|-----------|
|
||||
| **Unternehmen** | Fuer Endkunden/Firmen relevant |
|
||||
| **Behoerden** | Spezifisch fuer oeffentliche Verwaltung |
|
||||
| **Anbieter** | Fuer SaaS/Plattform-Anbieter |
|
||||
| **Alle** | Allgemein anwendbar |
|
||||
|
||||
### Dokumentenursprung (Source)
|
||||
|
||||
Filtert nach der Quelldokument-Herkunft des Controls. Zeigt alle Quellen sortiert nach
|
||||
Haeufigkeit. Die wichtigsten Quellen:
|
||||
|
||||
| Quelle | Typ |
|
||||
|--------|-----|
|
||||
| KI-Verordnung (EU) 2024/1689 | EU-Recht |
|
||||
| Cyber Resilience Act (EU) 2024/2847 | EU-Recht |
|
||||
| DSGVO (EU) 2016/679 | EU-Recht |
|
||||
| NIS2-Richtlinie (EU) 2022/2555 | EU-Recht |
|
||||
| NIST SP 800-53, CSF 2.0, SSDF | US-Standards |
|
||||
| OWASP Top 10, ASVS, SAMM | Open Source |
|
||||
| ENISA Guidelines | EU-Agentur |
|
||||
| CISA Secure by Design | US-Behoerde |
|
||||
| BDSG, TKG, GewO, HGB | Deutsche Gesetze |
|
||||
| EDPB Leitlinien | EU Datenschutz |`,
|
||||
},
|
||||
{
|
||||
id: 'badges',
|
||||
title: 'Badges & Lizenzregeln',
|
||||
content: `## Badges in der Control Library
|
||||
|
||||
Jedes Control zeigt mehrere farbige Badges:
|
||||
|
||||
### Lizenzregel-Badge (Rule 1 / 2 / 3)
|
||||
|
||||
Die Lizenzregel bestimmt, wie ein Control erstellt und genutzt werden darf:
|
||||
|
||||
| Badge | Farbe | Regel | Bedeutung |
|
||||
|-------|-------|-------|-----------|
|
||||
| **Free Use** | Gruen | Rule 1 | Quelle ist Public Domain oder EU-Recht — Originaltext darf gezeigt werden |
|
||||
| **Zitation** | Blau | Rule 2 | Quelle ist CC-BY oder aehnlich — Zitation + Quellenangabe erforderlich |
|
||||
| **Reformuliert** | Amber | Rule 3 | Quelle hat eingeschraenkte Lizenz — Control wurde eigenstaendig reformuliert, kein Originaltext |
|
||||
|
||||
### Processing-Path
|
||||
|
||||
| Pfad | Bedeutung |
|
||||
|------|-----------|
|
||||
| **structured** | Control wurde direkt aus strukturierten Daten (Tabellen, Listen) extrahiert |
|
||||
| **llm_reform** | Control wurde mit LLM eigenstaendig formuliert (bei Rule 3 zwingend) |
|
||||
|
||||
### Referenzen (Open Anchors)
|
||||
|
||||
Zeigt die Anzahl der verlinkten Open-Source-Referenzen (OWASP, NIST, ENISA etc.).
|
||||
Jedes freigegebene Control muss mindestens 1 Open Anchor haben.
|
||||
|
||||
### Weitere Badges
|
||||
|
||||
| Badge | Bedeutung |
|
||||
|-------|-----------|
|
||||
| Score | Risiko-Score (0-10) |
|
||||
| Severity-Badge | Schweregrad (Kritisch/Hoch/Mittel/Niedrig) |
|
||||
| State-Badge | Freigabestatus (Draft/Approved/etc.) |
|
||||
| Kategorie-Badge | Thematische Kategorie |
|
||||
| Zielgruppe-Badge | Enterprise/Behoerden/Anbieter/Alle |`,
|
||||
},
|
||||
{
|
||||
id: 'taxonomy',
|
||||
title: 'Unabhaengige Taxonomie',
|
||||
content: `## Eigenes Klassifikationssystem
|
||||
|
||||
Die Canonical Control Library verwendet ein **eigenes Domain-Schema**, das sich bewusst von
|
||||
proprietaeren Frameworks unterscheidet. Die Domains werden **automatisch** durch den
|
||||
Control Generator vergeben, basierend auf dem Inhalt der Quelldokumente.
|
||||
|
||||
### Top-10 Domains
|
||||
|
||||
| Domain | Anzahl | Thema | Hauptquellen |
|
||||
|--------|--------|-------|-------------|
|
||||
| SEC | ~700 | Allgemeine Sicherheit | CRA, NIS2, BSI, ENISA |
|
||||
| COMP | ~470 | Compliance & Regulierung | DSGVO, AI Act, Richtlinien |
|
||||
| DATA | ~400 | Datenschutz & Datenklassifizierung | DSGVO, BDSG, EDPB |
|
||||
| AI | ~290 | KI-Regulierung & Ethik | AI Act, HLEG, OECD |
|
||||
| LOG | ~230 | Logging & Monitoring | NIST, OWASP |
|
||||
| AUTH | ~200 | Authentifizierung & Session | NIST SP 800-63, OWASP |
|
||||
| NET | ~150 | Netzwerksicherheit | NIST, ENISA |
|
||||
| CRYP | ~90 | Kryptographie & Schluessel | NIST SP 800-57 |
|
||||
| ACC | ~25 | Zugriffskontrolle | OWASP ASVS |
|
||||
| INC | ~25 | Incident Response | NIS2, CRA |
|
||||
|
||||
### Spezialisierte Domains
|
||||
|
||||
Neben den Top-10 gibt es ueber 90 weitere Domains fuer spezifische Themen:
|
||||
|
||||
- **CRA** — Cyber Resilience Act spezifisch
|
||||
- **ARC** — Sichere Architektur
|
||||
- **API** — API-Security
|
||||
- **PKI** — Public Key Infrastructure
|
||||
- **SUP** — Supply Chain Security
|
||||
- **VUL** — Vulnerability Management
|
||||
- **BCP** — Business Continuity
|
||||
- **PHY** — Physische Sicherheit
|
||||
- u.v.m.
|
||||
|
||||
### ID-Format
|
||||
|
||||
Control-IDs folgen dem Muster \`DOMAIN-NNN\` (z.B. AUTH-001, SEC-042). Dieses Format ist
|
||||
**nicht von BSI oder anderen proprietaeren Standards abgeleitet**, sondern folgt einem
|
||||
allgemein ueblichen Nummerierungsschema.`,
|
||||
},
|
||||
{
|
||||
id: 'open-sources',
|
||||
title: 'Offene Referenzquellen',
|
||||
content: `## Primaere offene Quellen
|
||||
|
||||
Alle Controls sind in mindestens einer der folgenden **frei zugaenglichen** Quellen verankert:
|
||||
|
||||
### OWASP (CC BY-SA 4.0 — kommerziell erlaubt)
|
||||
- **ASVS** — Application Security Verification Standard v4.0.3
|
||||
- **MASVS** — Mobile Application Security Verification Standard v2.1
|
||||
- **Top 10** — OWASP Top 10 (2021)
|
||||
- **Cheat Sheets** — OWASP Cheat Sheet Series
|
||||
- **SAMM** — Software Assurance Maturity Model
|
||||
|
||||
### NIST (Public Domain — keine Einschraenkungen)
|
||||
- **SP 800-53 Rev.5** — Security and Privacy Controls
|
||||
- **SP 800-63B** — Digital Identity Guidelines (Authentication)
|
||||
- **SP 800-57** — Key Management Recommendations
|
||||
- **SP 800-52 Rev.2** — TLS Implementation Guidelines
|
||||
- **SP 800-92** — Log Management Guide
|
||||
- **SP 800-218 (SSDF)** — Secure Software Development Framework
|
||||
- **SP 800-60** — Information Types to Security Categories
|
||||
|
||||
### ENISA (CC BY 4.0 — kommerziell erlaubt)
|
||||
- Good Practices for IoT/Mobile Security
|
||||
- Data Protection Engineering
|
||||
- Algorithms, Key Sizes and Parameters Report
|
||||
|
||||
### Weitere offene Quellen
|
||||
- **SLSA** (Supply-chain Levels for Software Artifacts) — Google Open Source
|
||||
- **CIS Controls v8** (CC BY-NC-ND — nur fuer interne Analyse)`,
|
||||
},
|
||||
{
|
||||
id: 'restricted-sources',
|
||||
title: 'Geschuetzte Quellen — Nur interne Analyse',
|
||||
content: `## Quellen mit eingeschraenkter Nutzung
|
||||
|
||||
Die folgenden Quellen werden **ausschliesslich intern zur Analyse** verwendet.
|
||||
Kein Text, keine Struktur, keine Bezeichner aus diesen Quellen erscheinen im Produkt.
|
||||
|
||||
### BSI (Nutzungsbedingungen — kommerziell eingeschraenkt)
|
||||
- TR-03161 Teil 1-3 (Mobile, Web, Hintergrunddienste)
|
||||
- Nutzung: TDM unter UrhG §44b, Kopien werden geloescht
|
||||
- Kein Shipping von Zitaten, Embeddings oder Strukturen
|
||||
|
||||
### ISO/IEC (Kostenpflichtig — kein Shipping)
|
||||
- ISO 27001, ISO 27002
|
||||
- Nutzung: Nur als Referenz fuer Mapping, kein Text im Produkt
|
||||
|
||||
### ETSI (Restriktiv — kein kommerzieller Gebrauch)
|
||||
- Nutzung: Nur als Hintergrundwissen, kein direkter Einfluss
|
||||
|
||||
### Trennungsprinzip
|
||||
|
||||
| Ebene | Geschuetzte Quelle | Offene Quelle |
|
||||
|-------|--------------------|---------------|
|
||||
| Analyse | ✅ Darf gelesen werden | ✅ Darf gelesen werden |
|
||||
| Inspiration | ✅ Darf Ideen liefern | ✅ Darf Ideen liefern |
|
||||
| Formulierung | ❌ Keine Uebernahme | ✅ Darf zitiert werden |
|
||||
| Struktur | ❌ Keine Uebernahme | ✅ Darf verwendet werden |
|
||||
| Produkttext | ❌ Nicht erlaubt | ✅ Erlaubt |`,
|
||||
},
|
||||
{
|
||||
id: 'verification-methods',
|
||||
title: 'Verifikationsmethoden',
|
||||
content: `## Nachweis-Klassifizierung
|
||||
|
||||
Jedes Control wird einer von vier Verifikationsmethoden zugeordnet. Dies bestimmt,
|
||||
**wie** ein Kunde den Nachweis fuer die Einhaltung erbringen kann:
|
||||
|
||||
| Methode | Beschreibung | Beispiele |
|
||||
|---------|-------------|-----------|
|
||||
| **Code Review** | Nachweis durch Quellcode-Inspektion | Input-Validierung, Encryption-Konfiguration, Auth-Logic |
|
||||
| **Dokument** | Nachweis durch Richtlinien, Prozesse, Schulungen | Notfallplaene, Schulungsnachweise, Datenschutzkonzepte |
|
||||
| **Tool** | Nachweis durch automatisierte Tools/Scans | SIEM-Logs, Vulnerability-Scans, Monitoring-Dashboards |
|
||||
| **Hybrid** | Kombination aus mehreren Methoden | Zugriffskontrollen (Code + Policy + Tool) |
|
||||
|
||||
### Bedeutung fuer Kunden
|
||||
|
||||
- **Code Review Controls** koennen direkt im SDK-Scan geprueft werden
|
||||
- **Dokument Controls** erfordern manuelle Uploads (PDFs, Links)
|
||||
- **Tool Controls** koennen per API-Integration automatisch nachgewiesen werden
|
||||
- **Hybrid Controls** benoetigen mehrere Nachweisarten`,
|
||||
},
|
||||
{
|
||||
id: 'categories',
|
||||
title: 'Thematische Kategorien',
|
||||
content: `## 17 Sicherheitskategorien
|
||||
|
||||
Controls sind in thematische Kategorien gruppiert, um Kunden eine
|
||||
uebersichtliche Navigation zu ermoeglichen:
|
||||
|
||||
| Kategorie | Beschreibung |
|
||||
|-----------|-------------|
|
||||
| Verschluesselung & Kryptographie | TLS, Key Management, Algorithmen |
|
||||
| Authentisierung & Zugriffskontrolle | Login, MFA, RBAC, Session-Management |
|
||||
| Netzwerksicherheit | Firewall, Segmentierung, VPN, DNS |
|
||||
| Datenschutz & Datensicherheit | DSGVO, Datenklassifizierung, Anonymisierung |
|
||||
| Logging & Monitoring | SIEM, Audit-Logs, Alerting |
|
||||
| Vorfallmanagement | Incident Response, Meldepflichten |
|
||||
| Notfall & Wiederherstellung | BCM, Disaster Recovery, Backups |
|
||||
| Compliance & Audit | Zertifizierungen, Audits, Berichtspflichten |
|
||||
| Lieferkettenmanagement | Vendor Risk, SBOM, Third-Party |
|
||||
| Physische Sicherheit | Zutritt, Gebaeudesicherheit |
|
||||
| Personal & Schulung | Security Awareness, Rollenkonzepte |
|
||||
| Anwendungssicherheit | SAST, DAST, Secure Coding |
|
||||
| Systemhaertung & -betrieb | Patching, Konfiguration, Hardening |
|
||||
| Risikomanagement | Risikoanalyse, Bewertung, Massnahmen |
|
||||
| Sicherheitsorganisation | ISMS, Richtlinien, Governance |
|
||||
| Hardware & Plattformsicherheit | TPM, Secure Boot, Firmware |
|
||||
| Identitaetsmanagement | SSO, Federation, Directory |
|
||||
|
||||
### Abgrenzung zu Domains
|
||||
|
||||
Kategorien sind **thematisch**, Domains (AUTH, NET, etc.) sind **strukturell**.
|
||||
Ein Control AUTH-005 (Domain AUTH) hat die Kategorie "authentication",
|
||||
aber ein Control NET-012 (Domain NET) koennte ebenfalls die Kategorie
|
||||
"authentication" haben, wenn es um Netzwerk-Authentifizierung geht.`,
|
||||
},
|
||||
{
|
||||
id: 'master-library',
|
||||
title: 'Master Library Strategie',
|
||||
content: `## RAG-First Ansatz
|
||||
|
||||
Die Canonical Control Library folgt einer **RAG-First-Strategie**:
|
||||
|
||||
### Schritt 1: Rule 1+2 Controls aus RAG generieren
|
||||
|
||||
Prioritaet haben Controls aus Quellen mit **Originaltext-Erlaubnis**:
|
||||
|
||||
| Welle | Quellen | Lizenzregel | Vorteil |
|
||||
|-------|---------|------------|---------|
|
||||
| 1 | OWASP (ASVS, MASVS, Top10) | Rule 2 (CC-BY-SA, Zitation) | Originaltext + Zitation |
|
||||
| 2 | NIST (SP 800-53, CSF, SSDF) | Rule 1 (Public Domain) | Voller Text, keine Einschraenkungen |
|
||||
| 3 | EU-Verordnungen (DSGVO, AI Act, NIS2, CRA) | Rule 1 (EU Law) | Gesetzestext + Erklaerung |
|
||||
| 4 | Deutsche Gesetze (BDSG, TTDSG, TKG) | Rule 1 (DE Law) | Gesetzestext + Erklaerung |
|
||||
|
||||
### Schritt 2: Dedup gegen BSI Rule-3 Controls
|
||||
|
||||
Die ~880 BSI Rule-3 Controls werden **gegen** die neuen Rule 1+2 Controls abgeglichen:
|
||||
|
||||
- Wenn ein BSI-Control ein Duplikat eines OWASP/NIST-Controls ist → **OWASP/NIST bevorzugt**
|
||||
(weil Originaltext + Zitation erlaubt)
|
||||
- BSI-Duplikate werden als \`deprecated\` markiert
|
||||
- Tags und Anchors werden in den behaltenen Control zusammengefuehrt
|
||||
|
||||
### Schritt 3: Aktueller Stand
|
||||
|
||||
Aktuell: **~3.100+ Controls** (Stand Maerz 2026), davon:
|
||||
- Viele mit \`source_original_text\` (Originaltext fuer Kunden sichtbar)
|
||||
- Viele mit \`source_citation\` (Quellenangabe mit Lizenz)
|
||||
- Klare Nachweismethode (\`verification_method\`)
|
||||
- Thematische Kategorie (\`category\`)
|
||||
|
||||
### Verstaendliche Texte
|
||||
|
||||
Zusaetzlich zum Originaltext (der oft juristisch/technisch formuliert ist)
|
||||
enthaelt jedes Control ein eigenstaendig formuliertes **Ziel** (objective)
|
||||
und eine **Begruendung** (rationale) in verstaendlicher Sprache.`,
|
||||
},
|
||||
{
|
||||
id: 'validation',
|
||||
title: 'Automatisierte Validierung',
|
||||
content: `## CI/CD-Pruefungen
|
||||
|
||||
Jedes Control wird bei jedem Commit automatisch geprueft:
|
||||
|
||||
### 1. Schema-Validierung
|
||||
- Alle Pflichtfelder vorhanden
|
||||
- Control-ID Format: \`^[A-Z]{2,6}-[0-9]{3}$\`
|
||||
- Severity: low, medium, high, critical
|
||||
- Risk Score: 0-10
|
||||
|
||||
### 2. No-Leak Scanner
|
||||
Regex-Pruefung gegen verbotene Muster in produktfaehigen Feldern:
|
||||
- \`O.[A-Za-z]+_[0-9]+\` — BSI Objective-IDs
|
||||
- \`TR-03161\` — Direkte BSI-TR-Referenzen
|
||||
- \`BSI-TR-\` — BSI-spezifische Locators
|
||||
- \`Anforderung [A-Z].[0-9]+\` — BSI-Anforderungsformat
|
||||
|
||||
### 3. Open Anchor Check
|
||||
Jedes freigegebene Control muss mindestens 1 Open-Source-Referenz haben.
|
||||
|
||||
### 4. Too-Close Detektor (5 Metriken)
|
||||
|
||||
| Metrik | Warn | Fail | Beschreibung |
|
||||
|--------|------|------|-------------|
|
||||
| Exact Phrase | ≥8 Tokens | ≥12 Tokens | Laengste identische Token-Sequenz |
|
||||
| Token Overlap | ≥0.20 | ≥0.30 | Jaccard-Aehnlichkeit der Token-Mengen |
|
||||
| 3-Gram Jaccard | ≥0.10 | ≥0.18 | Zeichenketten-Aehnlichkeit |
|
||||
| Embedding Cosine | ≥0.86 | ≥0.92 | Semantische Aehnlichkeit (bge-m3) |
|
||||
| LCS Ratio | ≥0.35 | ≥0.50 | Longest Common Subsequence |
|
||||
|
||||
**Entscheidungslogik:**
|
||||
- **PASS** — Kein Fail + max 1 Warn
|
||||
- **WARN** — Max 2 Warn, kein Fail → Human Review erforderlich
|
||||
- **FAIL** — Irgendein Fail → Blockiert, Umformulierung noetig`,
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function ControlProvenancePage() {
|
||||
const [licenses, setLicenses] = useState<LicenseInfo[]>([])
|
||||
const [sources, setSources] = useState<SourceInfo[]>([])
|
||||
const [activeSection, setActiveSection] = useState('methodology')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [licRes, srcRes] = await Promise.all([
|
||||
fetch('/api/sdk/v1/canonical?endpoint=licenses'),
|
||||
fetch('/api/sdk/v1/canonical?endpoint=sources'),
|
||||
])
|
||||
if (licRes.ok) setLicenses(await licRes.json())
|
||||
if (srcRes.ok) setSources(await srcRes.json())
|
||||
} catch {
|
||||
// silently continue — static content still shown
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const currentSection = PROVENANCE_SECTIONS.find(s => s.id === activeSection)
|
||||
|
||||
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" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Control Provenance Wiki</h1>
|
||||
<p className="text-xs text-gray-500">
|
||||
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"
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
Zur Control Library
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left: Navigation */}
|
||||
<div className="w-72 border-r border-gray-200 bg-gray-50 overflow-y-auto flex-shrink-0">
|
||||
<div className="p-3 space-y-1">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase px-3 mb-2">Dokumentation</p>
|
||||
{PROVENANCE_SECTIONS.map(section => (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
activeSection === section.id
|
||||
? 'bg-green-100 text-green-900 font-medium'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{section.title}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="border-t border-gray-200 mt-3 pt-3">
|
||||
<p className="text-xs font-semibold text-gray-400 uppercase px-3 mb-2">Live-Daten</p>
|
||||
<button
|
||||
onClick={() => setActiveSection('license-matrix')}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
activeSection === 'license-matrix'
|
||||
? 'bg-green-100 text-green-900 font-medium'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Lizenz-Matrix
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSection('source-registry')}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
activeSection === 'source-registry'
|
||||
? 'bg-green-100 text-green-900 font-medium'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Quellenregister
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<MarkdownRenderer content={currentSection.content} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* License Matrix (live data) */}
|
||||
{activeSection === 'license-matrix' && (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Lizenz-Matrix</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Uebersicht aller Lizenzen mit ihren erlaubten Nutzungsarten.
|
||||
</p>
|
||||
{loading ? (
|
||||
<div className="animate-pulse h-32 bg-gray-100 rounded" />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Lizenz</th>
|
||||
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Kommerziell</th>
|
||||
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">AI-Training</th>
|
||||
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">TDM (§44b)</th>
|
||||
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Loeschpflicht</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{licenses.map(lic => (
|
||||
<tr key={lic.license_id} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2 border-b">
|
||||
<div className="font-medium text-gray-900">{lic.license_id}</div>
|
||||
<div className="text-xs text-gray-500">{lic.name}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 border-b">
|
||||
<UsageBadge value={lic.commercial_use} />
|
||||
</td>
|
||||
<td className="px-3 py-2 border-b">
|
||||
<UsageBadge value={lic.ai_training_restriction || 'n/a'} />
|
||||
</td>
|
||||
<td className="px-3 py-2 border-b">
|
||||
<UsageBadge value={lic.tdm_allowed_under_44b || 'unclear'} />
|
||||
</td>
|
||||
<td className="px-3 py-2 border-b">
|
||||
{lic.deletion_required ? (
|
||||
<span className="text-red-600 text-xs font-medium">Ja</span>
|
||||
) : (
|
||||
<span className="text-green-600 text-xs font-medium">Nein</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Registry (live data) */}
|
||||
{activeSection === 'source-registry' && (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Quellenregister</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Alle registrierten Quellen mit ihren Berechtigungen.
|
||||
</p>
|
||||
{loading ? (
|
||||
<div className="animate-pulse h-32 bg-gray-100 rounded" />
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sources.map(src => (
|
||||
<div key={src.source_id} className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-900">{src.title}</h3>
|
||||
<p className="text-xs text-gray-500">{src.publisher} — {src.license_name}</p>
|
||||
</div>
|
||||
{src.url && (
|
||||
<a
|
||||
href={src.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Quelle
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<PermBadge label="Analyse" allowed={src.allowed_analysis} />
|
||||
<PermBadge label="Excerpt" allowed={src.allowed_store_excerpt} />
|
||||
<PermBadge label="Embeddings" allowed={src.allowed_ship_embeddings} />
|
||||
<PermBadge label="Produkt" allowed={src.allowed_ship_in_product} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
|
||||
// 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 }} />
|
||||
}
|
||||
@@ -196,7 +196,15 @@ function ControlCard({
|
||||
{/* Linked Evidence */}
|
||||
{control.linkedEvidence.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||
<span className="text-xs text-gray-500 mb-1 block">Nachweise:</span>
|
||||
<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 ${
|
||||
@@ -205,6 +213,9 @@ function ControlCard({
|
||||
'bg-yellow-50 text-yellow-700'
|
||||
}`}>
|
||||
{ev.title}
|
||||
{(ev as { confidenceLevel?: string }).confidenceLevel && (
|
||||
<span className="ml-1 opacity-70">({(ev as { confidenceLevel?: string }).confidenceLevel})</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -359,6 +370,49 @@ interface RAGControlSuggestion {
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
function TransitionErrorBanner({
|
||||
controlId,
|
||||
violations,
|
||||
onDismiss,
|
||||
}: {
|
||||
controlId: string
|
||||
violations: string[]
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="p-4 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-orange-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="font-medium text-orange-800">
|
||||
Status-Transition blockiert ({controlId})
|
||||
</h4>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{violations.map((v, i) => (
|
||||
<li key={i} className="text-sm text-orange-700 flex items-start gap-2">
|
||||
<span className="text-orange-400 mt-0.5">•</span>
|
||||
<span>{v}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<a href="/sdk/evidence" className="mt-2 inline-block text-sm text-purple-600 hover:text-purple-700 font-medium">
|
||||
Evidence hinzufuegen →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onDismiss} className="text-orange-400 hover:text-orange-600 ml-4">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ControlsPage() {
|
||||
const { state, dispatch } = useSDK()
|
||||
const router = useRouter()
|
||||
@@ -373,6 +427,9 @@ export default function ControlsPage() {
|
||||
const [showRagPanel, setShowRagPanel] = useState(false)
|
||||
const [selectedRequirementId, setSelectedRequirementId] = useState<string>('')
|
||||
|
||||
// Transition error from Anti-Fake-Evidence state machine (409 Conflict)
|
||||
const [transitionError, setTransitionError] = useState<{ controlId: string; violations: string[] } | null>(null)
|
||||
|
||||
// Track effectiveness locally as it's not in the SDK state type
|
||||
const [effectivenessMap, setEffectivenessMap] = useState<Record<string, number>>({})
|
||||
// Track linked evidence per control
|
||||
@@ -385,7 +442,7 @@ export default function ControlsPage() {
|
||||
const data = await res.json()
|
||||
const allEvidence = data.evidence || data
|
||||
if (Array.isArray(allEvidence)) {
|
||||
const map: Record<string, { id: string; title: string; status: string }[]> = {}
|
||||
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] = []
|
||||
@@ -393,6 +450,7 @@ export default function ControlsPage() {
|
||||
id: ev.id,
|
||||
title: ev.title || ev.name || 'Nachweis',
|
||||
status: ev.status || 'pending',
|
||||
confidenceLevel: ev.confidence_level || undefined,
|
||||
})
|
||||
}
|
||||
setEvidenceMap(map)
|
||||
@@ -483,20 +541,56 @@ export default function ControlsPage() {
|
||||
: 0
|
||||
const partialCount = displayControls.filter(c => c.displayStatus === 'partial').length
|
||||
|
||||
const handleStatusChange = async (controlId: string, status: ImplementationStatus) => {
|
||||
const handleStatusChange = async (controlId: string, newStatus: ImplementationStatus) => {
|
||||
// Remember old status for rollback
|
||||
const oldControl = state.controls.find(c => c.id === controlId)
|
||||
const oldStatus = oldControl?.implementationStatus
|
||||
|
||||
// Optimistic update
|
||||
dispatch({
|
||||
type: 'UPDATE_CONTROL',
|
||||
payload: { id: controlId, data: { implementationStatus: status } },
|
||||
payload: { id: controlId, data: { implementationStatus: newStatus } },
|
||||
})
|
||||
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ implementation_status: status }),
|
||||
body: JSON.stringify({ implementation_status: newStatus }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
// Rollback optimistic update
|
||||
if (oldStatus) {
|
||||
dispatch({
|
||||
type: 'UPDATE_CONTROL',
|
||||
payload: { id: controlId, data: { implementationStatus: oldStatus } },
|
||||
})
|
||||
}
|
||||
|
||||
const err = await res.json().catch(() => ({ detail: 'Status-Aenderung fehlgeschlagen' }))
|
||||
|
||||
if (res.status === 409 && err.detail?.violations) {
|
||||
setTransitionError({ controlId, violations: err.detail.violations })
|
||||
} else {
|
||||
const msg = typeof err.detail === 'string' ? err.detail : err.detail?.error || 'Status-Aenderung fehlgeschlagen'
|
||||
setError(msg)
|
||||
}
|
||||
} else {
|
||||
// Clear any previous transition error for this control
|
||||
if (transitionError?.controlId === controlId) {
|
||||
setTransitionError(null)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — SDK state is already updated
|
||||
// Network error — rollback
|
||||
if (oldStatus) {
|
||||
dispatch({
|
||||
type: 'UPDATE_CONTROL',
|
||||
payload: { id: controlId, data: { implementationStatus: oldStatus } },
|
||||
})
|
||||
}
|
||||
setError('Netzwerkfehler bei Status-Aenderung')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,6 +839,15 @@ export default function ControlsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transition Error Banner (Anti-Fake-Evidence 409 violations) */}
|
||||
{transitionError && (
|
||||
<TransitionErrorBanner
|
||||
controlId={transitionError.controlId}
|
||||
violations={transitionError.violations}
|
||||
onDismiss={() => setTransitionError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Requirements Alert */}
|
||||
{state.requirements.length === 0 && !loading && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
|
||||
@@ -32,18 +32,28 @@ 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'] },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"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 (E0–E4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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
@@ -12,6 +12,46 @@ interface Component {
|
||||
safety_relevant: boolean
|
||||
parent_id: string | null
|
||||
children: Component[]
|
||||
library_component_id?: string
|
||||
energy_source_ids?: string[]
|
||||
}
|
||||
|
||||
interface LibraryComponent {
|
||||
id: string
|
||||
name_de: string
|
||||
name_en: string
|
||||
category: string
|
||||
description_de: string
|
||||
typical_hazard_categories: string[]
|
||||
typical_energy_sources: string[]
|
||||
maps_to_component_type: string
|
||||
tags: string[]
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
interface EnergySource {
|
||||
id: string
|
||||
name_de: string
|
||||
name_en: string
|
||||
description_de: string
|
||||
typical_components: string[]
|
||||
typical_hazard_categories: string[]
|
||||
tags: string[]
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
const LIBRARY_CATEGORIES: Record<string, string> = {
|
||||
mechanical: 'Mechanik',
|
||||
structural: 'Struktur',
|
||||
drive: 'Antrieb',
|
||||
hydraulic: 'Hydraulik',
|
||||
pneumatic: 'Pneumatik',
|
||||
electrical: 'Elektrik',
|
||||
control: 'Steuerung',
|
||||
sensor: 'Sensorik',
|
||||
actuator: 'Aktorik',
|
||||
safety: 'Sicherheit',
|
||||
it_network: 'IT/Netzwerk',
|
||||
}
|
||||
|
||||
const COMPONENT_TYPES = [
|
||||
@@ -98,6 +138,11 @@ function ComponentTreeNode({
|
||||
Sicherheitsrelevant
|
||||
</span>
|
||||
)}
|
||||
{component.library_component_id && (
|
||||
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">
|
||||
Bibliothek
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{component.description && (
|
||||
@@ -289,6 +334,289 @@ function buildTree(components: Component[]): Component[] {
|
||||
return roots
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Component Library Modal (Phase 5)
|
||||
// ============================================================================
|
||||
|
||||
function ComponentLibraryModal({
|
||||
onAdd,
|
||||
onClose,
|
||||
}: {
|
||||
onAdd: (components: LibraryComponent[], energySources: EnergySource[]) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [libraryComponents, setLibraryComponents] = useState<LibraryComponent[]>([])
|
||||
const [energySources, setEnergySources] = useState<EnergySource[]>([])
|
||||
const [selectedComponents, setSelectedComponents] = useState<Set<string>>(new Set())
|
||||
const [selectedEnergySources, setSelectedEnergySources] = useState<Set<string>>(new Set())
|
||||
const [search, setSearch] = useState('')
|
||||
const [filterCategory, setFilterCategory] = useState('')
|
||||
const [activeTab, setActiveTab] = useState<'components' | 'energy'>('components')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [compRes, enRes] = await Promise.all([
|
||||
fetch('/api/sdk/v1/iace/component-library'),
|
||||
fetch('/api/sdk/v1/iace/energy-sources'),
|
||||
])
|
||||
if (compRes.ok) {
|
||||
const json = await compRes.json()
|
||||
setLibraryComponents(json.components || [])
|
||||
}
|
||||
if (enRes.ok) {
|
||||
const json = await enRes.json()
|
||||
setEnergySources(json.energy_sources || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch library:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
function toggleComponent(id: string) {
|
||||
setSelectedComponents(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function toggleEnergySource(id: string) {
|
||||
setSelectedEnergySources(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function toggleAllInCategory(category: string) {
|
||||
const items = libraryComponents.filter(c => c.category === category)
|
||||
const allIds = items.map(i => i.id)
|
||||
const allSelected = allIds.every(id => selectedComponents.has(id))
|
||||
setSelectedComponents(prev => {
|
||||
const next = new Set(prev)
|
||||
allIds.forEach(id => allSelected ? next.delete(id) : next.add(id))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
const selComps = libraryComponents.filter(c => selectedComponents.has(c.id))
|
||||
const selEnergy = energySources.filter(e => selectedEnergySources.has(e.id))
|
||||
onAdd(selComps, selEnergy)
|
||||
}
|
||||
|
||||
const filtered = libraryComponents.filter(c => {
|
||||
if (filterCategory && c.category !== filterCategory) return false
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
return c.name_de.toLowerCase().includes(q) || c.name_en.toLowerCase().includes(q) || c.description_de.toLowerCase().includes(q)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const grouped = filtered.reduce<Record<string, LibraryComponent[]>>((acc, c) => {
|
||||
if (!acc[c.category]) acc[c.category] = []
|
||||
acc[c.category].push(c)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const categories = Object.keys(LIBRARY_CATEGORIES)
|
||||
const totalSelected = selectedComponents.size + selectedEnergySources.size
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600 mx-auto" />
|
||||
<p className="mt-3 text-sm text-gray-500">Bibliothek wird geladen...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-4xl max-h-[85vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Komponentenbibliothek</h3>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('components')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
activeTab === 'components' ? 'bg-purple-100 text-purple-700' : 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Komponenten ({libraryComponents.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('energy')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
activeTab === 'energy' ? 'bg-purple-100 text-purple-700' : 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
Energiequellen ({energySources.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'components' && (
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Suchen..."
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={e => setFilterCategory(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="">Alle Kategorien</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat}>{LIBRARY_CATEGORIES[cat]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{activeTab === 'components' ? (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(grouped)
|
||||
.sort(([a], [b]) => categories.indexOf(a) - categories.indexOf(b))
|
||||
.map(([category, items]) => (
|
||||
<div key={category}>
|
||||
<div className="flex items-center gap-2 mb-2 sticky top-0 bg-white dark:bg-gray-800 py-1 z-10">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{LIBRARY_CATEGORIES[category] || category}
|
||||
</h4>
|
||||
<span className="text-xs text-gray-400">({items.length})</span>
|
||||
<button
|
||||
onClick={() => toggleAllInCategory(category)}
|
||||
className="text-xs text-purple-600 hover:text-purple-700 ml-auto"
|
||||
>
|
||||
{items.every(i => selectedComponents.has(i.id)) ? 'Alle abwaehlen' : 'Alle waehlen'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{items.map(comp => (
|
||||
<label
|
||||
key={comp.id}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedComponents.has(comp.id)
|
||||
? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20'
|
||||
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-750'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedComponents.has(comp.id)}
|
||||
onChange={() => toggleComponent(comp.id)}
|
||||
className="mt-0.5 accent-purple-600"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-gray-400">{comp.id}</span>
|
||||
<ComponentTypeIcon type={comp.maps_to_component_type} />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{comp.name_de}</div>
|
||||
{comp.description_de && (
|
||||
<div className="text-xs text-gray-500 mt-0.5 line-clamp-2">{comp.description_de}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">Keine Komponenten gefunden</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{energySources.map(es => (
|
||||
<label
|
||||
key={es.id}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedEnergySources.has(es.id)
|
||||
? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20'
|
||||
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-750'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedEnergySources.has(es.id)}
|
||||
onChange={() => toggleEnergySource(es.id)}
|
||||
className="mt-0.5 accent-purple-600"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-gray-400">{es.id}</span>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{es.name_de}</div>
|
||||
{es.description_de && (
|
||||
<div className="text-xs text-gray-500 mt-0.5 line-clamp-2">{es.description_de}</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">
|
||||
{selectedComponents.size} Komponenten, {selectedEnergySources.size} Energiequellen ausgewaehlt
|
||||
</span>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={totalSelected === 0}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
totalSelected > 0
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{totalSelected > 0 ? `${totalSelected} hinzufuegen` : 'Auswaehlen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Page
|
||||
// ============================================================================
|
||||
|
||||
export default function ComponentsPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
@@ -297,6 +625,7 @@ export default function ComponentsPage() {
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingComponent, setEditingComponent] = useState<Component | null>(null)
|
||||
const [addingParentId, setAddingParentId] = useState<string | null>(null)
|
||||
const [showLibrary, setShowLibrary] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchComponents()
|
||||
@@ -365,6 +694,32 @@ export default function ComponentsPage() {
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
async function handleAddFromLibrary(libraryComps: LibraryComponent[], energySrcs: EnergySource[]) {
|
||||
setShowLibrary(false)
|
||||
const energySourceIds = energySrcs.map(e => e.id)
|
||||
|
||||
for (const comp of libraryComps) {
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: comp.name_de,
|
||||
type: comp.maps_to_component_type,
|
||||
description: comp.description_de,
|
||||
safety_relevant: false,
|
||||
library_component_id: comp.id,
|
||||
energy_source_ids: energySourceIds,
|
||||
tags: comp.tags,
|
||||
}),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`Failed to add component ${comp.id}:`, err)
|
||||
}
|
||||
}
|
||||
await fetchComponents()
|
||||
}
|
||||
|
||||
const tree = buildTree(components)
|
||||
|
||||
if (loading) {
|
||||
@@ -386,22 +741,41 @@ export default function ComponentsPage() {
|
||||
</p>
|
||||
</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 className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowLibrary(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
Aus Bibliothek waehlen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowForm(true)
|
||||
setEditingComponent(null)
|
||||
setAddingParentId(null)
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Komponente hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Library Modal */}
|
||||
{showLibrary && (
|
||||
<ComponentLibraryModal
|
||||
onAdd={handleAddFromLibrary}
|
||||
onClose={() => setShowLibrary(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
{showForm && (
|
||||
<ComponentForm
|
||||
@@ -454,12 +828,20 @@ 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>
|
||||
<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 className="mt-6 flex items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowLibrary(true)}
|
||||
className="px-6 py-3 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors"
|
||||
>
|
||||
Aus Bibliothek waehlen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowForm(true)}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Manuell hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,20 +14,51 @@ interface Mitigation {
|
||||
created_at: string
|
||||
verified_at: string | null
|
||||
verified_by: string | null
|
||||
source?: string
|
||||
}
|
||||
|
||||
interface Hazard {
|
||||
id: string
|
||||
name: string
|
||||
risk_level: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
interface ProtectiveMeasure {
|
||||
id: string
|
||||
reduction_type: string
|
||||
sub_type: string
|
||||
name: string
|
||||
description: string
|
||||
hazard_category: string
|
||||
examples: string[]
|
||||
}
|
||||
|
||||
interface SuggestedMeasure {
|
||||
id: string
|
||||
reduction_type: string
|
||||
sub_type: string
|
||||
name: string
|
||||
description: string
|
||||
hazard_category: string
|
||||
examples: string[]
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
const REDUCTION_TYPES = {
|
||||
design: {
|
||||
label: '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" />
|
||||
@@ -35,10 +66,21 @@ const REDUCTION_TYPES = {
|
||||
),
|
||||
},
|
||||
protection: {
|
||||
label: 'Schutz',
|
||||
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" />
|
||||
@@ -46,10 +88,18 @@ const REDUCTION_TYPES = {
|
||||
),
|
||||
},
|
||||
information: {
|
||||
label: '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" />
|
||||
@@ -76,6 +126,281 @@ function StatusBadge({ status }: { status: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function HierarchyWarning({ onDismiss }: { onDismiss: () => void }) {
|
||||
return (
|
||||
<div className="bg-amber-50 border border-amber-300 rounded-xl p-4 flex items-start gap-3">
|
||||
<svg className="w-6 h-6 text-amber-600 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-amber-800">Hierarchie-Warnung: Massnahmen vom Typ "Information"</h4>
|
||||
<p className="text-sm text-amber-700 mt-1">
|
||||
Hinweismassnahmen (Stufe 3) duerfen <strong>nicht als Primaermassnahme</strong> akzeptiert werden, wenn konstruktive
|
||||
(Stufe 1) oder technische (Stufe 2) Massnahmen moeglich und zumutbar sind. Pruefen Sie, ob hoeherwertige
|
||||
Massnahmen ergaenzt werden koennen.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onDismiss} className="text-amber-400 hover:text-amber-600 transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MeasuresLibraryModal({
|
||||
measures,
|
||||
onSelect,
|
||||
onClose,
|
||||
filterType,
|
||||
}: {
|
||||
measures: ProtectiveMeasure[]
|
||||
onSelect: (measure: ProtectiveMeasure) => void
|
||||
onClose: () => void
|
||||
filterType?: string
|
||||
}) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedSubType, setSelectedSubType] = useState('')
|
||||
|
||||
const filtered = measures.filter((m) => {
|
||||
if (filterType && m.reduction_type !== filterType) return false
|
||||
if (selectedSubType && m.sub_type !== selectedSubType) return false
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
return m.name.toLowerCase().includes(q) || m.description.toLowerCase().includes(q)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const subTypes = [...new Set(measures.filter((m) => !filterType || m.reduction_type === filterType).map((m) => m.sub_type))].filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[80vh] flex flex-col">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Massnahmen-Bibliothek</h3>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Massnahme suchen..."
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
/>
|
||||
{subTypes.length > 1 && (
|
||||
<select
|
||||
value={selectedSubType}
|
||||
onChange={(e) => setSelectedSubType(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white text-sm"
|
||||
>
|
||||
<option value="">Alle Sub-Typen</option>
|
||||
{subTypes.map((st) => (
|
||||
<option key={st} value={st}>{st}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">{filtered.length} Massnahmen</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-3">
|
||||
{filtered.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-purple-300 hover:bg-purple-50/30 transition-colors cursor-pointer"
|
||||
onClick={() => onSelect(m)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-gray-400">{m.id}</span>
|
||||
{m.sub_type && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{m.sub_type}</span>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{m.name}</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">{m.description}</p>
|
||||
{m.examples && m.examples.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{m.examples.map((ex, i) => (
|
||||
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-600">
|
||||
{ex}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="ml-3 px-3 py-1.5 text-xs bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors flex-shrink-0">
|
||||
Uebernehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">Keine Massnahmen gefunden</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Suggest Measures Modal (Phase 5)
|
||||
// ============================================================================
|
||||
|
||||
function SuggestMeasuresModal({
|
||||
hazards,
|
||||
projectId,
|
||||
onAddMeasure,
|
||||
onClose,
|
||||
}: {
|
||||
hazards: Hazard[]
|
||||
projectId: string
|
||||
onAddMeasure: (title: string, description: string, reductionType: string, hazardId: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [selectedHazard, setSelectedHazard] = useState<string>('')
|
||||
const [suggested, setSuggested] = useState<SuggestedMeasure[]>([])
|
||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
|
||||
|
||||
const riskColors: Record<string, string> = {
|
||||
not_acceptable: 'border-red-400 bg-red-50',
|
||||
very_high: 'border-red-300 bg-red-50',
|
||||
critical: 'border-red-300 bg-red-50',
|
||||
high: 'border-orange-300 bg-orange-50',
|
||||
medium: 'border-yellow-300 bg-yellow-50',
|
||||
low: 'border-green-300 bg-green-50',
|
||||
}
|
||||
|
||||
async function handleSelectHazard(hazardId: string) {
|
||||
setSelectedHazard(hazardId)
|
||||
setSuggested([])
|
||||
if (!hazardId) return
|
||||
|
||||
setLoadingSuggestions(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/${hazardId}/suggest-measures`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setSuggested(json.suggested_measures || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to suggest measures:', err)
|
||||
} finally {
|
||||
setLoadingSuggestions(false)
|
||||
}
|
||||
}
|
||||
|
||||
const groupedByType = {
|
||||
design: suggested.filter(m => m.reduction_type === 'design'),
|
||||
protection: suggested.filter(m => m.reduction_type === 'protection'),
|
||||
information: suggested.filter(m => m.reduction_type === 'information'),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Massnahmen-Vorschlaege</h3>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Waehlen Sie eine Gefaehrdung, um passende Massnahmen vorgeschlagen zu bekommen.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hazards.map(h => (
|
||||
<button
|
||||
key={h.id}
|
||||
onClick={() => handleSelectHazard(h.id)}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
||||
selectedHazard === h.id
|
||||
? 'border-purple-400 bg-purple-50 text-purple-700 font-medium'
|
||||
: `${riskColors[h.risk_level] || 'border-gray-200 bg-white'} text-gray-700 hover:border-purple-300`
|
||||
}`}
|
||||
>
|
||||
{h.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{loadingSuggestions ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : suggested.length > 0 ? (
|
||||
<div className="space-y-6">
|
||||
{(['design', 'protection', 'information'] as const).map(type => {
|
||||
const items = groupedByType[type]
|
||||
if (items.length === 0) return null
|
||||
const config = REDUCTION_TYPES[type]
|
||||
return (
|
||||
<div key={type}>
|
||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-3`}>
|
||||
{config.icon}
|
||||
<span className="text-sm font-semibold">{config.label}</span>
|
||||
<span className="ml-auto text-sm font-bold">{items.length}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{items.map(m => (
|
||||
<div key={m.id} className="border border-gray-200 rounded-lg p-3 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-gray-400">{m.id}</span>
|
||||
{m.sub_type && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{m.sub_type}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{m.name}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{m.description}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAddMeasure(m.name, m.description, m.reduction_type, selectedHazard)}
|
||||
className="ml-3 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex-shrink-0"
|
||||
>
|
||||
Uebernehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : selectedHazard ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Keine Vorschlaege fuer diese Gefaehrdung gefunden.
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Waehlen Sie eine Gefaehrdung aus, um Vorschlaege zu erhalten.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MitigationFormData {
|
||||
title: string
|
||||
description: string
|
||||
@@ -88,11 +413,13 @@ function MitigationForm({
|
||||
onCancel,
|
||||
hazards,
|
||||
preselectedType,
|
||||
onOpenLibrary,
|
||||
}: {
|
||||
onSubmit: (data: MitigationFormData) => void
|
||||
onCancel: () => void
|
||||
hazards: Hazard[]
|
||||
preselectedType?: 'design' | 'protection' | 'information'
|
||||
onOpenLibrary: (type?: string) => void
|
||||
}) {
|
||||
const [formData, setFormData] = useState<MitigationFormData>({
|
||||
title: '',
|
||||
@@ -112,7 +439,15 @@ function MitigationForm({
|
||||
|
||||
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="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>
|
||||
@@ -132,9 +467,9 @@ function MitigationForm({
|
||||
onChange={(e) => setFormData({ ...formData, reduction_type: e.target.value as MitigationFormData['reduction_type'] })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||
>
|
||||
<option value="design">Design - Inhaerent sichere Konstruktion</option>
|
||||
<option value="protection">Schutz - Technische Schutzmassnahmen</option>
|
||||
<option value="information">Information - Hinweise und Schulungen</option>
|
||||
<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>
|
||||
@@ -201,7 +536,14 @@ function MitigationCard({
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title}</h4>
|
||||
<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 && (
|
||||
@@ -246,6 +588,12 @@ export default function MitigationsPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [preselectedType, setPreselectedType] = useState<'design' | 'protection' | 'information' | undefined>()
|
||||
const [hierarchyWarning, setHierarchyWarning] = useState<boolean>(false)
|
||||
const [showLibrary, setShowLibrary] = useState(false)
|
||||
const [libraryFilter, setLibraryFilter] = useState<string | undefined>()
|
||||
const [measures, setMeasures] = useState<ProtectiveMeasure[]>([])
|
||||
// Phase 5: Suggest measures
|
||||
const [showSuggest, setShowSuggest] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
@@ -259,11 +607,14 @@ export default function MitigationsPage() {
|
||||
])
|
||||
if (mitRes.ok) {
|
||||
const json = await mitRes.json()
|
||||
setMitigations(json.mitigations || json || [])
|
||||
const mits = json.mitigations || json || []
|
||||
setMitigations(mits)
|
||||
// Check hierarchy: if information-only measures exist without design/protection
|
||||
validateHierarchy(mits)
|
||||
}
|
||||
if (hazRes.ok) {
|
||||
const json = await hazRes.json()
|
||||
setHazards((json.hazards || json || []).map((h: Hazard) => ({ id: h.id, name: h.name, risk_level: h.risk_level })))
|
||||
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)
|
||||
@@ -272,6 +623,55 @@ export default function MitigationsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function validateHierarchy(mits: Mitigation[]) {
|
||||
if (mits.length === 0) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/validate-mitigation-hierarchy`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mitigations: mits.map((m) => ({
|
||||
reduction_type: m.reduction_type,
|
||||
linked_hazard_ids: m.linked_hazard_ids,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setHierarchyWarning(json.has_warning === true)
|
||||
}
|
||||
} catch {
|
||||
// Non-critical, ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMeasuresLibrary(type?: string) {
|
||||
try {
|
||||
const url = type
|
||||
? `/api/sdk/v1/iace/protective-measures-library?reduction_type=${type}`
|
||||
: '/api/sdk/v1/iace/protective-measures-library'
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setMeasures(json.protective_measures || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch measures library:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenLibrary(type?: string) {
|
||||
setLibraryFilter(type)
|
||||
fetchMeasuresLibrary(type)
|
||||
setShowLibrary(true)
|
||||
}
|
||||
|
||||
function handleSelectMeasure(measure: ProtectiveMeasure) {
|
||||
setShowLibrary(false)
|
||||
setShowForm(true)
|
||||
setPreselectedType(measure.reduction_type as 'design' | 'protection' | 'information')
|
||||
}
|
||||
|
||||
async function handleSubmit(data: MitigationFormData) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`, {
|
||||
@@ -289,6 +689,26 @@ export default function MitigationsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddSuggestedMeasure(title: string, description: string, reductionType: string, hazardId: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
reduction_type: reductionType,
|
||||
linked_hazard_ids: [hazardId],
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add suggested measure:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerify(id: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${id}/verify`, {
|
||||
@@ -341,23 +761,50 @@ 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, Schutz, Information.
|
||||
Risikominderung nach dem 3-Stufen-Verfahren: Design → Schutz → Information.
|
||||
</p>
|
||||
</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 className="flex items-center gap-3">
|
||||
{hazards.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowSuggest(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
Vorschlaege
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleOpenLibrary()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
Bibliothek
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setPreselectedType(undefined)
|
||||
setShowForm(true)
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Massnahme hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hierarchy Warning */}
|
||||
{hierarchyWarning && (
|
||||
<HierarchyWarning onDismiss={() => setHierarchyWarning(false)} />
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
{showForm && (
|
||||
<MitigationForm
|
||||
@@ -368,6 +815,27 @@ export default function MitigationsPage() {
|
||||
}}
|
||||
hazards={hazards}
|
||||
preselectedType={preselectedType}
|
||||
onOpenLibrary={handleOpenLibrary}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Measures Library Modal */}
|
||||
{showLibrary && (
|
||||
<MeasuresLibraryModal
|
||||
measures={measures}
|
||||
onSelect={handleSelectMeasure}
|
||||
onClose={() => setShowLibrary(false)}
|
||||
filterType={libraryFilter}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Suggest Measures Modal (Phase 5) */}
|
||||
{showSuggest && (
|
||||
<SuggestMeasuresModal
|
||||
hazards={hazards}
|
||||
projectId={projectId}
|
||||
onAddMeasure={handleAddSuggestedMeasure}
|
||||
onClose={() => setShowSuggest(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -378,7 +846,7 @@ export default function MitigationsPage() {
|
||||
const items = byType[type]
|
||||
return (
|
||||
<div key={type} className={`rounded-xl border ${config.color} p-4`}>
|
||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-4`}>
|
||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-3`}>
|
||||
{config.icon}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{config.label}</h3>
|
||||
@@ -387,6 +855,15 @@ export default function MitigationsPage() {
|
||||
<span className="ml-auto text-sm font-bold">{items.length}</span>
|
||||
</div>
|
||||
|
||||
{/* Sub-types overview */}
|
||||
<div className="mb-3 flex flex-wrap gap-1">
|
||||
{config.subTypes.map((st) => (
|
||||
<span key={st.value} className="text-xs px-1.5 py-0.5 rounded bg-white/60 text-gray-500 border border-gray-200/50">
|
||||
{st.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{items.map((m) => (
|
||||
<MitigationCard
|
||||
@@ -398,12 +875,23 @@ export default function MitigationsPage() {
|
||||
))}
|
||||
</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 className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => handleAddForType(type)}
|
||||
className="flex-1 py-2 text-sm text-gray-500 hover:text-purple-600 hover:bg-white rounded-lg border border-dashed border-gray-300 hover:border-purple-300 transition-colors"
|
||||
>
|
||||
+ Hinzufuegen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenLibrary(type)}
|
||||
className="py-2 px-3 text-sm text-gray-400 hover:text-purple-600 hover:bg-white rounded-lg border border-dashed border-gray-300 hover:border-purple-300 transition-colors"
|
||||
title="Aus Bibliothek waehlen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { TechFileEditor } from '@/components/sdk/iace/TechFileEditor'
|
||||
|
||||
interface TechFileSection {
|
||||
id: string
|
||||
@@ -67,6 +68,14 @@ 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 (
|
||||
@@ -87,7 +96,6 @@ function SectionViewer({
|
||||
onApprove: (id: string) => void
|
||||
onSave: (id: string, content: string) => void
|
||||
}) {
|
||||
const [editedContent, setEditedContent] = useState(section.content || '')
|
||||
const [editing, setEditing] = useState(false)
|
||||
|
||||
return (
|
||||
@@ -111,13 +119,10 @@ function SectionViewer({
|
||||
)}
|
||||
{editing && (
|
||||
<button
|
||||
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"
|
||||
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"
|
||||
>
|
||||
Speichern
|
||||
Fertig
|
||||
</button>
|
||||
)}
|
||||
{section.status !== 'approved' && section.content && !editing && (
|
||||
@@ -136,19 +141,19 @@ function SectionViewer({
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{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>
|
||||
{section.content ? (
|
||||
editing ? (
|
||||
<TechFileEditor
|
||||
content={section.content}
|
||||
onSave={(html) => onSave(section.id, html)}
|
||||
/>
|
||||
) : (
|
||||
<TechFileEditor
|
||||
content={section.content}
|
||||
onSave={() => {}}
|
||||
readOnly
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Kein Inhalt vorhanden. Klicken Sie "Generieren" um den Abschnitt zu erstellen.
|
||||
@@ -167,6 +172,21 @@ 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()
|
||||
@@ -236,18 +256,22 @@ export default function TechFilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportZip() {
|
||||
async function handleExport(format: string) {
|
||||
setExporting(true)
|
||||
setShowExportMenu(false)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/tech-file/export`, {
|
||||
method: 'POST',
|
||||
})
|
||||
const res = await fetch(
|
||||
`/api/sdk/v1/iace/projects/${projectId}/tech-file/export?format=${format}`,
|
||||
{ method: 'GET' }
|
||||
)
|
||||
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}.zip`
|
||||
a.download = `CE-Akte-${projectId}${extension}`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
@@ -284,25 +308,45 @@ export default function TechFilePage() {
|
||||
Sie alle erforderlichen Abschnitte.
|
||||
</p>
|
||||
</div>
|
||||
<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" />
|
||||
{/* 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" />
|
||||
</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>
|
||||
)}
|
||||
ZIP exportieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
|
||||
@@ -19,14 +19,25 @@ interface VerificationItem {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface SuggestedEvidence {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
method: string
|
||||
tags?: 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' },
|
||||
{ value: 'design_review', label: 'Design-Review', description: 'Systematische Pruefung der Konstruktionsunterlagen' },
|
||||
{ value: 'calculation', label: 'Berechnung', description: 'Rechnerischer Nachweis (FEM, Festigkeit, Thermik)' },
|
||||
{ value: 'test_report', label: 'Pruefbericht', description: 'Dokumentierter Test mit Messprotokoll' },
|
||||
{ value: 'validation', label: 'Validierung', description: 'Nachweis der Eignung unter realen Betriebsbedingungen' },
|
||||
{ value: 'electrical_test', label: 'Elektrische Pruefung', description: 'Isolationsmessung, Schutzleiter, Spannungsfestigkeit' },
|
||||
{ value: 'software_test', label: 'Software-Test', description: 'Unit-, Integrations- oder Systemtest der Steuerungssoftware' },
|
||||
{ value: 'penetration_test', label: 'Penetrationstest', description: 'Security-Test der Netzwerk- und Steuerungskomponenten' },
|
||||
{ value: 'acceptance_protocol', label: 'Abnahmeprotokoll', description: 'Formelle Abnahme mit Checkliste und Unterschrift' },
|
||||
{ value: 'user_test', label: 'Anwendertest', description: 'Pruefung durch Bediener unter realen Einsatzbedingungen' },
|
||||
{ value: 'documentation_release', label: 'Dokumentenfreigabe', description: 'Formelle Freigabe der technischen Dokumentation' },
|
||||
]
|
||||
|
||||
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
@@ -238,6 +249,130 @@ function CompleteModal({
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Suggest Evidence Modal (Phase 5)
|
||||
// ============================================================================
|
||||
|
||||
function SuggestEvidenceModal({
|
||||
mitigations,
|
||||
projectId,
|
||||
onAddEvidence,
|
||||
onClose,
|
||||
}: {
|
||||
mitigations: { id: string; title: string }[]
|
||||
projectId: string
|
||||
onAddEvidence: (title: string, description: string, method: string, mitigationId: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [selectedMitigation, setSelectedMitigation] = useState<string>('')
|
||||
const [suggested, setSuggested] = useState<SuggestedEvidence[]>([])
|
||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
|
||||
|
||||
async function handleSelectMitigation(mitigationId: string) {
|
||||
setSelectedMitigation(mitigationId)
|
||||
setSuggested([])
|
||||
if (!mitigationId) return
|
||||
|
||||
setLoadingSuggestions(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${mitigationId}/suggest-evidence`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setSuggested(json.suggested_evidence || [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to suggest evidence:', err)
|
||||
} finally {
|
||||
setLoadingSuggestions(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[85vh] flex flex-col">
|
||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Nachweise vorschlagen</h3>
|
||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Waehlen Sie eine Massnahme, um passende Nachweismethoden vorgeschlagen zu bekommen.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mitigations.map(m => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => handleSelectMitigation(m.id)}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
||||
selectedMitigation === m.id
|
||||
? 'border-purple-400 bg-purple-50 text-purple-700 font-medium'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:border-purple-300'
|
||||
}`}
|
||||
>
|
||||
{m.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{loadingSuggestions ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : suggested.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{suggested.map(ev => (
|
||||
<div key={ev.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-gray-400">{ev.id}</span>
|
||||
{ev.method && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-600">
|
||||
{VERIFICATION_METHODS.find(m => m.value === ev.method)?.label || ev.method}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{ev.name}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{ev.description}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAddEvidence(ev.name, ev.description, ev.method || 'test_report', selectedMitigation)}
|
||||
className="ml-3 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex-shrink-0"
|
||||
>
|
||||
Uebernehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : selectedMitigation ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Keine Vorschlaege fuer diese Massnahme gefunden.
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Waehlen Sie eine Massnahme aus, um Nachweise vorgeschlagen zu bekommen.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Page
|
||||
// ============================================================================
|
||||
|
||||
export default function VerificationPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
@@ -247,6 +382,8 @@ export default function VerificationPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [completingItem, setCompletingItem] = useState<VerificationItem | null>(null)
|
||||
// Phase 5: Suggest evidence
|
||||
const [showSuggest, setShowSuggest] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
@@ -294,6 +431,26 @@ export default function VerificationPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddSuggestedEvidence(title: string, description: string, method: string, mitigationId: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
method,
|
||||
linked_mitigation_id: mitigationId,
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
await fetchData()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to add suggested evidence:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleComplete(id: string, result: string, passed: boolean) {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}/complete`, {
|
||||
@@ -344,15 +501,28 @@ export default function VerificationPage() {
|
||||
Nachweisfuehrung fuer alle Schutzmassnahmen und Sicherheitsanforderungen.
|
||||
</p>
|
||||
</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 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>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
@@ -396,6 +566,16 @@ export default function VerificationPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Suggest Evidence Modal (Phase 5) */}
|
||||
{showSuggest && (
|
||||
<SuggestEvidenceModal
|
||||
mitigations={mitigations}
|
||||
projectId={projectId}
|
||||
onAddEvidence={handleAddSuggestedEvidence}
|
||||
onClose={() => setShowSuggest(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{items.length > 0 ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
@@ -469,12 +649,22 @@ export default function VerificationPage() {
|
||||
Definieren Sie Verifikationsschritte fuer Ihre Schutzmassnahmen.
|
||||
Jede Massnahme sollte durch mindestens eine Verifikation abgedeckt sein.
|
||||
</p>
|
||||
<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 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>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
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,
|
||||
@@ -15,7 +14,6 @@ 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,
|
||||
@@ -27,12 +25,18 @@ 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'
|
||||
type Tab = 'uebersicht' | 'editor' | 'generator' | 'export' | 'loeschkonzept'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: TagInput
|
||||
@@ -101,7 +105,6 @@ function TagInput({
|
||||
|
||||
export default function LoeschfristenPage() {
|
||||
const router = useRouter()
|
||||
const sdk = useSDK()
|
||||
|
||||
// ---- Core state ----
|
||||
const [tab, setTab] = useState<Tab>('uebersicht')
|
||||
@@ -121,15 +124,19 @@ 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)
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -184,6 +191,7 @@ 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,
|
||||
@@ -218,6 +226,7 @@ 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,
|
||||
@@ -247,6 +256,59 @@ 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
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -489,6 +551,7 @@ export default function LoeschfristenPage() {
|
||||
{ key: 'editor', label: 'Editor' },
|
||||
{ key: 'generator', label: 'Generator' },
|
||||
{ key: 'export', label: 'Export & Compliance' },
|
||||
{ key: 'loeschkonzept', label: 'Loeschkonzept' },
|
||||
]
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -1355,13 +1418,13 @@ export default function LoeschfristenPage() {
|
||||
Verarbeitungstaetigkeit aus Ihrem VVT.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{policy.linkedVvtIds && policy.linkedVvtIds.length > 0 && (
|
||||
{policy.linkedVVTActivityIds && policy.linkedVVTActivityIds.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.linkedVvtIds.map((vvtId: string) => {
|
||||
{policy.linkedVVTActivityIds.map((vvtId: string) => {
|
||||
const activity = vvtActivities.find(
|
||||
(a: any) => a.id === vvtId,
|
||||
)
|
||||
@@ -1376,8 +1439,8 @@ export default function LoeschfristenPage() {
|
||||
onClick={() =>
|
||||
updatePolicy(pid, (p) => ({
|
||||
...p,
|
||||
linkedVvtIds: (
|
||||
p.linkedVvtIds || []
|
||||
linkedVVTActivityIds: (
|
||||
p.linkedVVTActivityIds || []
|
||||
).filter((id: string) => id !== vvtId),
|
||||
}))
|
||||
}
|
||||
@@ -1396,11 +1459,11 @@ export default function LoeschfristenPage() {
|
||||
const val = e.target.value
|
||||
if (
|
||||
val &&
|
||||
!(policy.linkedVvtIds || []).includes(val)
|
||||
!(policy.linkedVVTActivityIds || []).includes(val)
|
||||
) {
|
||||
updatePolicy(pid, (p) => ({
|
||||
...p,
|
||||
linkedVvtIds: [...(p.linkedVvtIds || []), val],
|
||||
linkedVVTActivityIds: [...(p.linkedVVTActivityIds || []), val],
|
||||
}))
|
||||
}
|
||||
e.target.value = ''
|
||||
@@ -1413,7 +1476,7 @@ export default function LoeschfristenPage() {
|
||||
{vvtActivities
|
||||
.filter(
|
||||
(a: any) =>
|
||||
!(policy.linkedVvtIds || []).includes(a.id),
|
||||
!(policy.linkedVVTActivityIds || []).includes(a.id),
|
||||
)
|
||||
.map((a: any) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
@@ -1432,6 +1495,95 @@ 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">
|
||||
@@ -2278,6 +2430,316 @@ 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
|
||||
// ==========================================================================
|
||||
@@ -2317,6 +2779,7 @@ export default function LoeschfristenPage() {
|
||||
{tab === 'editor' && renderEditor()}
|
||||
{tab === 'generator' && renderGenerator()}
|
||||
{tab === 'export' && renderExport()}
|
||||
{tab === 'loeschkonzept' && renderLoeschkonzept()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,35 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useMemo } 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
|
||||
// Types (local only — Obligation imported from obligations-compliance.ts)
|
||||
// =============================================================================
|
||||
|
||||
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
|
||||
@@ -50,6 +35,7 @@ interface ObligationFormData {
|
||||
priority: string
|
||||
responsible: string
|
||||
linked_systems: string
|
||||
linked_vendor_ids: string
|
||||
notes: string
|
||||
}
|
||||
|
||||
@@ -63,11 +49,26 @@ 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
|
||||
// =============================================================================
|
||||
@@ -262,6 +263,18 @@ 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
|
||||
@@ -365,6 +378,19 @@ 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>
|
||||
@@ -559,9 +585,15 @@ 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)
|
||||
@@ -613,6 +645,7 @@ 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,
|
||||
}),
|
||||
})
|
||||
@@ -634,12 +667,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())
|
||||
@@ -656,7 +689,6 @@ 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(() => {})
|
||||
}
|
||||
|
||||
@@ -672,7 +704,6 @@ 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 || []
|
||||
@@ -682,7 +713,6 @@ 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',
|
||||
@@ -702,11 +732,9 @@ 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>) => ({
|
||||
@@ -738,11 +766,6 @@ 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()
|
||||
@@ -751,91 +774,12 @@ export default function ObligationsPage() {
|
||||
return true
|
||||
})
|
||||
|
||||
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>
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab Content Renderers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderUebersichtTab = () => (
|
||||
<>
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-700">{error}</div>
|
||||
@@ -872,12 +816,13 @@ export default function ObligationsPage() {
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 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>
|
||||
@@ -901,9 +846,26 @@ export default function ObligationsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gap Analysis View */}
|
||||
{showGapAnalysis && (
|
||||
<GapAnalysisView />
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Regulation Filter Chips */}
|
||||
@@ -970,7 +932,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 "Pflicht hinzufuegen", um die erste Compliance-Pflicht zu erfassen.
|
||||
Klicken Sie auf "Pflicht hinzufuegen", um die erste Compliance-Pflicht zu erfassen.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
@@ -982,6 +944,220 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
496
admin-compliance/app/sdk/payment-compliance/page.tsx
Normal file
496
admin-compliance/app/sdk/payment-compliance/page.tsx
Normal file
@@ -0,0 +1,496 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
interface PaymentControl {
|
||||
control_id: string
|
||||
domain: string
|
||||
title: string
|
||||
objective: string
|
||||
check_target: string
|
||||
evidence: string[]
|
||||
automation: string
|
||||
}
|
||||
|
||||
interface PaymentDomain {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface Assessment {
|
||||
id: string
|
||||
project_name: string
|
||||
tender_reference: string
|
||||
customer_name: string
|
||||
system_type: string
|
||||
total_controls: number
|
||||
controls_passed: number
|
||||
controls_failed: number
|
||||
controls_partial: number
|
||||
controls_not_applicable: number
|
||||
controls_not_checked: number
|
||||
compliance_score: number
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface TenderAnalysis {
|
||||
id: string
|
||||
file_name: string
|
||||
file_size: number
|
||||
project_name: string
|
||||
customer_name: string
|
||||
status: string
|
||||
total_requirements: number
|
||||
matched_count: number
|
||||
unmatched_count: number
|
||||
partial_count: number
|
||||
requirements?: Array<{ req_id: string; text: string; obligation_level: string; technical_domain: string; confidence: number }>
|
||||
match_results?: Array<{ req_id: string; req_text: string; verdict: string; matched_controls: Array<{ control_id: string; title: string; relevance: number }>; gap_description?: string }>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const AUTOMATION_STYLES: Record<string, { bg: string; text: string }> = {
|
||||
high: { bg: 'bg-green-100', text: 'text-green-700' },
|
||||
medium: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
|
||||
partial: { bg: 'bg-orange-100', text: 'text-orange-700' },
|
||||
low: { bg: 'bg-red-100', text: 'text-red-700' },
|
||||
}
|
||||
|
||||
const TARGET_ICONS: Record<string, string> = {
|
||||
code: '💻', system: '🖥️', config: '⚙️', process: '📋',
|
||||
repository: '📦', certificate: '📜',
|
||||
}
|
||||
|
||||
export default function PaymentCompliancePage() {
|
||||
const [controls, setControls] = useState<PaymentControl[]>([])
|
||||
const [domains, setDomains] = useState<PaymentDomain[]>([])
|
||||
const [assessments, setAssessments] = useState<Assessment[]>([])
|
||||
const [tenderAnalyses, setTenderAnalyses] = useState<TenderAnalysis[]>([])
|
||||
const [selectedTender, setSelectedTender] = useState<TenderAnalysis | null>(null)
|
||||
const [selectedDomain, setSelectedDomain] = useState<string>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [tab, setTab] = useState<'controls' | 'assessments' | 'tender'>('controls')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [showNewAssessment, setShowNewAssessment] = useState(false)
|
||||
const [newProject, setNewProject] = useState({ project_name: '', tender_reference: '', customer_name: '', system_type: 'full_stack' })
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const [ctrlResp, assessResp, tenderResp] = await Promise.all([
|
||||
fetch('/api/sdk/v1/payment-compliance?endpoint=controls'),
|
||||
fetch('/api/sdk/v1/payment-compliance?endpoint=assessments'),
|
||||
fetch('/api/sdk/v1/payment-compliance/tender'),
|
||||
])
|
||||
if (ctrlResp.ok) {
|
||||
const data = await ctrlResp.json()
|
||||
setControls(data.controls || [])
|
||||
setDomains(data.domains || [])
|
||||
}
|
||||
if (assessResp.ok) {
|
||||
const data = await assessResp.json()
|
||||
setAssessments(data.assessments || [])
|
||||
}
|
||||
if (tenderResp.ok) {
|
||||
const data = await tenderResp.json()
|
||||
setTenderAnalyses(data.analyses || [])
|
||||
}
|
||||
} catch {}
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
async function handleTenderUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('project_name', file.name.replace(/\.[^.]+$/, ''))
|
||||
const resp = await fetch('/api/sdk/v1/payment-compliance/tender', { method: 'POST', body: formData })
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
// Auto-start extraction + matching
|
||||
setProcessing(true)
|
||||
const extractResp = await fetch(`/api/sdk/v1/payment-compliance/tender/${data.id}?action=extract`, { method: 'POST' })
|
||||
if (extractResp.ok) {
|
||||
await fetch(`/api/sdk/v1/payment-compliance/tender/${data.id}?action=match`, { method: 'POST' })
|
||||
}
|
||||
// Reload and show result
|
||||
const detailResp = await fetch(`/api/sdk/v1/payment-compliance/tender/${data.id}`)
|
||||
if (detailResp.ok) {
|
||||
const detail = await detailResp.json()
|
||||
setSelectedTender(detail)
|
||||
}
|
||||
loadData()
|
||||
}
|
||||
} catch {} finally {
|
||||
setUploading(false)
|
||||
setProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewTender(id: string) {
|
||||
const resp = await fetch(`/api/sdk/v1/payment-compliance/tender/${id}`)
|
||||
if (resp.ok) {
|
||||
setSelectedTender(await resp.json())
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateAssessment() {
|
||||
const resp = await fetch('/api/sdk/v1/payment-compliance', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newProject),
|
||||
})
|
||||
if (resp.ok) {
|
||||
setShowNewAssessment(false)
|
||||
setNewProject({ project_name: '', tender_reference: '', customer_name: '', system_type: 'full_stack' })
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
const filteredControls = selectedDomain === 'all'
|
||||
? controls
|
||||
: controls.filter(c => c.domain === selectedDomain)
|
||||
|
||||
const domainStats = domains.map(d => ({
|
||||
...d,
|
||||
count: controls.filter(c => c.domain === d.id).length,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Payment Terminal Compliance</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Technische Pruefbibliothek fuer Zahlungssysteme — {controls.length} Controls in {domains.length} Domaenen
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setTab('controls')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === 'controls' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700'}`}>
|
||||
Controls ({controls.length})
|
||||
</button>
|
||||
<button onClick={() => setTab('assessments')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === 'assessments' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700'}`}>
|
||||
Assessments ({assessments.length})
|
||||
</button>
|
||||
<button onClick={() => setTab('tender')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === 'tender' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700'}`}>
|
||||
Ausschreibung ({tenderAnalyses.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-xl text-sm text-blue-800">
|
||||
<div className="font-semibold mb-2">Wie funktioniert Payment Terminal Compliance?</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="font-medium mb-1">1. Controls durchsuchen</div>
|
||||
<p className="text-xs text-blue-700">Unsere Bibliothek enthaelt {controls.length} technische Pruefregeln fuer Zahlungssysteme — von Transaktionslogik ueber Kryptographie bis ZVT/OPI-Protokollverhalten. Jeder Control definiert was geprueft wird und welche Evidenz noetig ist.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium mb-1">2. Assessment erstellen</div>
|
||||
<p className="text-xs text-blue-700">Ein Assessment ist eine projektbezogene Pruefung — z.B. fuer eine bestimmte Ausschreibung oder einen Kunden. Sie ordnet jedem Control einen Status zu: bestanden, fehlgeschlagen, teilweise oder nicht anwendbar.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium mb-1">3. Ausschreibung analysieren</div>
|
||||
<p className="text-xs text-blue-700">Laden Sie ein Ausschreibungsdokument hoch. Die KI extrahiert automatisch die Anforderungen und matcht sie gegen unsere Controls. Ergebnis: Welche Anforderungen sind abgedeckt und wo gibt es Luecken.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Lade...</div>
|
||||
) : tab === 'controls' ? (
|
||||
<>
|
||||
{/* Domain Filter */}
|
||||
<div className="grid grid-cols-5 gap-3 mb-6">
|
||||
<button onClick={() => setSelectedDomain('all')}
|
||||
className={`p-3 rounded-xl border text-center ${selectedDomain === 'all' ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-purple-300'}`}>
|
||||
<div className="text-lg font-bold text-purple-700">{controls.length}</div>
|
||||
<div className="text-xs text-gray-500">Alle</div>
|
||||
</button>
|
||||
{domainStats.map(d => (
|
||||
<button key={d.id} onClick={() => setSelectedDomain(d.id)}
|
||||
className={`p-3 rounded-xl border text-center ${selectedDomain === d.id ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-purple-300'}`}>
|
||||
<div className="text-lg font-bold text-gray-900">{d.count}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{d.id}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Domain Description */}
|
||||
{selectedDomain !== 'all' && (
|
||||
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-800">
|
||||
<strong>{domains.find(d => d.id === selectedDomain)?.name}:</strong>{' '}
|
||||
{domains.find(d => d.id === selectedDomain)?.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls List */}
|
||||
<div className="space-y-3">
|
||||
{filteredControls.map(ctrl => {
|
||||
const autoStyle = AUTOMATION_STYLES[ctrl.automation] || AUTOMATION_STYLES.low
|
||||
return (
|
||||
<div key={ctrl.control_id} className="bg-white rounded-xl border border-gray-200 p-4 hover:border-purple-300 transition-all">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-2 py-0.5 rounded">{ctrl.control_id}</span>
|
||||
<span className="text-xs text-gray-400">{TARGET_ICONS[ctrl.check_target] || '🔍'} {ctrl.check_target}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${autoStyle.bg} ${autoStyle.text}`}>
|
||||
{ctrl.automation}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">{ctrl.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">{ctrl.objective}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 mt-2">
|
||||
{ctrl.evidence.map(ev => (
|
||||
<span key={ev} className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">{ev}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : tab === 'assessments' ? (
|
||||
<>
|
||||
{/* Assessments Tab */}
|
||||
<div className="mb-4">
|
||||
<button onClick={() => setShowNewAssessment(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||
+ Neues Assessment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showNewAssessment && (
|
||||
<div className="mb-6 p-6 bg-white rounded-xl border border-purple-200">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Neues Payment Compliance Assessment</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Projektname *</label>
|
||||
<input value={newProject.project_name} onChange={e => setNewProject(p => ({ ...p, project_name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" placeholder="z.B. Ausschreibung Muenchen 2026" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ausschreibungs-Referenz</label>
|
||||
<input value={newProject.tender_reference} onChange={e => setNewProject(p => ({ ...p, tender_reference: e.target.value }))}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" placeholder="z.B. 2026-PAY-001" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kunde</label>
|
||||
<input value={newProject.customer_name} onChange={e => setNewProject(p => ({ ...p, customer_name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" placeholder="z.B. Stadt Muenchen" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Systemtyp</label>
|
||||
<select value={newProject.system_type} onChange={e => setNewProject(p => ({ ...p, system_type: e.target.value }))}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500">
|
||||
<option value="full_stack">Full Stack (Terminal + Backend)</option>
|
||||
<option value="terminal">Nur Terminal</option>
|
||||
<option value="backend">Nur Backend</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button onClick={handleCreateAssessment} disabled={!newProject.project_name}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">Erstellen</button>
|
||||
<button onClick={() => setShowNewAssessment(false)}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assessments.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<p className="text-lg mb-2">Noch keine Assessments</p>
|
||||
<p className="text-sm">Erstelle ein neues Assessment fuer eine Ausschreibung.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{assessments.map(a => (
|
||||
<div key={a.id} className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{a.project_name}</h3>
|
||||
<div className="text-sm text-gray-500">
|
||||
{a.customer_name && <span>{a.customer_name} · </span>}
|
||||
{a.tender_reference && <span>Ref: {a.tender_reference} · </span>}
|
||||
<span>{new Date(a.created_at).toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
a.status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||
a.status === 'in_progress' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
}`}>{a.status}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
<div className="text-center p-2 bg-gray-50 rounded">
|
||||
<div className="text-lg font-bold">{a.total_controls}</div>
|
||||
<div className="text-xs text-gray-500">Total</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-green-50 rounded">
|
||||
<div className="text-lg font-bold text-green-700">{a.controls_passed}</div>
|
||||
<div className="text-xs text-gray-500">Passed</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-red-50 rounded">
|
||||
<div className="text-lg font-bold text-red-700">{a.controls_failed}</div>
|
||||
<div className="text-xs text-gray-500">Failed</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-yellow-50 rounded">
|
||||
<div className="text-lg font-bold text-yellow-700">{a.controls_partial}</div>
|
||||
<div className="text-xs text-gray-500">Partial</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-gray-50 rounded">
|
||||
<div className="text-lg font-bold text-gray-400">{a.controls_not_applicable}</div>
|
||||
<div className="text-xs text-gray-500">N/A</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-gray-50 rounded">
|
||||
<div className="text-lg font-bold text-gray-400">{a.controls_not_checked}</div>
|
||||
<div className="text-xs text-gray-500">Offen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : tab === 'tender' ? (
|
||||
<>
|
||||
{/* Tender Analysis Tab */}
|
||||
<div className="mb-6 p-6 bg-white rounded-xl border-2 border-dashed border-purple-300 text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Ausschreibung analysieren</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Laden Sie ein Ausschreibungsdokument hoch. Die KI extrahiert automatisch alle Anforderungen und matcht sie gegen die Control-Bibliothek.
|
||||
</p>
|
||||
<label className="inline-block px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 cursor-pointer">
|
||||
{uploading ? 'Hochladen...' : processing ? 'Analysiere...' : 'PDF / Dokument hochladen'}
|
||||
<input type="file" className="hidden" accept=".pdf,.txt,.doc,.docx" onChange={handleTenderUpload} disabled={uploading || processing} />
|
||||
</label>
|
||||
<p className="text-xs text-gray-400 mt-2">PDF, TXT oder Word. Max 50 MB. Dokument wird nur fuer diese Analyse verwendet.</p>
|
||||
</div>
|
||||
|
||||
{/* Selected Tender Detail */}
|
||||
{selectedTender && (
|
||||
<div className="mb-6 p-6 bg-white rounded-xl border border-purple-200">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{selectedTender.project_name}</h3>
|
||||
<p className="text-sm text-gray-500">{selectedTender.file_name} — {selectedTender.status}</p>
|
||||
</div>
|
||||
<button onClick={() => setSelectedTender(null)} className="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-3 mb-6">
|
||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-2xl font-bold">{selectedTender.total_requirements}</div>
|
||||
<div className="text-xs text-gray-500">Anforderungen</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-green-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-700">{selectedTender.matched_count}</div>
|
||||
<div className="text-xs text-gray-500">Abgedeckt</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-yellow-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-yellow-700">{selectedTender.partial_count}</div>
|
||||
<div className="text-xs text-gray-500">Teilweise</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-red-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-red-700">{selectedTender.unmatched_count}</div>
|
||||
<div className="text-xs text-gray-500">Luecken</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Match Results */}
|
||||
{selectedTender.match_results && selectedTender.match_results.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-gray-900">Requirement → Control Matching</h4>
|
||||
{selectedTender.match_results.map((mr, idx) => (
|
||||
<div key={idx} className={`p-4 rounded-lg border ${
|
||||
mr.verdict === 'matched' ? 'border-green-200 bg-green-50' :
|
||||
mr.verdict === 'partial' ? 'border-yellow-200 bg-yellow-50' :
|
||||
'border-red-200 bg-red-50'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono bg-white px-2 py-0.5 rounded border">{mr.req_id}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
mr.verdict === 'matched' ? 'bg-green-200 text-green-800' :
|
||||
mr.verdict === 'partial' ? 'bg-yellow-200 text-yellow-800' :
|
||||
'bg-red-200 text-red-800'
|
||||
}`}>
|
||||
{mr.verdict === 'matched' ? 'Abgedeckt' : mr.verdict === 'partial' ? 'Teilweise' : 'Luecke'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-900">{mr.req_text}</p>
|
||||
</div>
|
||||
</div>
|
||||
{mr.matched_controls && mr.matched_controls.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{mr.matched_controls.map(mc => (
|
||||
<span key={mc.control_id} className="text-xs bg-white border px-2 py-0.5 rounded">
|
||||
{mc.control_id} ({Math.round(mc.relevance * 100)}%)
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{mr.gap_description && (
|
||||
<p className="text-xs text-orange-700 mt-2">{mr.gap_description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Previous Analyses */}
|
||||
{tenderAnalyses.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-3">Bisherige Analysen</h4>
|
||||
<div className="space-y-3">
|
||||
{tenderAnalyses.map(ta => (
|
||||
<button key={ta.id} onClick={() => handleViewTender(ta.id)}
|
||||
className="w-full text-left bg-white rounded-xl border border-gray-200 p-4 hover:border-purple-300 transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{ta.project_name}</h3>
|
||||
<p className="text-xs text-gray-500">{ta.file_name} — {new Date(ta.created_at).toLocaleDateString('de-DE')}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">{ta.matched_count} matched</span>
|
||||
{ta.unmatched_count > 0 && (
|
||||
<span className="text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded-full">{ta.unmatched_count} gaps</span>
|
||||
)}
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
ta.status === 'matched' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'
|
||||
}`}>{ta.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1383
admin-compliance/app/sdk/process-tasks/page.tsx
Normal file
1383
admin-compliance/app/sdk/process-tasks/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -103,7 +103,7 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
|
||||
checkpointType: 'REQUIRED',
|
||||
checkpointReviewer: 'NONE',
|
||||
description: 'Erfassung aller Stammdaten des Unternehmens als Grundlage fuer die Compliance-Analyse.',
|
||||
descriptionLong: 'Hier werden alle relevanten Unternehmensdaten erfasst: Firmenname, Rechtsform, Branche, Mitarbeiterzahl, Standorte, Datenschutzbeauftragter und Verantwortlicher. Diese Daten bilden die Basis fuer alle nachfolgenden Compliance-Schritte, da sie bestimmen, welche Regulierungen anwendbar sind (z.B. DSGVO, NIS2, AI Act). Ohne ein vollstaendiges Unternehmensprofil koennen keine weiteren Schritte durchgefuehrt werden.',
|
||||
descriptionLong: 'Hier werden alle relevanten Unternehmensdaten erfasst: Firmenname, Rechtsform, Branche, Mitarbeiterzahl, Standorte, Datenschutzbeauftragter und Verantwortlicher. Diese Daten bilden die Basis fuer alle nachfolgenden Compliance-Schritte, da sie bestimmen, welche Regulierungen anwendbar sind (z.B. DSGVO, NIS2, AI Act). Nach Abschluss zeigt eine Summary-Seite alle erfassten Daten auf einen Blick. Ohne ein vollstaendiges Unternehmensprofil koennen keine weiteren Schritte durchgefuehrt werden.',
|
||||
inputs: [],
|
||||
outputs: ['companyProfile', 'complianceScope'],
|
||||
prerequisiteSteps: [],
|
||||
@@ -123,9 +123,9 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
|
||||
checkpointType: 'REQUIRED',
|
||||
checkpointReviewer: 'NONE',
|
||||
description: 'Bestimmung der Compliance-Tiefe und des Umfangs basierend auf Unternehmensprofil.',
|
||||
descriptionLong: 'Basierend auf dem Unternehmensprofil wird automatisch ermittelt, wie tiefgehend die Compliance-Analyse sein muss. Kleine Unternehmen mit wenig Datenverarbeitung erhalten eine "BASIS"-Tiefe, waehrend grosse Unternehmen mit sensiblen Daten oder KI-Systemen eine "ERWEITERT" oder "VOLLSTAENDIG"-Tiefe erhalten. Der Compliance-Scope bestimmt, welche Module aktiviert werden und wie detailliert die Dokumentation sein muss.',
|
||||
descriptionLong: 'Basierend auf dem Unternehmensprofil wird automatisch ermittelt, wie tiefgehend die Compliance-Analyse sein muss. Kleine Unternehmen mit wenig Datenverarbeitung erhalten eine "BASIS"-Tiefe, waehrend grosse Unternehmen mit sensiblen Daten oder KI-Systemen eine "ERWEITERT" oder "VOLLSTAENDIG"-Tiefe erhalten. Der Compliance-Scope bestimmt, welche Module aktiviert werden und wie detailliert die Dokumentation sein muss. Zusaetzlich werden anwendbare Regulierungen (DSGVO, AI Act, NIS2 etc.) und zustaendige Aufsichtsbehoerden automatisch abgeleitet.',
|
||||
inputs: ['companyProfile'],
|
||||
outputs: ['complianceDepthLevel'],
|
||||
outputs: ['complianceDepthLevel', 'applicableRegulations', 'supervisoryAuthorities'],
|
||||
prerequisiteSteps: ['company-profile'],
|
||||
dbTables: ['sdk_states'],
|
||||
dbMode: 'read/write',
|
||||
@@ -142,8 +142,8 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
|
||||
checkpointId: 'CP-UC',
|
||||
checkpointType: 'REQUIRED',
|
||||
checkpointReviewer: 'NONE',
|
||||
description: 'Systematische Erfassung aller Datenverarbeitungs- und KI-Anwendungsfaelle.',
|
||||
descriptionLong: 'In diesem Schritt werden alle konkreten Anwendungsfaelle erfasst, in denen das Unternehmen personenbezogene Daten verarbeitet oder KI-Systeme einsetzt. Fuer jeden Use Case wird ermittelt: Welche Daten werden verarbeitet? Welche Personen sind betroffen (Kunden, Mitarbeiter, Schueler)? Welche Technologien kommen zum Einsatz? Die RAG-Collection bp_compliance_ce wird verwendet, um relevante CE-Regulierungen automatisch den Use Cases zuzuordnen (UCCA - Use Case Compliance Assessment).',
|
||||
description: 'Systematische Erfassung aller Datenverarbeitungs- und KI-Anwendungsfaelle ueber einen 8-Schritte-Wizard mit Kachel-Auswahl. Inkl. BetrVG-Mitbestimmungspruefung und Betriebsrats-Konflikt-Score.',
|
||||
descriptionLong: 'In einem 8-Schritte-Wizard werden alle Use Cases erfasst: (1) Grundlegendes — Titel, Beschreibung, KI-Kategorie (21 Kacheln), Branche wird automatisch aus dem Profil abgeleitet. (2) Datenkategorien — ~60 Kategorien in 10 Gruppen als Kacheln (inkl. Art. 9 hervorgehoben). (3) Verarbeitungszweck — 16 Zweck-Kacheln, Rechtsgrundlage wird vom SDK automatisch ermittelt. (4) Automatisierungsgrad + BetrVG — assistiv/teilautomatisiert/vollautomatisiert, plus 3 BetrVG-Toggles: Ueberwachungseignung, HR-Entscheidungsunterstuetzung, BR-Konsultation. Das SDK berechnet daraus einen Betriebsrats-Konflikt-Score (0-100) und leitet BetrVG-Pflichten ab (§87, §90, §94, §95). (5) Hosting & Modell — Provider, Region, Modellnutzung (Inferenz/RAG/Fine-Tuning/Training). (6) Datentransfer — Transferziele und Schutzmechanismen. (7) Datenhaltung — Aufbewahrungsfristen. (8) Vertraege — vorhandene Compliance-Dokumente. Die RAG-Collection bp_compliance_ce wird verwendet, um relevante CE-Regulierungen automatisch den Use Cases zuzuordnen (UCCA). Die Collection bp_compliance_datenschutz enthaelt 14 BAG-Urteile zu IT-Mitbestimmung (M365, SAP, SaaS, Video).',
|
||||
legalBasis: 'Art. 30 DSGVO (Verzeichnis von Verarbeitungstaetigkeiten)',
|
||||
inputs: ['companyProfile'],
|
||||
outputs: ['useCases'],
|
||||
@@ -155,6 +155,27 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
|
||||
isOptional: false,
|
||||
url: '/sdk/use-cases',
|
||||
},
|
||||
{
|
||||
id: 'ai-registration',
|
||||
name: 'EU AI Database Registrierung',
|
||||
nameShort: 'EU-Reg',
|
||||
package: 'vorbereitung',
|
||||
seq: 350,
|
||||
checkpointId: 'CP-REG',
|
||||
checkpointType: 'CONDITIONAL',
|
||||
checkpointReviewer: 'NONE',
|
||||
description: 'Registrierung von Hochrisiko-KI-Systemen in der EU AI Database gemaess Art. 49 KI-Verordnung.',
|
||||
descriptionLong: 'Fuer Hochrisiko-KI-Systeme (Annex III) ist eine Registrierung in der EU AI Database Pflicht. Ein 6-Schritte-Wizard fuehrt durch den Prozess: (1) Anbieter-Daten — Name, Rechtsform, Adresse, EU-Repraesentant. (2) System-Details — Name, Version, Beschreibung, Einsatzzweck (vorausgefuellt aus UCCA Assessment). (3) Klassifikation — Risikoklasse und Annex III Kategorie (aus Decision Tree). (4) Konformitaet — CE-Kennzeichnung, Notified Body. (5) Trainingsdaten — Zusammenfassung der Datenquellen (Art. 10). (6) Pruefung + Export — JSON-Download fuer EU-Datenbank-Submission. Der Status-Workflow ist: Entwurf → Bereit → Eingereicht → Registriert.',
|
||||
legalBasis: 'Art. 49 KI-Verordnung (EU) 2024/1689',
|
||||
inputs: ['useCases', 'companyProfile'],
|
||||
outputs: ['euRegistration'],
|
||||
prerequisiteSteps: ['use-case-assessment'],
|
||||
dbTables: ['ai_system_registrations'],
|
||||
dbMode: 'read/write',
|
||||
ragCollections: [],
|
||||
isOptional: true,
|
||||
url: '/sdk/ai-registration',
|
||||
},
|
||||
{
|
||||
id: 'import',
|
||||
name: 'Dokument-Import',
|
||||
@@ -399,9 +420,9 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
|
||||
checkpointType: 'REQUIRED',
|
||||
checkpointReviewer: 'NONE',
|
||||
description: 'Zusammenfassung aller gesetzlichen Pflichten aus DSGVO, AI Act, NIS2.',
|
||||
descriptionLong: 'Die Pflichtenuebersicht konsolidiert alle gesetzlichen Pflichten, die sich aus den Requirements, der AI-Act-Klassifizierung und den aktivierten Modulen ergeben. Fuer jede Pflicht wird angegeben: Welches Gesetz (DSGVO, AI Act, NIS2), welcher Artikel, welche Frist, wer verantwortlich ist und welche Massnahmen erforderlich sind. Die RAG-Collection bp_compliance_recht liefert aktuelle Pflichtentexte und Auslegungshinweise.',
|
||||
descriptionLong: 'Die Pflichtenuebersicht konsolidiert alle gesetzlichen Pflichten, die sich aus den Requirements, der AI-Act-Klassifizierung und den aktivierten Modulen ergeben. Wenn applicableRegulations aus dem Scope-Profiling vorliegen, werden diese direkt als Vorfilter verwendet. Fuer jede Pflicht wird angegeben: Welches Gesetz (DSGVO, AI Act, NIS2), welcher Artikel, welche Frist, wer verantwortlich ist und welche Massnahmen erforderlich sind. Die RAG-Collection bp_compliance_recht liefert aktuelle Pflichtentexte und Auslegungshinweise.',
|
||||
legalBasis: 'Art. 5 Abs. 2 DSGVO, Art. 9 AI Act',
|
||||
inputs: ['requirements', 'aiActClassification', 'modules'],
|
||||
inputs: ['requirements', 'aiActClassification', 'modules', 'applicableRegulations'],
|
||||
outputs: ['obligationsOverview'],
|
||||
prerequisiteSteps: ['audit-report'],
|
||||
dbTables: ['compliance_obligations'],
|
||||
@@ -672,19 +693,19 @@ export const SDK_FLOW_STEPS: SDKFlowStep[] = [
|
||||
id: 'vendor-compliance',
|
||||
name: 'Vendor Compliance',
|
||||
nameShort: 'Vendor',
|
||||
package: 'betrieb',
|
||||
seq: 4200,
|
||||
package: 'dokumentation',
|
||||
seq: 2500,
|
||||
checkpointId: 'CP-VEND',
|
||||
checkpointType: 'REQUIRED',
|
||||
checkpointReviewer: 'NONE',
|
||||
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.',
|
||||
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.',
|
||||
legalBasis: 'Art. 28 DSGVO (Auftragsverarbeiter), Art. 44-49 (Drittlandtransfer)',
|
||||
inputs: ['modules', 'vvt'],
|
||||
outputs: ['vendorAssessments'],
|
||||
prerequisiteSteps: ['escalations'],
|
||||
dbTables: [],
|
||||
dbMode: 'none',
|
||||
outputs: ['vendorAssessments', 'vendorControlInstances'],
|
||||
prerequisiteSteps: ['vvt'],
|
||||
dbTables: ['vendor_vendors', 'vendor_contracts', 'vendor_findings', 'vendor_control_instances', 'compliance_templates'],
|
||||
dbMode: 'read/write',
|
||||
ragCollections: ['bp_compliance_recht'],
|
||||
ragPurpose: 'AVV-Vorlagen und Pruefkataloge',
|
||||
isOptional: false,
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react'
|
||||
import React, { useState, useCallback, useMemo, useEffect } 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 } from '@/components/sdk/tom-dashboard'
|
||||
import { TOMOverviewTab, TOMEditorTab, TOMGapExportTab, TOMDocumentTab } from '@/components/sdk/tom-dashboard'
|
||||
import { runTOMComplianceCheck, type TOMComplianceCheckResult } from '@/lib/sdk/tom-compliance'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type Tab = 'uebersicht' | 'editor' | 'generator' | 'gap-export'
|
||||
type Tab = 'uebersicht' | 'editor' | 'generator' | 'gap-export' | 'tom-dokument'
|
||||
|
||||
interface TabDefinition {
|
||||
key: Tab
|
||||
@@ -24,6 +25,7 @@ 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' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
@@ -33,7 +35,7 @@ const TABS: TabDefinition[] = [
|
||||
export default function TOMPage() {
|
||||
const router = useRouter()
|
||||
const sdk = useSDK()
|
||||
const { state, dispatch, bulkUpdateTOMs, runGapAnalysis } = useTOMGenerator()
|
||||
const { state, dispatch, runGapAnalysis } = useTOMGenerator()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local state
|
||||
@@ -41,6 +43,58 @@ 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
|
||||
@@ -316,6 +370,17 @@ export default function TOMPage() {
|
||||
/>
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab 5 – TOM-Dokument
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const renderTOMDokument = () => (
|
||||
<TOMDocumentTab
|
||||
state={state}
|
||||
complianceResult={complianceResult}
|
||||
/>
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab content router
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -330,6 +395,8 @@ export default function TOMPage() {
|
||||
return renderGenerator()
|
||||
case 'gap-export':
|
||||
return renderGapExport()
|
||||
case 'tom-dokument':
|
||||
return renderTOMDokument()
|
||||
default:
|
||||
return renderUebersicht()
|
||||
}
|
||||
@@ -351,6 +418,60 @@ 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 →
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
560
admin-compliance/app/sdk/training/learner/page.tsx
Normal file
560
admin-compliance/app/sdk/training/learner/page.tsx
Normal file
@@ -0,0 +1,560 @@
|
||||
'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 "Meine Schulungen"
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -9,14 +9,18 @@ import {
|
||||
createModule, updateModule, deleteModule,
|
||||
deleteMatrixEntry, setMatrixEntry,
|
||||
startAssignment, completeAssignment, updateAssignment,
|
||||
listBlockConfigs, createBlockConfig, deleteBlockConfig,
|
||||
previewBlock, generateBlock, getCanonicalMeta,
|
||||
generateInteractiveVideo,
|
||||
} from '@/lib/sdk/training/api'
|
||||
import type {
|
||||
TrainingModule, TrainingAssignment,
|
||||
MatrixResponse, TrainingStats, DeadlineInfo, AuditLogEntry, ModuleContent, TrainingMedia,
|
||||
TrainingBlockConfig, CanonicalControlMeta, BlockPreview, BlockGenerateResult,
|
||||
} from '@/lib/sdk/training/types'
|
||||
import {
|
||||
REGULATION_LABELS, REGULATION_COLORS, FREQUENCY_LABELS,
|
||||
STATUS_LABELS, STATUS_COLORS, ROLE_LABELS, ALL_ROLES,
|
||||
STATUS_LABELS, STATUS_COLORS, ROLE_LABELS, ALL_ROLES, TARGET_AUDIENCE_LABELS,
|
||||
} from '@/lib/sdk/training/types'
|
||||
import AudioPlayer from '@/components/training/AudioPlayer'
|
||||
import VideoPlayer from '@/components/training/VideoPlayer'
|
||||
@@ -41,6 +45,7 @@ export default function TrainingPage() {
|
||||
const [bulkGenerating, setBulkGenerating] = useState(false)
|
||||
const [bulkResult, setBulkResult] = useState<{ generated: number; skipped: number; errors: string[] } | null>(null)
|
||||
const [moduleMedia, setModuleMedia] = useState<TrainingMedia[]>([])
|
||||
const [interactiveGenerating, setInteractiveGenerating] = useState(false)
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<string>('')
|
||||
const [regulationFilter, setRegulationFilter] = useState<string>('')
|
||||
@@ -52,6 +57,15 @@ export default function TrainingPage() {
|
||||
const [selectedAssignment, setSelectedAssignment] = useState<TrainingAssignment | null>(null)
|
||||
const [escalationResult, setEscalationResult] = useState<{ total_checked: number; escalated: number } | null>(null)
|
||||
|
||||
// Block (Controls → Module) state
|
||||
const [blocks, setBlocks] = useState<TrainingBlockConfig[]>([])
|
||||
const [canonicalMeta, setCanonicalMeta] = useState<CanonicalControlMeta | null>(null)
|
||||
const [showBlockCreate, setShowBlockCreate] = useState(false)
|
||||
const [blockPreview, setBlockPreview] = useState<BlockPreview | null>(null)
|
||||
const [blockPreviewId, setBlockPreviewId] = useState<string>('')
|
||||
const [blockGenerating, setBlockGenerating] = useState(false)
|
||||
const [blockResult, setBlockResult] = useState<BlockGenerateResult | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
@@ -66,13 +80,15 @@ export default function TrainingPage() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [statsRes, modulesRes, matrixRes, assignmentsRes, deadlinesRes, auditRes] = await Promise.allSettled([
|
||||
const [statsRes, modulesRes, matrixRes, assignmentsRes, deadlinesRes, auditRes, blocksRes, metaRes] = await Promise.allSettled([
|
||||
getStats(),
|
||||
getModules(),
|
||||
getMatrix(),
|
||||
getAssignments({ limit: 50 }),
|
||||
getDeadlines(10),
|
||||
getAuditLog({ limit: 30 }),
|
||||
listBlockConfigs(),
|
||||
getCanonicalMeta(),
|
||||
])
|
||||
|
||||
if (statsRes.status === 'fulfilled') setStats(statsRes.value)
|
||||
@@ -81,6 +97,8 @@ export default function TrainingPage() {
|
||||
if (assignmentsRes.status === 'fulfilled') setAssignments(assignmentsRes.value.assignments)
|
||||
if (deadlinesRes.status === 'fulfilled') setDeadlines(deadlinesRes.value.deadlines)
|
||||
if (auditRes.status === 'fulfilled') setAuditLog(auditRes.value.entries)
|
||||
if (blocksRes.status === 'fulfilled') setBlocks(blocksRes.value.blocks)
|
||||
if (metaRes.status === 'fulfilled') setCanonicalMeta(metaRes.value)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
@@ -114,6 +132,19 @@ export default function TrainingPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateInteractiveVideo() {
|
||||
if (!selectedModuleId) return
|
||||
setInteractiveGenerating(true)
|
||||
try {
|
||||
await generateInteractiveVideo(selectedModuleId)
|
||||
await loadModuleMedia(selectedModuleId)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler bei der interaktiven Video-Generierung')
|
||||
} finally {
|
||||
setInteractiveGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublishContent(contentId: string) {
|
||||
try {
|
||||
await publishContent(contentId)
|
||||
@@ -190,6 +221,59 @@ export default function TrainingPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Block handlers
|
||||
async function handleCreateBlock(data: {
|
||||
name: string; description?: string; domain_filter?: string; category_filter?: string;
|
||||
severity_filter?: string; target_audience_filter?: string; regulation_area: string;
|
||||
module_code_prefix: string; max_controls_per_module?: number;
|
||||
}) {
|
||||
try {
|
||||
await createBlockConfig(data)
|
||||
setShowBlockCreate(false)
|
||||
const res = await listBlockConfigs()
|
||||
setBlocks(res.blocks)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Erstellen')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteBlock(id: string) {
|
||||
if (!confirm('Block-Konfiguration wirklich loeschen?')) return
|
||||
try {
|
||||
await deleteBlockConfig(id)
|
||||
const res = await listBlockConfigs()
|
||||
setBlocks(res.blocks)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Loeschen')
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePreviewBlock(id: string) {
|
||||
setBlockPreviewId(id)
|
||||
setBlockPreview(null)
|
||||
setBlockResult(null)
|
||||
try {
|
||||
const preview = await previewBlock(id)
|
||||
setBlockPreview(preview)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Preview')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateBlock(id: string) {
|
||||
setBlockGenerating(true)
|
||||
setBlockResult(null)
|
||||
try {
|
||||
const result = await generateBlock(id, { language: 'de', auto_matrix: true })
|
||||
setBlockResult(result)
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler bei der Block-Generierung')
|
||||
} finally {
|
||||
setBlockGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'overview', label: 'Uebersicht' },
|
||||
{ id: 'modules', label: 'Modulkatalog' },
|
||||
@@ -521,6 +605,228 @@ export default function TrainingPage() {
|
||||
|
||||
{activeTab === 'content' && (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Training Blocks — Controls → Schulungsmodule */}
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700">Schulungsbloecke aus Controls</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Canonical Controls nach Kriterien filtern und automatisch Schulungsmodule generieren
|
||||
{canonicalMeta && <span className="ml-2 text-gray-400">({canonicalMeta.total} Controls verfuegbar)</span>}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowBlockCreate(true)}
|
||||
className="px-3 py-1.5 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
+ Neuen Block erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Block list */}
|
||||
{blocks.length > 0 ? (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Name</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Domain</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Zielgruppe</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Severity</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Prefix</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Letzte Generierung</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-600">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{blocks.map(block => (
|
||||
<tr key={block.id} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium text-gray-900">{block.name}</div>
|
||||
{block.description && <div className="text-xs text-gray-500">{block.description}</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-600">{block.domain_filter || 'Alle'}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{block.target_audience_filter ? (TARGET_AUDIENCE_LABELS[block.target_audience_filter] || block.target_audience_filter) : 'Alle'}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{block.severity_filter || 'Alle'}</td>
|
||||
<td className="px-3 py-2"><code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">{block.module_code_prefix}</code></td>
|
||||
<td className="px-3 py-2 text-gray-500 text-xs">{block.last_generated_at ? new Date(block.last_generated_at).toLocaleString('de-DE') : 'Noch nie'}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button
|
||||
onClick={() => handlePreviewBlock(block.id)}
|
||||
className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleGenerateBlock(block.id)}
|
||||
disabled={blockGenerating}
|
||||
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{blockGenerating ? 'Generiert...' : 'Generieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteBlock(block.id)}
|
||||
className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500 text-sm">
|
||||
Noch keine Schulungsbloecke konfiguriert. Erstelle einen Block, um Controls automatisch in Module umzuwandeln.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview result */}
|
||||
{blockPreview && blockPreviewId && (
|
||||
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-blue-800 mb-2">Preview: {blocks.find(b => b.id === blockPreviewId)?.name}</h4>
|
||||
<div className="flex gap-6 text-sm mb-3">
|
||||
<span className="text-blue-700">Controls: <strong>{blockPreview.control_count}</strong></span>
|
||||
<span className="text-blue-700">Module: <strong>{blockPreview.module_count}</strong></span>
|
||||
<span className="text-blue-700">Rollen: <strong>{blockPreview.proposed_roles.map(r => ROLE_LABELS[r] || r).join(', ')}</strong></span>
|
||||
</div>
|
||||
{blockPreview.controls.length > 0 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-blue-600 hover:text-blue-800">Passende Controls anzeigen ({blockPreview.control_count})</summary>
|
||||
<div className="mt-2 max-h-48 overflow-y-auto">
|
||||
{blockPreview.controls.slice(0, 50).map(ctrl => (
|
||||
<div key={ctrl.control_id} className="flex gap-2 py-1 border-b border-blue-100">
|
||||
<code className="text-xs bg-blue-100 px-1 rounded shrink-0">{ctrl.control_id}</code>
|
||||
<span className="text-gray-700 truncate">{ctrl.title}</span>
|
||||
<span className={`text-xs px-1.5 rounded shrink-0 ${ctrl.severity === 'critical' ? 'bg-red-100 text-red-700' : ctrl.severity === 'high' ? 'bg-orange-100 text-orange-700' : 'bg-gray-100 text-gray-600'}`}>{ctrl.severity}</span>
|
||||
</div>
|
||||
))}
|
||||
{blockPreview.control_count > 50 && <div className="text-gray-500 py-1">... und {blockPreview.control_count - 50} weitere</div>}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate result */}
|
||||
{blockResult && (
|
||||
<div className="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-green-800 mb-2">Generierung abgeschlossen</h4>
|
||||
<div className="flex gap-6 text-sm">
|
||||
<span className="text-green-700">Module erstellt: <strong>{blockResult.modules_created}</strong></span>
|
||||
<span className="text-green-700">Controls verknuepft: <strong>{blockResult.controls_linked}</strong></span>
|
||||
<span className="text-green-700">Matrix-Eintraege: <strong>{blockResult.matrix_entries_created}</strong></span>
|
||||
<span className="text-green-700">Content generiert: <strong>{blockResult.content_generated}</strong></span>
|
||||
</div>
|
||||
{blockResult.errors && blockResult.errors.length > 0 && (
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
{blockResult.errors.map((err, i) => <div key={i}>{err}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Block Create Modal */}
|
||||
{showBlockCreate && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Neuen Schulungsblock erstellen</h3>
|
||||
<form onSubmit={e => {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
handleCreateBlock({
|
||||
name: fd.get('name') as string,
|
||||
description: fd.get('description') as string || undefined,
|
||||
domain_filter: fd.get('domain_filter') as string || undefined,
|
||||
category_filter: fd.get('category_filter') as string || undefined,
|
||||
severity_filter: fd.get('severity_filter') as string || undefined,
|
||||
target_audience_filter: fd.get('target_audience_filter') as string || undefined,
|
||||
regulation_area: fd.get('regulation_area') as string,
|
||||
module_code_prefix: fd.get('module_code_prefix') as string,
|
||||
max_controls_per_module: parseInt(fd.get('max_controls_per_module') as string) || 20,
|
||||
})
|
||||
}} className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Name *</label>
|
||||
<input name="name" required className="w-full px-3 py-2 text-sm border rounded-lg" placeholder="z.B. Authentifizierung fuer Geschaeftsfuehrung" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Beschreibung</label>
|
||||
<textarea name="description" className="w-full px-3 py-2 text-sm border rounded-lg" rows={2} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Domain-Filter</label>
|
||||
<select name="domain_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||
<option value="">Alle Domains</option>
|
||||
{canonicalMeta?.domains.map(d => (
|
||||
<option key={d.domain} value={d.domain}>{d.domain} ({d.count})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Kategorie-Filter</label>
|
||||
<select name="category_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||
<option value="">Alle Kategorien</option>
|
||||
{canonicalMeta?.categories.filter(c => c.category !== 'uncategorized').map(c => (
|
||||
<option key={c.category} value={c.category}>{c.category} ({c.count})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Zielgruppe</label>
|
||||
<select name="target_audience_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||
<option value="">Alle Zielgruppen</option>
|
||||
{canonicalMeta?.audiences.filter(a => a.audience !== 'unset').map(a => (
|
||||
<option key={a.audience} value={a.audience}>{TARGET_AUDIENCE_LABELS[a.audience] || a.audience} ({a.count})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Severity</label>
|
||||
<select name="severity_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||
<option value="">Alle</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Regulierungsbereich *</label>
|
||||
<select name="regulation_area" required className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||
{Object.entries(REGULATION_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Modul-Code-Prefix *</label>
|
||||
<input name="module_code_prefix" required className="w-full px-3 py-2 text-sm border rounded-lg" placeholder="z.B. CB-AUTH" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Max. Controls pro Modul</label>
|
||||
<input name="max_controls_per_module" type="number" defaultValue={20} min={1} max={50} className="w-full px-3 py-2 text-sm border rounded-lg" />
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="submit" className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">Erstellen</button>
|
||||
<button type="button" onClick={() => setShowBlockCreate(false)} className="px-4 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk Generation */}
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Bulk-Generierung</h3>
|
||||
@@ -620,6 +926,35 @@ export default function TrainingPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Interactive Video */}
|
||||
{selectedModuleId && generatedContent?.is_published && (
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700">Interaktives Video</h3>
|
||||
<p className="text-xs text-gray-500">Video mit Narrator-Persona und Checkpoint-Quizzes</p>
|
||||
</div>
|
||||
{moduleMedia.some(m => m.media_type === 'interactive_video' && m.status === 'completed') ? (
|
||||
<span className="px-3 py-1.5 text-xs bg-purple-100 text-purple-700 rounded-full">Interaktiv erstellt</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleGenerateInteractiveVideo}
|
||||
disabled={interactiveGenerating}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{interactiveGenerating ? 'Generiere interaktives Video...' : 'Interaktives Video generieren'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{moduleMedia.filter(m => m.media_type === 'interactive_video' && m.status === 'completed').map(m => (
|
||||
<div key={m.id} className="text-xs text-gray-500 space-y-1 bg-gray-50 rounded p-3">
|
||||
<p>Dauer: {Math.round(m.duration_seconds / 60)} Min | Groesse: {(m.file_size_bytes / 1024 / 1024).toFixed(1)} MB</p>
|
||||
<p>Generiert: {new Date(m.created_at).toLocaleString('de-DE')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Script Preview */}
|
||||
{selectedModuleId && generatedContent?.is_published && (
|
||||
<ScriptPreview moduleId={selectedModuleId} />
|
||||
|
||||
@@ -57,6 +57,8 @@ interface FullAssessment {
|
||||
dsfa_recommended: boolean
|
||||
art22_risk: boolean
|
||||
training_allowed: string
|
||||
betrvg_conflict_score?: number
|
||||
betrvg_consultation_required?: boolean
|
||||
triggered_rules?: TriggeredRule[]
|
||||
required_controls?: RequiredControl[]
|
||||
recommended_architecture?: PatternRecommendation[]
|
||||
@@ -167,6 +169,8 @@ export default function AssessmentDetailPage() {
|
||||
dsfa_recommended: assessment.dsfa_recommended,
|
||||
art22_risk: assessment.art22_risk,
|
||||
training_allowed: assessment.training_allowed,
|
||||
betrvg_conflict_score: assessment.betrvg_conflict_score,
|
||||
betrvg_consultation_required: assessment.betrvg_consultation_required,
|
||||
// AssessmentResultCard expects rule_code; backend stores code — map here
|
||||
triggered_rules: assessment.triggered_rules?.map(r => ({
|
||||
rule_code: r.code,
|
||||
|
||||
@@ -1,812 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, Suspense } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { AssessmentResultCard } from '@/components/sdk/use-case-assessment/AssessmentResultCard'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// WIZARD STEPS CONFIG
|
||||
// =============================================================================
|
||||
|
||||
const WIZARD_STEPS = [
|
||||
{ id: 1, title: 'Grundlegendes', description: 'Titel und Beschreibung' },
|
||||
{ id: 2, title: 'Datenkategorien', description: 'Welche Daten werden verarbeitet?' },
|
||||
{ id: 3, title: 'Verarbeitungszweck', description: 'Rechtsgrundlage und Zweck' },
|
||||
{ id: 4, title: 'Automatisierung', description: 'Grad der Automatisierung' },
|
||||
{ id: 5, title: 'Hosting & Modell', description: 'Technische Details' },
|
||||
{ id: 6, title: 'Datentransfer', description: 'Internationaler Datentransfer' },
|
||||
{ id: 7, title: 'Datenhaltung', description: 'Aufbewahrung und Speicherung' },
|
||||
{ id: 8, title: 'Vertraege', description: 'Compliance und Vereinbarungen' },
|
||||
]
|
||||
|
||||
const DOMAINS = [
|
||||
{ value: 'healthcare', label: 'Gesundheit' },
|
||||
{ value: 'finance', label: 'Finanzen' },
|
||||
{ value: 'education', label: 'Bildung' },
|
||||
{ value: 'retail', label: 'Handel' },
|
||||
{ value: 'it_services', label: 'IT-Dienstleistungen' },
|
||||
{ value: 'consulting', label: 'Beratung' },
|
||||
{ value: 'manufacturing', label: 'Produktion' },
|
||||
{ value: 'hr', label: 'Personalwesen' },
|
||||
{ value: 'marketing', label: 'Marketing' },
|
||||
{ value: 'legal', label: 'Recht' },
|
||||
{ value: 'public', label: 'Oeffentlicher Sektor' },
|
||||
{ value: 'general', label: 'Allgemein' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
function NewUseCasePageInner() {
|
||||
function RedirectInner() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const editId = searchParams.get('edit')
|
||||
const isEditMode = !!editId
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
const [result, setResult] = useState<unknown>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Form state
|
||||
const [form, setForm] = useState({
|
||||
title: '',
|
||||
use_case_text: '',
|
||||
domain: 'general',
|
||||
// Data Types
|
||||
personal_data: false,
|
||||
special_categories: false,
|
||||
minors_data: false,
|
||||
health_data: false,
|
||||
biometric_data: false,
|
||||
financial_data: false,
|
||||
custom_data_types: [] as string[],
|
||||
// Purpose
|
||||
purpose_profiling: false,
|
||||
purpose_automated_decision: false,
|
||||
purpose_marketing: false,
|
||||
purpose_analytics: false,
|
||||
purpose_service_delivery: false,
|
||||
// Automation
|
||||
automation: 'assistive' as 'assistive' | 'semi_automated' | 'fully_automated',
|
||||
// Hosting
|
||||
hosting_provider: 'self_hosted',
|
||||
hosting_region: 'eu',
|
||||
// Model Usage
|
||||
model_rag: false,
|
||||
model_finetune: false,
|
||||
model_training: false,
|
||||
model_inference: true,
|
||||
// Legal Basis (Step 3)
|
||||
legal_basis: 'consent' as 'consent' | 'contract' | 'legitimate_interest' | 'legal_obligation' | 'vital_interest' | 'public_interest',
|
||||
// Data Transfer (Step 6)
|
||||
international_transfer: false,
|
||||
transfer_countries: [] as string[],
|
||||
transfer_mechanism: 'none' as 'none' | 'scc' | 'bcr' | 'adequacy' | 'derogation',
|
||||
// Retention (Step 7)
|
||||
retention_days: 90,
|
||||
retention_purpose: '',
|
||||
// Contracts (Step 8)
|
||||
has_dpa: false,
|
||||
has_aia_documentation: false,
|
||||
has_risk_assessment: false,
|
||||
subprocessors: '',
|
||||
})
|
||||
|
||||
const updateForm = (updates: Partial<typeof form>) => {
|
||||
setForm(prev => ({ ...prev, ...updates }))
|
||||
}
|
||||
|
||||
// Pre-fill form when in edit mode
|
||||
useEffect(() => {
|
||||
if (!editId) return
|
||||
setEditLoading(true)
|
||||
fetch(`/api/sdk/v1/ucca/assessments/${editId}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const intake = data.intake || {}
|
||||
setForm({
|
||||
title: data.title || '',
|
||||
use_case_text: intake.use_case_text || '',
|
||||
domain: data.domain || 'general',
|
||||
personal_data: intake.data_types?.personal_data || false,
|
||||
special_categories: intake.data_types?.article_9_data || false,
|
||||
minors_data: intake.data_types?.minor_data || false,
|
||||
health_data: intake.data_types?.health_data || false,
|
||||
biometric_data: intake.data_types?.biometric_data || false,
|
||||
financial_data: intake.data_types?.financial_data || false,
|
||||
custom_data_types: intake.data_types?.custom_data_types || [],
|
||||
purpose_profiling: intake.purpose?.profiling || false,
|
||||
purpose_automated_decision: intake.purpose?.automated_decision || intake.purpose?.decision_making || false,
|
||||
purpose_marketing: intake.purpose?.marketing || false,
|
||||
purpose_analytics: intake.purpose?.analytics || false,
|
||||
purpose_service_delivery: intake.purpose?.service_delivery || intake.purpose?.customer_support || false,
|
||||
automation: intake.automation || 'assistive',
|
||||
hosting_provider: intake.hosting?.provider || 'self_hosted',
|
||||
hosting_region: intake.hosting?.region || 'eu',
|
||||
model_rag: intake.model_usage?.rag || false,
|
||||
model_finetune: intake.model_usage?.finetune || false,
|
||||
model_training: intake.model_usage?.training || false,
|
||||
model_inference: intake.model_usage?.inference ?? true,
|
||||
legal_basis: intake.legal_basis || 'consent',
|
||||
international_transfer: intake.international_transfer?.enabled || false,
|
||||
transfer_countries: intake.international_transfer?.countries || [],
|
||||
transfer_mechanism: intake.international_transfer?.mechanism || 'none',
|
||||
retention_days: intake.retention?.days || 90,
|
||||
retention_purpose: intake.retention?.purpose || '',
|
||||
has_dpa: intake.contracts?.has_dpa || false,
|
||||
has_aia_documentation: intake.contracts?.has_aia_documentation || false,
|
||||
has_risk_assessment: intake.contracts?.has_risk_assessment || false,
|
||||
subprocessors: intake.contracts?.subprocessors || '',
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setEditLoading(false))
|
||||
}, [editId])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const intake = {
|
||||
title: form.title,
|
||||
use_case_text: form.use_case_text,
|
||||
domain: form.domain,
|
||||
data_types: {
|
||||
personal_data: form.personal_data,
|
||||
special_categories: form.special_categories,
|
||||
minors_data: form.minors_data,
|
||||
health_data: form.health_data,
|
||||
biometric_data: form.biometric_data,
|
||||
financial_data: form.financial_data,
|
||||
custom_data_types: form.custom_data_types.filter(s => s.trim()),
|
||||
},
|
||||
purpose: {
|
||||
profiling: form.purpose_profiling,
|
||||
automated_decision: form.purpose_automated_decision,
|
||||
marketing: form.purpose_marketing,
|
||||
analytics: form.purpose_analytics,
|
||||
service_delivery: form.purpose_service_delivery,
|
||||
},
|
||||
automation: form.automation,
|
||||
hosting: {
|
||||
provider: form.hosting_provider,
|
||||
region: form.hosting_region,
|
||||
},
|
||||
model_usage: {
|
||||
rag: form.model_rag,
|
||||
finetune: form.model_finetune,
|
||||
training: form.model_training,
|
||||
inference: form.model_inference,
|
||||
},
|
||||
legal_basis: form.legal_basis,
|
||||
international_transfer: {
|
||||
enabled: form.international_transfer,
|
||||
countries: form.transfer_countries,
|
||||
mechanism: form.transfer_mechanism,
|
||||
},
|
||||
retention: {
|
||||
days: form.retention_days,
|
||||
purpose: form.retention_purpose,
|
||||
},
|
||||
contracts: {
|
||||
has_dpa: form.has_dpa,
|
||||
has_aia_documentation: form.has_aia_documentation,
|
||||
has_risk_assessment: form.has_risk_assessment,
|
||||
subprocessors: form.subprocessors,
|
||||
},
|
||||
store_raw_text: true,
|
||||
}
|
||||
|
||||
const url = isEditMode
|
||||
? `/api/sdk/v1/ucca/assessments/${editId}`
|
||||
: '/api/sdk/v1/ucca/assess'
|
||||
const method = isEditMode ? 'PUT' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(intake),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => null)
|
||||
throw new Error(errData?.error || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
if (isEditMode) {
|
||||
router.push(`/sdk/use-cases/${editId}`)
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setResult(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler bei der Bewertung')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a result, show it
|
||||
if (result) {
|
||||
const r = result as { assessment?: { id: string }; result?: Record<string, unknown> }
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Assessment Ergebnis</h1>
|
||||
<div className="flex gap-2">
|
||||
{r.assessment?.id && (
|
||||
<button
|
||||
onClick={() => router.push(`/sdk/use-cases/${r.assessment!.id}`)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
Zum Assessment
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => router.push('/sdk/use-cases')}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
Zur Uebersicht
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{r.result && (
|
||||
<AssessmentResultCard result={r.result as Parameters<typeof AssessmentResultCard>[0]['result']} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const editId = searchParams.get('edit')
|
||||
const target = editId
|
||||
? `/sdk/advisory-board?edit=${encodeURIComponent(editId)}`
|
||||
: '/sdk/advisory-board'
|
||||
router.replace(target)
|
||||
}, [router, searchParams])
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{isEditMode ? 'Assessment bearbeiten' : 'Neues Use Case Assessment'}
|
||||
</h1>
|
||||
<p className="mt-1 text-gray-500">
|
||||
{isEditMode
|
||||
? 'Angaben anpassen und Assessment neu bewerten'
|
||||
: 'Beschreiben Sie Ihren KI-Anwendungsfall Schritt fuer Schritt'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Edit loading indicator */}
|
||||
{editLoading && (
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 text-purple-700 text-sm">
|
||||
Lade Assessment-Daten...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
{WIZARD_STEPS.map((step, idx) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<button
|
||||
onClick={() => setCurrentStep(step.id)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
currentStep === step.id
|
||||
? 'bg-purple-600 text-white'
|
||||
: currentStep > step.id
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<span className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center text-xs font-bold">
|
||||
{currentStep > step.id ? '✓' : step.id}
|
||||
</span>
|
||||
<span className="hidden md:inline">{step.title}</span>
|
||||
</button>
|
||||
{idx < WIZARD_STEPS.length - 1 && <div className="flex-1 h-px bg-gray-200" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
{/* Step 1: Grundlegendes */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Grundlegende Informationen</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={e => updateForm({ title: e.target.value })}
|
||||
placeholder="z.B. Chatbot fuer Kundenservice"
|
||||
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={form.use_case_text}
|
||||
onChange={e => updateForm({ use_case_text: e.target.value })}
|
||||
rows={4}
|
||||
placeholder="Beschreiben Sie den Anwendungsfall..."
|
||||
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">Branche</label>
|
||||
<select
|
||||
value={form.domain}
|
||||
onChange={e => updateForm({ domain: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
{DOMAINS.map(d => (
|
||||
<option key={d.value} value={d.value}>{d.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Datenkategorien */}
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Welche Daten werden verarbeitet?</h2>
|
||||
{[
|
||||
{ key: 'personal_data', label: 'Personenbezogene Daten', desc: 'Name, E-Mail, Adresse etc.' },
|
||||
{ key: 'special_categories', label: 'Besondere Kategorien (Art. 9)', desc: 'Religion, Gesundheit, politische Meinung' },
|
||||
{ key: 'health_data', label: 'Gesundheitsdaten', desc: 'Diagnosen, Medikation, Fitness' },
|
||||
{ key: 'biometric_data', label: 'Biometrische Daten', desc: 'Gesichtserkennung, Fingerabdruck, Stimme' },
|
||||
{ key: 'minors_data', label: 'Daten von Minderjaehrigen', desc: 'Unter 16 Jahren' },
|
||||
{ key: 'financial_data', label: 'Finanzdaten', desc: 'Kontodaten, Transaktionen, Kreditwuerdigkeit' },
|
||||
].map(item => (
|
||||
<label key={item.key} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form[item.key as keyof typeof form] as boolean}
|
||||
onChange={e => updateForm({ [item.key]: e.target.checked })}
|
||||
className="mt-1 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{item.label}</div>
|
||||
<div className="text-sm text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
|
||||
{/* Sonstige Datentypen */}
|
||||
<div className="border border-gray-200 rounded-lg p-4 space-y-3">
|
||||
<div className="font-medium text-gray-900">Sonstige Datentypen</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
z.B. Kennzeichen, Fahrzeug-Identifikationsnummer (VIN), Geraete-IDs, IP-Adressen
|
||||
</p>
|
||||
{form.custom_data_types.map((dt, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={dt}
|
||||
onChange={e => {
|
||||
const updated = [...form.custom_data_types]
|
||||
updated[idx] = e.target.value
|
||||
updateForm({ custom_data_types: updated })
|
||||
}}
|
||||
placeholder="Datentyp eingeben..."
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const updated = form.custom_data_types.filter((_, i) => i !== idx)
|
||||
updateForm({ custom_data_types: updated })
|
||||
}}
|
||||
className="p-2 text-red-500 hover:bg-red-50 rounded-lg"
|
||||
title="Entfernen"
|
||||
>
|
||||
<svg className="w-4 h-4" 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>
|
||||
))}
|
||||
<button
|
||||
onClick={() => updateForm({ custom_data_types: [...form.custom_data_types, ''] })}
|
||||
className="flex items-center gap-1 text-sm text-purple-600 hover:text-purple-700 font-medium"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
|
||||
Weiteren Datentyp hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Verarbeitungszweck & Rechtsgrundlage */}
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Verarbeitungszweck & Rechtsgrundlage</h2>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsgrundlage (Art. 6 DSGVO)</label>
|
||||
<select
|
||||
value={form.legal_basis}
|
||||
onChange={e => updateForm({ legal_basis: e.target.value as typeof form.legal_basis })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="consent">Einwilligung (Art. 6 Abs. 1a)</option>
|
||||
<option value="contract">Vertragserfullung (Art. 6 Abs. 1b)</option>
|
||||
<option value="legal_obligation">Rechtliche Verpflichtung (Art. 6 Abs. 1c)</option>
|
||||
<option value="vital_interest">Lebenswichtige Interessen (Art. 6 Abs. 1d)</option>
|
||||
<option value="public_interest">Oeffentliches Interesse (Art. 6 Abs. 1e)</option>
|
||||
<option value="legitimate_interest">Berechtigtes Interesse (Art. 6 Abs. 1f)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-gray-700 mt-4">Zweck der Verarbeitung</h3>
|
||||
{[
|
||||
{ key: 'purpose_profiling', label: 'Profiling', desc: 'Automatisierte Analyse personenbezogener Aspekte' },
|
||||
{ key: 'purpose_automated_decision', label: 'Automatisierte Entscheidung', desc: 'Art. 22 DSGVO — Entscheidung ohne menschliches Zutun' },
|
||||
{ key: 'purpose_marketing', label: 'Marketing', desc: 'Werbung, Personalisierung, Targeting' },
|
||||
{ key: 'purpose_analytics', label: 'Analytics', desc: 'Statistische Auswertung, Business Intelligence' },
|
||||
{ key: 'purpose_service_delivery', label: 'Serviceerbringung', desc: 'Kernfunktion des Produkts/Services' },
|
||||
].map(item => (
|
||||
<label key={item.key} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form[item.key as keyof typeof form] as boolean}
|
||||
onChange={e => updateForm({ [item.key]: e.target.checked })}
|
||||
className="mt-1 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{item.label}</div>
|
||||
<div className="text-sm text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Automatisierung */}
|
||||
{currentStep === 4 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Grad der Automatisierung</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Wie stark greift die KI in Entscheidungen ein? Je hoeher der Automatisierungsgrad, desto strenger die regulatorischen Anforderungen.
|
||||
</p>
|
||||
{[
|
||||
{
|
||||
value: 'assistive',
|
||||
label: 'Assistiv (Mensch entscheidet)',
|
||||
desc: 'Die KI liefert Informationen oder Vorschlaege, aber ein Mensch trifft immer die finale Entscheidung.',
|
||||
examples: 'Rechtschreibkorrektur, Suchvorschlaege, Zusammenfassungen, Uebersetzungshilfe',
|
||||
},
|
||||
{
|
||||
value: 'semi_automated',
|
||||
label: 'Teilautomatisiert (Mensch prueft)',
|
||||
desc: 'Die KI erstellt Ergebnisse oder Entwuerfe, die ein Mensch vor der Ausfuehrung prueft und bestaetigt.',
|
||||
examples: 'E-Mail-Entwuerfe mit manueller Freigabe, vorgeschlagene Diagnosen mit Arztbestaetigung, KI-generierte Vertraege mit juristischer Pruefung',
|
||||
},
|
||||
{
|
||||
value: 'fully_automated',
|
||||
label: 'Vollautomatisiert (KI entscheidet)',
|
||||
desc: 'Die KI trifft Entscheidungen eigenstaendig. Ein Mensch ueberwacht nur stichprobenartig oder bei Ausnahmen.',
|
||||
examples: 'Automatische Kreditentscheidungen, automatisierte Bewerbungs-Vorauswahl, autonome Chatbot-Antworten ohne Pruefung',
|
||||
},
|
||||
].map(item => (
|
||||
<label
|
||||
key={item.value}
|
||||
className={`flex items-start gap-3 p-4 rounded-lg border-2 cursor-pointer transition-all ${
|
||||
form.automation === item.value
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="automation"
|
||||
value={item.value}
|
||||
checked={form.automation === item.value}
|
||||
onChange={e => updateForm({ automation: e.target.value as typeof form.automation })}
|
||||
className="mt-1 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{item.label}</div>
|
||||
<div className="text-sm text-gray-500">{item.desc}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">Beispiele: {item.examples}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
|
||||
{/* Info-Box: Warum ist das wichtig? */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
|
||||
<div className="font-medium mb-1">Warum ist das wichtig?</div>
|
||||
<p>
|
||||
Art. 22 DSGVO regelt automatisierte Einzelentscheidungen. Vollautomatisierte Systeme, die Personen
|
||||
erheblich beeinflussen (z.B. Kreditvergabe, Bewerbungsauswahl), unterliegen strengen Auflagen:
|
||||
Informationspflicht, Recht auf menschliche Ueberpruefung und Anfechtungsmoeglichkeit.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 5: Hosting & Modell */}
|
||||
{currentStep === 5 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Technische Details</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Hosting</label>
|
||||
<select
|
||||
value={form.hosting_provider}
|
||||
onChange={e => updateForm({ hosting_provider: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="self_hosted">Eigenes Hosting</option>
|
||||
<option value="aws">AWS</option>
|
||||
<option value="azure">Microsoft Azure</option>
|
||||
<option value="gcp">Google Cloud</option>
|
||||
<option value="hetzner">Hetzner (DE)</option>
|
||||
<option value="other">Anderer Anbieter</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Region</label>
|
||||
<select
|
||||
value={form.hosting_region}
|
||||
onChange={e => updateForm({ hosting_region: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="eu">EU</option>
|
||||
<option value="de">Deutschland</option>
|
||||
<option value="us">USA</option>
|
||||
<option value="other">Andere</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-gray-700 mt-4">Wie wird das KI-Modell genutzt?</h3>
|
||||
<p className="text-sm text-gray-500">Waehlen Sie alle zutreffenden Optionen. Klicken Sie auf die Info-Symbole fuer Erklaerungen.</p>
|
||||
|
||||
{[
|
||||
{
|
||||
key: 'model_inference',
|
||||
label: 'Inferenz',
|
||||
desc: 'Ein fertiges, vortrainiertes Modell wird direkt genutzt — z.B. ChatGPT, Claude, DeepL. Das Modell wird nicht veraendert.',
|
||||
example: 'Sie nutzen die OpenAI API, um Texte zusammenzufassen.',
|
||||
},
|
||||
{
|
||||
key: 'model_rag',
|
||||
label: 'RAG (Retrieval-Augmented Generation)',
|
||||
desc: 'Das Modell erhaelt zusaetzlichen Kontext aus Ihren eigenen Dokumenten (z.B. Wissensdatenbank, Handbuecher). Das Modell selbst wird nicht veraendert.',
|
||||
example: 'Ein Chatbot durchsucht Ihre Firmen-FAQ und beantwortet Fragen basierend auf den gefundenen Dokumenten.',
|
||||
},
|
||||
{
|
||||
key: 'model_finetune',
|
||||
label: 'Fine-Tuning',
|
||||
desc: 'Ein bestehendes Modell wird mit Ihren eigenen Daten nachtrainiert, um es an Ihre spezifischen Anforderungen anzupassen. Die Originaldaten fliessen ins Modell ein.',
|
||||
example: 'Sie trainieren GPT-3.5 mit 1.000 Ihrer Support-Tickets, damit es Ihren Kommunikationsstil uebernimmt.',
|
||||
},
|
||||
{
|
||||
key: 'model_training',
|
||||
label: 'Training (eigenes Modell)',
|
||||
desc: 'Sie trainieren ein komplett eigenes KI-Modell von Grund auf mit Ihren Daten. Hoechster Kontrollgrad, aber auch hoechster Aufwand und Datenrisiko.',
|
||||
example: 'Sie trainieren ein eigenes Bilderkennungsmodell fuer Qualitaetskontrolle in der Produktion.',
|
||||
},
|
||||
].map(item => (
|
||||
<div key={item.key} className="bg-gray-50 rounded-lg border border-gray-200 overflow-hidden">
|
||||
<label className="flex items-start gap-3 p-3 cursor-pointer hover:bg-gray-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form[item.key as keyof typeof form] as boolean}
|
||||
onChange={e => updateForm({ [item.key]: e.target.checked })}
|
||||
className="mt-1 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{item.label}</div>
|
||||
<div className="text-sm text-gray-500">{item.desc}</div>
|
||||
<div className="text-xs text-purple-600 mt-1 bg-purple-50 inline-block px-2 py-0.5 rounded">
|
||||
Beispiel: {item.example}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Info-Box: Begriffe erklaert */}
|
||||
<details className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden">
|
||||
<summary className="px-4 py-3 text-sm font-medium text-amber-800 cursor-pointer hover:bg-amber-100">
|
||||
Begriffe erklaert: ML, DL, NLP, LLM — Was bedeutet das?
|
||||
</summary>
|
||||
<div className="px-4 pb-4 space-y-3 text-sm text-amber-900">
|
||||
<div>
|
||||
<span className="font-semibold">ML (Machine Learning)</span> —
|
||||
Computer lernt Muster aus Daten, statt explizit programmiert zu werden.
|
||||
Beispiel: Spam-Filter, der aus markierten E-Mails lernt.
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">DL (Deep Learning)</span> —
|
||||
Spezielle Form von ML mit kuenstlichen neuronalen Netzen (viele Schichten).
|
||||
Beispiel: Bilderkennung, Spracherkennung, Textgenerierung.
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">NLP (Natural Language Processing)</span> —
|
||||
KI, die menschliche Sprache versteht und verarbeitet.
|
||||
Beispiel: ChatGPT, DeepL, Sentiment-Analyse von Kundenbewertungen.
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">LLM (Large Language Model)</span> —
|
||||
Sehr grosses Sprachmodell, trainiert auf riesigen Textmengen. Kann Texte verstehen, generieren und uebersetzen.
|
||||
Beispiel: GPT-4, Claude, Llama, Gemini.
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">RAG (Retrieval-Augmented Generation)</span> —
|
||||
Das LLM erhaelt vor der Antwort relevante Dokumente aus einer Datenbank als Kontext.
|
||||
Vorteil: Aktuelle und firmenspezifische Antworten, ohne das Modell neu zu trainieren.
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Fine-Tuning</span> —
|
||||
Ein bestehendes Modell wird mit eigenen Daten weiter trainiert, um es zu spezialisieren.
|
||||
Achtung: Ihre Trainingsdaten werden Teil des Modells.
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 6: Internationaler Datentransfer */}
|
||||
{currentStep === 6 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Internationaler Datentransfer</h2>
|
||||
|
||||
<label className="flex items-start gap-3 p-4 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.international_transfer}
|
||||
onChange={e => updateForm({ international_transfer: e.target.checked })}
|
||||
className="mt-1 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Daten werden in Drittlaender uebermittelt</div>
|
||||
<div className="text-sm text-gray-500">Ausserhalb des EWR (z.B. USA, UK, Schweiz)</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{form.international_transfer && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ziellaender</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.transfer_countries.join(', ')}
|
||||
onChange={e => updateForm({ transfer_countries: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })}
|
||||
placeholder="z.B. USA, UK, CH"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Kommagetrennte Laenderkuerzel</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Transfer-Mechanismus</label>
|
||||
<select
|
||||
value={form.transfer_mechanism}
|
||||
onChange={e => updateForm({ transfer_mechanism: e.target.value as typeof form.transfer_mechanism })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
>
|
||||
<option value="none">Noch nicht festgelegt</option>
|
||||
<option value="adequacy">Angemessenheitsbeschluss</option>
|
||||
<option value="scc">Standardvertragsklauseln (SCC)</option>
|
||||
<option value="bcr">Binding Corporate Rules (BCR)</option>
|
||||
<option value="derogation">Ausnahmeregelung (Art. 49 DSGVO)</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 7: Datenhaltung */}
|
||||
{currentStep === 7 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Datenhaltung & Aufbewahrung</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Aufbewahrungsdauer (Tage)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.retention_days}
|
||||
onChange={e => updateForm({ retention_days: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Zweck der Aufbewahrung
|
||||
</label>
|
||||
<textarea
|
||||
value={form.retention_purpose}
|
||||
onChange={e => updateForm({ retention_purpose: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="z.B. Vertragliche Pflichten, gesetzliche Aufbewahrungsfristen..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 8: Vertraege & Compliance */}
|
||||
{currentStep === 8 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Vertraege & Compliance-Dokumentation</h2>
|
||||
|
||||
{[
|
||||
{ key: 'has_dpa', label: 'Auftragsverarbeitungsvertrag (AVV/DPA)', desc: 'Vertrag mit KI-Anbieter / Subprozessor nach Art. 28 DSGVO' },
|
||||
{ key: 'has_aia_documentation', label: 'AI Act Dokumentation', desc: 'Risikoklassifizierung und technische Dokumentation nach EU AI Act' },
|
||||
{ key: 'has_risk_assessment', label: 'Risikobewertung / DSFA', desc: 'Datenschutz-Folgenabschaetzung nach Art. 35 DSGVO' },
|
||||
].map(item => (
|
||||
<label key={item.key} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form[item.key as keyof typeof form] as boolean}
|
||||
onChange={e => updateForm({ [item.key]: e.target.checked })}
|
||||
className="mt-1 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{item.label}</div>
|
||||
<div className="text-sm text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Subprozessoren</label>
|
||||
<textarea
|
||||
value={form.subprocessors}
|
||||
onChange={e => updateForm({ subprocessors: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="z.B. OpenAI (USA, SCC), Hetzner Cloud (DE)..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => currentStep > 1 ? setCurrentStep(currentStep - 1) : router.push('/sdk/use-cases')}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{currentStep === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||
</button>
|
||||
|
||||
{currentStep < 8 ? (
|
||||
<button
|
||||
onClick={() => setCurrentStep(currentStep + 1)}
|
||||
disabled={currentStep === 1 && !form.title}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !form.title}
|
||||
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Bewerte...
|
||||
</>
|
||||
) : (
|
||||
isEditMode ? 'Speichern & neu bewerten' : 'Assessment starten'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center h-64 text-gray-500">
|
||||
Weiterleitung...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -814,7 +26,7 @@ function NewUseCasePageInner() {
|
||||
export default function NewUseCasePage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex items-center justify-center h-64 text-gray-500">Lade...</div>}>
|
||||
<NewUseCasePageInner />
|
||||
<RedirectInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ interface Assessment {
|
||||
feasibility: string
|
||||
risk_level: string
|
||||
risk_score: number
|
||||
betrvg_conflict_score?: number
|
||||
betrvg_consultation_required?: boolean
|
||||
domain: string
|
||||
created_at: string
|
||||
}
|
||||
@@ -194,6 +196,16 @@ export default function UseCasesPage() {
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${feasibility.bg} ${feasibility.text}`}>
|
||||
{feasibility.label}
|
||||
</span>
|
||||
{assessment.betrvg_conflict_score != null && assessment.betrvg_conflict_score > 0 && (
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
assessment.betrvg_conflict_score >= 75 ? 'bg-red-100 text-red-700' :
|
||||
assessment.betrvg_conflict_score >= 50 ? 'bg-orange-100 text-orange-700' :
|
||||
assessment.betrvg_conflict_score >= 25 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
BR {assessment.betrvg_conflict_score}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span>{assessment.domain}</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1128,24 +1128,88 @@ export default function WhistleblowerPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Box about HinSchG Deadlines (Overview Tab) */}
|
||||
{/* Info Box about HinSchG (Overview Tab) */}
|
||||
{activeTab === 'overview' && (
|
||||
<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).
|
||||
<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).
|
||||
</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 36–37 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>
|
||||
)}
|
||||
|
||||
@@ -482,7 +482,6 @@ export function ProjectSelector() {
|
||||
}
|
||||
|
||||
const handleProjectClick = (project: ProjectInfo) => {
|
||||
if (project.status === 'archived') return // archived projects are read-only in list
|
||||
router.push(`/sdk?project=${project.id}`)
|
||||
}
|
||||
|
||||
@@ -598,26 +597,6 @@ export function ProjectSelector() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No active but has archived */}
|
||||
{!loading && projects.length === 0 && archivedProjects.length > 0 && (
|
||||
<div className="text-center py-12 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Keine aktiven Projekte</h2>
|
||||
<p className="mt-2 text-gray-500">
|
||||
Sie haben {archivedProjects.length} archivierte{archivedProjects.length === 1 ? 's' : ''} Projekt{archivedProjects.length === 1 ? '' : 'e'}.
|
||||
Stellen Sie ein Projekt wieder her oder erstellen Sie ein neues.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
className="mt-4 inline-flex items-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium"
|
||||
>
|
||||
<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>
|
||||
Neues Projekt erstellen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Archived Projects Section */}
|
||||
{!loading && archivedProjects.length > 0 && (
|
||||
<div className="mt-8">
|
||||
|
||||
@@ -546,6 +546,89 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* KI-Compliance */}
|
||||
<div className="border-t border-gray-100 py-2">
|
||||
{!collapsed && (
|
||||
<div className="px-4 py-2 text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
KI-Compliance
|
||||
</div>
|
||||
)}
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/advisory-board"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
}
|
||||
label="Use Case Erfassung"
|
||||
isActive={pathname === '/sdk/advisory-board'}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/use-cases"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
}
|
||||
label="Use Cases"
|
||||
isActive={pathname?.startsWith('/sdk/use-cases') ?? false}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/ai-act"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
}
|
||||
label="AI Act"
|
||||
isActive={pathname?.startsWith('/sdk/ai-act') ?? false}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/ai-registration"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
}
|
||||
label="EU Registrierung"
|
||||
isActive={pathname?.startsWith('/sdk/ai-registration') ?? false}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Payment Compliance */}
|
||||
<div className="border-t border-gray-100 py-2">
|
||||
{!collapsed && (
|
||||
<div className="px-4 py-2 text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Payment / Terminal
|
||||
</div>
|
||||
)}
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/payment-compliance"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||
</svg>
|
||||
}
|
||||
label="Payment Compliance"
|
||||
isActive={pathname?.startsWith('/sdk/payment-compliance') ?? false}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Additional Modules */}
|
||||
<div className="border-t border-gray-100 py-2">
|
||||
{!collapsed && (
|
||||
@@ -553,6 +636,32 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
|
||||
Zusatzmodule
|
||||
</div>
|
||||
)}
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/training"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
}
|
||||
label="Schulung (Admin)"
|
||||
isActive={pathname === '/sdk/training'}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/training/learner"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
}
|
||||
label="Schulung (Learner)"
|
||||
isActive={pathname === '/sdk/training/learner'}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/rag"
|
||||
icon={
|
||||
@@ -617,6 +726,19 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/assertions"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
}
|
||||
label="Assertions"
|
||||
isActive={pathname === '/sdk/assertions'}
|
||||
collapsed={collapsed}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/dsms"
|
||||
icon={
|
||||
|
||||
@@ -545,8 +545,8 @@ export const STEP_EXPLANATIONS = {
|
||||
},
|
||||
'tom': {
|
||||
title: 'Technische und Organisatorische Massnahmen',
|
||||
description: 'Dokumentieren Sie Ihre TOMs nach Art. 32 DSGVO',
|
||||
explanation: 'TOMs sind konkrete Sicherheitsmassnahmen zum Schutz personenbezogener Daten. Das Dashboard zeigt den Status aller aus dem TOM Generator abgeleiteten Massnahmen mit SDM-Mapping und Gap-Analyse.',
|
||||
description: 'TOMs nach Art. 32 DSGVO mit Vendor-Controls-Querverweis',
|
||||
explanation: 'TOMs sind konkrete Sicherheitsmassnahmen zum Schutz personenbezogener Daten. Das Dashboard zeigt den Status aller aus dem TOM Generator abgeleiteten Massnahmen mit SDM-Mapping und Gap-Analyse. Im Uebersicht-Tab werden zusaetzlich Vendor-TOM-Controls (VND-TOM-01 bis VND-TOM-06) aus dem Vendor-Compliance-Modul als Querverweis angezeigt.',
|
||||
tips: [
|
||||
{
|
||||
icon: 'warning' as const,
|
||||
@@ -563,12 +563,17 @@ export const STEP_EXPLANATIONS = {
|
||||
title: 'SDM-Mapping',
|
||||
description: 'Kontrollen werden den 7 SDM-Gewaehrleistungszielen zugeordnet: Verfuegbarkeit, Integritaet, Vertraulichkeit, Nichtverkettung, Intervenierbarkeit, Transparenz, Datenminimierung.',
|
||||
},
|
||||
{
|
||||
icon: 'success' as const,
|
||||
title: 'Vendor-Controls',
|
||||
description: 'Im Uebersicht-Tab werden Vendor-TOM-Controls (VND-TOM-01 bis 06) als Read-Only-Querverweis angezeigt: Verschluesselung, Zugriffskontrolle, Verfuegbarkeit und Ueberpruefungsverfahren Ihrer Auftragsverarbeiter.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'vvt': {
|
||||
title: 'Verarbeitungsverzeichnis',
|
||||
description: 'Erstellen und verwalten Sie Ihr Verzeichnis nach Art. 30 DSGVO',
|
||||
explanation: 'Das Verarbeitungsverzeichnis (VVT) dokumentiert alle Verarbeitungstaetigkeiten mit personenbezogenen Daten. Der integrierte Generator-Fragebogen befuellt 70-90% der Pflichtfelder automatisch anhand Ihres Unternehmensprofils.',
|
||||
description: 'Verarbeitungsverzeichnis nach Art. 30 DSGVO mit integriertem Processor-Tab',
|
||||
explanation: 'Das Verarbeitungsverzeichnis (VVT) dokumentiert alle Verarbeitungstaetigkeiten mit personenbezogenen Daten. Der integrierte Generator-Fragebogen befuellt 70-90% der Pflichtfelder automatisch. Der Tab "Auftragsverarbeiter (Abs. 2)" liest Vendors mit role=PROCESSOR/SUB_PROCESSOR direkt aus der Vendor-Compliance-API — keine doppelte Datenhaltung.',
|
||||
tips: [
|
||||
{
|
||||
icon: 'warning' as const,
|
||||
@@ -585,6 +590,11 @@ export const STEP_EXPLANATIONS = {
|
||||
title: 'Kein oeffentliches Dokument',
|
||||
description: 'Das VVT ist ein internes Dokument. Es muss der Aufsichtsbehoerde nur auf Verlangen vorgelegt werden (Art. 30 Abs. 4).',
|
||||
},
|
||||
{
|
||||
icon: 'success' as const,
|
||||
title: 'Processor-Tab (Art. 30 Abs. 2)',
|
||||
description: 'Auftragsverarbeiter werden direkt aus dem Vendor Register gelesen (Read-Only). Neue Vendors werden im Vendor-Compliance-Modul angelegt und erscheinen hier automatisch. PDF-Druck fuer Art. 30 Abs. 2 Dokument.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'cookie-banner': {
|
||||
@@ -611,8 +621,8 @@ export const STEP_EXPLANATIONS = {
|
||||
},
|
||||
'obligations': {
|
||||
title: 'Pflichtenuebersicht',
|
||||
description: 'Alle regulatorischen Pflichten auf einen Blick',
|
||||
explanation: 'Die Pflichtenuebersicht aggregiert alle Anforderungen aus DSGVO, AI Act, NIS2 und weiteren Regulierungen. Sie sehen auf einen Blick, welche Pflichten fuer Ihr Unternehmen gelten.',
|
||||
description: 'Regulatorische Pflichten mit 12 Compliance-Checks und Vendor-Verknuepfung',
|
||||
explanation: 'Die Pflichtenuebersicht aggregiert alle Anforderungen aus DSGVO, AI Act, NIS2 und weiteren Regulierungen. 12 automatische Compliance-Checks pruefen Vollstaendigkeit, Fristen, Nachweise und Vendor-Verknuepfungen. Art.-28-Pflichten koennen mit Auftragsverarbeitern aus dem Vendor Register verknuepft werden. Das Pflichtenregister-Dokument (11 Sektionen) kann als auditfaehiges PDF gedruckt werden.',
|
||||
tips: [
|
||||
{
|
||||
icon: 'info' as const,
|
||||
@@ -621,15 +631,25 @@ export const STEP_EXPLANATIONS = {
|
||||
},
|
||||
{
|
||||
icon: 'warning' as const,
|
||||
title: 'Fristen',
|
||||
description: 'Achten Sie auf die Umsetzungsfristen. Einige Pflichten haben feste Deadlines.',
|
||||
title: 'Compliance-Checks',
|
||||
description: '12 automatische Checks: Fehlende Verantwortliche, ueberfaellige Fristen, fehlende Nachweise, keine Rechtsreferenz, stagnierende Regulierungen, nicht gestartete High-Priority-Pflichten, fehlende Vendor-Verknuepfung (Art. 28) u.v.m.',
|
||||
},
|
||||
{
|
||||
icon: 'success' as const,
|
||||
title: 'Vendor-Verknuepfung',
|
||||
description: 'Art.-28-Pflichten (Auftragsverarbeitung) koennen direkt mit Vendors aus dem Vendor Register verknuepft werden. Check #12 (MISSING_VENDOR_LINK) warnt bei fehlender Verknuepfung.',
|
||||
},
|
||||
{
|
||||
icon: 'lightbulb' as const,
|
||||
title: 'Pflichtenregister-Dokument',
|
||||
description: 'Generieren Sie ein auditfaehiges Pflichtenregister mit 11 Sektionen: Ziel, Geltungsbereich, Methodik, Regulatorische Grundlagen, Pflichtenuebersicht, Details, Verantwortlichkeiten, Fristen, Nachweisverzeichnis, Compliance-Status und Aenderungshistorie.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'loeschfristen': {
|
||||
title: 'Loeschfristen',
|
||||
description: 'Definieren Sie Aufbewahrungsrichtlinien fuer Ihre Daten',
|
||||
explanation: 'Loeschfristen legen fest, wie lange personenbezogene Daten gespeichert werden duerfen. Die 3-Stufen-Logik (Zweckende, Aufbewahrungspflicht, Legal Hold) stellt sicher, dass alle gesetzlichen Anforderungen beruecksichtigt werden.',
|
||||
description: 'Aufbewahrungsrichtlinien mit VVT-Verknuepfung und Vendor-Zuordnung',
|
||||
explanation: 'Loeschfristen legen fest, wie lange personenbezogene Daten gespeichert werden duerfen. Die 3-Stufen-Logik (Zweckende, Aufbewahrungspflicht, Legal Hold) stellt sicher, dass alle gesetzlichen Anforderungen beruecksichtigt werden. Policies koennen mit VVT-Verarbeitungstaetigkeiten und Auftragsverarbeitern aus dem Vendor Register verknuepft werden.',
|
||||
tips: [
|
||||
{
|
||||
icon: 'warning' as const,
|
||||
@@ -646,6 +666,11 @@ export const STEP_EXPLANATIONS = {
|
||||
title: 'Backup-Behandlung',
|
||||
description: 'Auch Backups muessen ins Loeschkonzept einbezogen werden. Daten koennen nach primaerer Loeschung noch in Backup-Systemen existieren.',
|
||||
},
|
||||
{
|
||||
icon: 'success' as const,
|
||||
title: 'Vendor-Verknuepfung',
|
||||
description: 'Loeschfrist-Policies koennen mit Auftragsverarbeitern verknuepft werden. So ist dokumentiert, welche Vendors Loeschpflichten fuer bestimmte Datenkategorien haben.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'consent': {
|
||||
@@ -716,6 +741,33 @@ export const STEP_EXPLANATIONS = {
|
||||
},
|
||||
],
|
||||
},
|
||||
'vendor-compliance': {
|
||||
title: 'Vendor Compliance',
|
||||
description: 'Auftragsverarbeiter-Management mit Cross-Modul-Integration',
|
||||
explanation: 'Vendor Compliance verwaltet alle Auftragsverarbeiter (Art. 28 DSGVO) und Drittanbieter. Fuer jeden Vendor werden AVVs, Drittlandtransfers, TOMs und Subunternehmer geprueft. Das Modul ist zentral mit vier weiteren Modulen integriert: VVT-Processor-Tab liest Vendors direkt aus der API, Obligations und Loeschfristen verknuepfen Vendors ueber linked_vendor_ids, TOM zeigt Vendor-Controls als Querverweis.',
|
||||
tips: [
|
||||
{
|
||||
icon: 'warning' as const,
|
||||
title: 'Art. 28 DSGVO',
|
||||
description: 'Jede Auftragsverarbeitung erfordert einen schriftlichen Vertrag (AVV). Pruefen Sie: Weisungsgebundenheit, TOMs, Subunternehmer-Genehmigung, Loeschpflicht und Audit-Recht.',
|
||||
},
|
||||
{
|
||||
icon: 'info' as const,
|
||||
title: 'Cross-Modul-Integration',
|
||||
description: 'Vendors erscheinen automatisch im VVT-Processor-Tab, koennen in Obligations und Loeschfristen verknuepft werden, und ihre TOM-Controls werden im TOM-Modul als Querverweis angezeigt.',
|
||||
},
|
||||
{
|
||||
icon: 'lightbulb' as const,
|
||||
title: 'Drittlandtransfer',
|
||||
description: 'Bei Datenverarbeitung ausserhalb der EU/EWR sind Standardvertragsklauseln (SCCs) oder andere Garantien nach Art. 44-49 DSGVO erforderlich.',
|
||||
},
|
||||
{
|
||||
icon: 'success' as const,
|
||||
title: 'Controls Library',
|
||||
description: '6 TOM-Domain Controls (VND-TOM-01 bis VND-TOM-06) pruefen Verschluesselung, Zugriffskontrolle, Verfuegbarkeit und Ueberpruefungsverfahren bei Ihren Auftragsverarbeitern.',
|
||||
},
|
||||
],
|
||||
},
|
||||
'document-generator': {
|
||||
title: 'Dokumentengenerator',
|
||||
description: 'Generieren Sie rechtliche Dokumente aus lizenzkonformen Vorlagen',
|
||||
@@ -852,18 +904,28 @@ export const STEP_EXPLANATIONS = {
|
||||
},
|
||||
'whistleblower': {
|
||||
title: 'Hinweisgebersystem',
|
||||
description: 'Meldestelle gemaess Hinweisgeberschutzgesetz (HinSchG)',
|
||||
explanation: 'Das Hinweisgebersystem bietet eine sichere, anonyme Meldestelle fuer Compliance-Verstoesse gemaess dem Hinweisgeberschutzgesetz (HinSchG). Unternehmen ab 50 Mitarbeitern sind zur Einrichtung verpflichtet.',
|
||||
description: 'Interne Meldestelle gemaess Hinweisgeberschutzgesetz (HinSchG) — seit 17. Dezember 2023 Pflicht fuer alle Unternehmen ab 50 Beschaeftigten',
|
||||
explanation: 'Das Hinweisgebersystem implementiert eine HinSchG-konforme interne Meldestelle fuer die sichere, auch anonyme Meldung von Rechtsverstoessen. Es setzt die EU-Whistleblowing-Richtlinie (2019/1937) in deutsches Recht um. Beschaeftigungsgeber mit mindestens 50 Beschaeftigten sind zur Einrichtung verpflichtet (§ 12 HinSchG). Das System unterstuetzt den gesamten Meldeprozess: Einreichung, Eingangsbestaetigung (7-Tage-Frist), Sachverhaltspruefung, Folgemaßnahmen und Rueckmeldung (3-Monate-Frist).',
|
||||
tips: [
|
||||
{
|
||||
icon: 'warning' as const,
|
||||
title: 'Pflicht ab 50 MA',
|
||||
description: 'Seit Juli 2023 muessen Unternehmen ab 50 Mitarbeitern eine interne Meldestelle einrichten (HinSchG §12).',
|
||||
title: 'Pflicht ab 50 Beschaeftigten',
|
||||
description: 'Seit 17.12.2023 gilt die Pflicht fuer ALLE Unternehmen ab 50 Beschaeftigten (§ 12 HinSchG). Verstoesse koennen mit Bussgeldern bis zu 50.000 EUR geahndet werden (§ 40 HinSchG).',
|
||||
},
|
||||
{
|
||||
icon: 'info' as const,
|
||||
title: 'Anonymitaet',
|
||||
description: 'Die Identitaet des Hinweisgebers muss geschuetzt werden. Repressalien gegen Hinweisgeber sind verboten.',
|
||||
title: 'Anonymitaet & Vertraulichkeit',
|
||||
description: 'Die Identitaet des Hinweisgebers ist streng vertraulich zu behandeln (§ 8 HinSchG). Anonyme Meldungen sollen bearbeitet werden. Repressalien sind verboten und loesen Schadensersatzpflicht aus (§ 36, § 37 HinSchG).',
|
||||
},
|
||||
{
|
||||
icon: 'lightbulb' as const,
|
||||
title: 'Gesetzliche Fristen',
|
||||
description: 'Eingangsbestaetigung innerhalb von 7 Tagen (§ 17 Abs. 1 S. 2). Rueckmeldung ueber ergriffene Folgemaßnahmen innerhalb von 3 Monaten nach Eingangsbestaetigung (§ 17 Abs. 2). Die Dokumentation muss 3 Jahre aufbewahrt werden (§ 11 HinSchG).',
|
||||
},
|
||||
{
|
||||
icon: 'warning' as const,
|
||||
title: 'Sachlicher Anwendungsbereich',
|
||||
description: 'Erfasst werden Verstoesse gegen EU-Recht und nationales Recht, u.a. Strafrecht, Datenschutz (DSGVO/BDSG), Arbeitsschutz, Umweltschutz, Geldwaesche, Produktsicherheit und Verbraucherschutz (§ 2 HinSchG).',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
554
admin-compliance/components/sdk/ai-act/DecisionTreeWizard.tsx
Normal file
554
admin-compliance/components/sdk/ai-act/DecisionTreeWizard.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface DecisionTreeQuestion {
|
||||
id: string
|
||||
axis: 'high_risk' | 'gpai'
|
||||
question: string
|
||||
description: string
|
||||
article_ref: string
|
||||
skip_if?: string
|
||||
}
|
||||
|
||||
interface DecisionTreeDefinition {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
questions: DecisionTreeQuestion[]
|
||||
}
|
||||
|
||||
interface DecisionTreeAnswer {
|
||||
question_id: string
|
||||
value: boolean
|
||||
note?: string
|
||||
}
|
||||
|
||||
interface GPAIClassification {
|
||||
is_gpai: boolean
|
||||
is_systemic_risk: boolean
|
||||
gpai_category: 'none' | 'standard' | 'systemic'
|
||||
applicable_articles: string[]
|
||||
obligations: string[]
|
||||
}
|
||||
|
||||
interface DecisionTreeResult {
|
||||
id: string
|
||||
tenant_id: string
|
||||
system_name: string
|
||||
system_description?: string
|
||||
answers: Record<string, DecisionTreeAnswer>
|
||||
high_risk_result: string
|
||||
gpai_result: GPAIClassification
|
||||
combined_obligations: string[]
|
||||
applicable_articles: string[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
const RISK_LEVEL_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
|
||||
unacceptable: { label: 'Unzulässig', color: 'text-red-700', bg: 'bg-red-50', border: 'border-red-200' },
|
||||
high_risk: { label: 'Hochrisiko', color: 'text-orange-700', bg: 'bg-orange-50', border: 'border-orange-200' },
|
||||
limited_risk: { label: 'Begrenztes Risiko', color: 'text-yellow-700', bg: 'bg-yellow-50', border: 'border-yellow-200' },
|
||||
minimal_risk: { label: 'Minimales Risiko', color: 'text-green-700', bg: 'bg-green-50', border: 'border-green-200' },
|
||||
not_applicable: { label: 'Nicht anwendbar', color: 'text-gray-500', bg: 'bg-gray-50', border: 'border-gray-200' },
|
||||
}
|
||||
|
||||
const GPAI_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
|
||||
none: { label: 'Kein GPAI', color: 'text-gray-500', bg: 'bg-gray-50', border: 'border-gray-200' },
|
||||
standard: { label: 'GPAI Standard', color: 'text-blue-700', bg: 'bg-blue-50', border: 'border-blue-200' },
|
||||
systemic: { label: 'GPAI Systemisches Risiko', color: 'text-purple-700', bg: 'bg-purple-50', border: 'border-purple-200' },
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export default function DecisionTreeWizard() {
|
||||
const [definition, setDefinition] = useState<DecisionTreeDefinition | null>(null)
|
||||
const [answers, setAnswers] = useState<Record<string, DecisionTreeAnswer>>({})
|
||||
const [currentIdx, setCurrentIdx] = useState(0)
|
||||
const [systemName, setSystemName] = useState('')
|
||||
const [systemDescription, setSystemDescription] = useState('')
|
||||
const [result, setResult] = useState<DecisionTreeResult | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [phase, setPhase] = useState<'intro' | 'questions' | 'result'>('intro')
|
||||
|
||||
// Load decision tree definition
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/ucca/decision-tree')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setDefinition(data)
|
||||
} else {
|
||||
setError('Entscheidungsbaum konnte nicht geladen werden')
|
||||
}
|
||||
} catch {
|
||||
setError('Verbindung zum Backend fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
// Get visible questions (respecting skip logic)
|
||||
const getVisibleQuestions = useCallback((): DecisionTreeQuestion[] => {
|
||||
if (!definition) return []
|
||||
return definition.questions.filter(q => {
|
||||
if (!q.skip_if) return true
|
||||
// Skip this question if the gate question was answered "no"
|
||||
const gateAnswer = answers[q.skip_if]
|
||||
if (gateAnswer && !gateAnswer.value) return false
|
||||
return true
|
||||
})
|
||||
}, [definition, answers])
|
||||
|
||||
const visibleQuestions = getVisibleQuestions()
|
||||
const currentQuestion = visibleQuestions[currentIdx]
|
||||
const totalVisible = visibleQuestions.length
|
||||
|
||||
const highRiskQuestions = visibleQuestions.filter(q => q.axis === 'high_risk')
|
||||
const gpaiQuestions = visibleQuestions.filter(q => q.axis === 'gpai')
|
||||
|
||||
const handleAnswer = (value: boolean) => {
|
||||
if (!currentQuestion) return
|
||||
setAnswers(prev => ({
|
||||
...prev,
|
||||
[currentQuestion.id]: {
|
||||
question_id: currentQuestion.id,
|
||||
value,
|
||||
},
|
||||
}))
|
||||
|
||||
// Auto-advance
|
||||
if (currentIdx < totalVisible - 1) {
|
||||
setCurrentIdx(prev => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentIdx > 0) {
|
||||
setCurrentIdx(prev => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/ucca/decision-tree/evaluate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
system_name: systemName,
|
||||
system_description: systemDescription,
|
||||
answers,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setResult(data)
|
||||
setPhase('result')
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: 'Auswertung fehlgeschlagen' }))
|
||||
setError(err.error || 'Auswertung fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
setError('Verbindung zum Backend fehlgeschlagen')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setAnswers({})
|
||||
setCurrentIdx(0)
|
||||
setSystemName('')
|
||||
setSystemDescription('')
|
||||
setResult(null)
|
||||
setPhase('intro')
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const allAnswered = visibleQuestions.every(q => answers[q.id] !== undefined)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-10 h-10 border-2 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-500">Entscheidungsbaum wird geladen...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && !definition) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
|
||||
<p className="text-red-700">{error}</p>
|
||||
<p className="text-red-500 text-sm mt-2">Bitte stellen Sie sicher, dass der AI Compliance SDK Service läuft.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// INTRO PHASE
|
||||
// =========================================================================
|
||||
if (phase === 'intro') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">AI Act Entscheidungsbaum</h3>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Klassifizieren Sie Ihr KI-System anhand von 12 Fragen auf zwei Achsen:
|
||||
<strong> High-Risk</strong> (Anhang III) und <strong>GPAI</strong> (Art. 51–56).
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div className="p-4 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg className="w-5 h-5 text-orange-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126z" />
|
||||
</svg>
|
||||
<span className="font-medium text-orange-700">Achse 1: High-Risk</span>
|
||||
</div>
|
||||
<p className="text-sm text-orange-600">7 Fragen zu Anhang III Kategorien (Biometrie, kritische Infrastruktur, Bildung, Beschäftigung, etc.)</p>
|
||||
</div>
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||||
</svg>
|
||||
<span className="font-medium text-blue-700">Achse 2: GPAI</span>
|
||||
</div>
|
||||
<p className="text-sm text-blue-600">5 Fragen zu General-Purpose AI (Foundation Models, systemisches Risiko, Art. 51–56)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name des KI-Systems *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={systemName}
|
||||
onChange={e => setSystemName(e.target.value)}
|
||||
placeholder="z.B. Dokumenten-Analyse-KI, Chatbot-Service, Code-Assistent"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung (optional)</label>
|
||||
<textarea
|
||||
value={systemDescription}
|
||||
onChange={e => setSystemDescription(e.target.value)}
|
||||
placeholder="Kurze Beschreibung des KI-Systems und seines Einsatzzwecks..."
|
||||
rows={2}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={() => setPhase('questions')}
|
||||
disabled={!systemName.trim()}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
systemName.trim()
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Klassifizierung starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// RESULT PHASE
|
||||
// =========================================================================
|
||||
if (phase === 'result' && result) {
|
||||
const riskConfig = RISK_LEVEL_CONFIG[result.high_risk_result] || RISK_LEVEL_CONFIG.not_applicable
|
||||
const gpaiConfig = GPAI_CONFIG[result.gpai_result.gpai_category] || GPAI_CONFIG.none
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Klassifizierungsergebnis: {result.system_name}</h3>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-4 py-2 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||||
>
|
||||
Neue Klassifizierung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Two-Axis Result Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div className={`p-5 rounded-xl border-2 ${riskConfig.border} ${riskConfig.bg}`}>
|
||||
<div className="text-sm font-medium text-gray-500 mb-1">Achse 1: High-Risk (Anhang III)</div>
|
||||
<div className={`text-xl font-bold ${riskConfig.color}`}>{riskConfig.label}</div>
|
||||
</div>
|
||||
<div className={`p-5 rounded-xl border-2 ${gpaiConfig.border} ${gpaiConfig.bg}`}>
|
||||
<div className="text-sm font-medium text-gray-500 mb-1">Achse 2: GPAI (Art. 51–56)</div>
|
||||
<div className={`text-xl font-bold ${gpaiConfig.color}`}>{gpaiConfig.label}</div>
|
||||
{result.gpai_result.is_systemic_risk && (
|
||||
<div className="mt-1 text-xs text-purple-600 font-medium">Systemisches Risiko</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Applicable Articles */}
|
||||
{result.applicable_articles && result.applicable_articles.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">Anwendbare Artikel</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{result.applicable_articles.map(art => (
|
||||
<span key={art} className="px-3 py-1 text-xs bg-indigo-50 text-indigo-700 rounded-full border border-indigo-200">
|
||||
{art}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Combined Obligations */}
|
||||
{result.combined_obligations && result.combined_obligations.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">
|
||||
Pflichten ({result.combined_obligations.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{result.combined_obligations.map((obl, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm">
|
||||
<svg className="w-4 h-4 text-purple-500 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-gray-700">{obl}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GPAI-specific obligations */}
|
||||
{result.gpai_result.is_gpai && result.gpai_result.obligations.length > 0 && (
|
||||
<div className="bg-blue-50 rounded-xl border border-blue-200 p-6">
|
||||
<h4 className="text-sm font-semibold text-blue-900 mb-3">
|
||||
GPAI-spezifische Pflichten ({result.gpai_result.obligations.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{result.gpai_result.obligations.map((obl, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm">
|
||||
<svg className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
</svg>
|
||||
<span className="text-blue-800">{obl}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Answer Summary */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">Ihre Antworten</h4>
|
||||
<div className="space-y-2">
|
||||
{definition?.questions.map(q => {
|
||||
const answer = result.answers[q.id]
|
||||
if (!answer) return null
|
||||
return (
|
||||
<div key={q.id} className="flex items-center gap-3 text-sm py-1.5 border-b border-gray-100 last:border-0">
|
||||
<span className="text-xs font-mono text-gray-400 w-8">{q.id}</span>
|
||||
<span className="flex-1 text-gray-600">{q.question}</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
answer.value ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
{answer.value ? 'Ja' : 'Nein'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// QUESTIONS PHASE
|
||||
// =========================================================================
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Progress */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{systemName} — Frage {currentIdx + 1} von {totalVisible}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full font-medium ${
|
||||
currentQuestion?.axis === 'high_risk'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{currentQuestion?.axis === 'high_risk' ? 'High-Risk' : 'GPAI'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Dual progress bar */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="text-[10px] text-orange-600 mb-1 font-medium">
|
||||
Achse 1: High-Risk ({highRiskQuestions.filter(q => answers[q.id] !== undefined).length}/{highRiskQuestions.length})
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-orange-500 rounded-full transition-all"
|
||||
style={{ width: `${highRiskQuestions.length ? (highRiskQuestions.filter(q => answers[q.id] !== undefined).length / highRiskQuestions.length) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-[10px] text-blue-600 mb-1 font-medium">
|
||||
Achse 2: GPAI ({gpaiQuestions.filter(q => answers[q.id] !== undefined).length}/{gpaiQuestions.length})
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{ width: `${gpaiQuestions.length ? (gpaiQuestions.filter(q => answers[q.id] !== undefined).length / gpaiQuestions.length) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Question */}
|
||||
{currentQuestion && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<span className="px-2 py-1 text-xs font-mono bg-gray-100 text-gray-500 rounded">{currentQuestion.id}</span>
|
||||
<span className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded">{currentQuestion.article_ref}</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">{currentQuestion.question}</h3>
|
||||
<p className="text-sm text-gray-500 mb-6">{currentQuestion.description}</p>
|
||||
|
||||
{/* Answer buttons */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => handleAnswer(true)}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-center font-medium ${
|
||||
answers[currentQuestion.id]?.value === true
|
||||
? 'border-green-500 bg-green-50 text-green-700'
|
||||
: 'border-gray-200 hover:border-green-300 hover:bg-green-50/50 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-8 h-8 mx-auto mb-2 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Ja
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAnswer(false)}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-center font-medium ${
|
||||
answers[currentQuestion.id]?.value === false
|
||||
? 'border-gray-500 bg-gray-50 text-gray-700'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50/50 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-8 h-8 mx-auto mb-2 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Nein
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={currentIdx === 0 ? () => setPhase('intro') : handleBack}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{visibleQuestions.map((q, i) => (
|
||||
<button
|
||||
key={q.id}
|
||||
onClick={() => setCurrentIdx(i)}
|
||||
className={`w-2.5 h-2.5 rounded-full transition-colors ${
|
||||
i === currentIdx
|
||||
? q.axis === 'high_risk' ? 'bg-orange-500' : 'bg-blue-500'
|
||||
: answers[q.id] !== undefined
|
||||
? 'bg-green-400'
|
||||
: 'bg-gray-200'
|
||||
}`}
|
||||
title={`${q.id}: ${q.question}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{allAnswered ? (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={saving}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
saving
|
||||
? 'bg-purple-300 text-white cursor-wait'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
{saving ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Auswertung...
|
||||
</span>
|
||||
) : (
|
||||
'Auswerten'
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setCurrentIdx(prev => Math.min(prev + 1, totalVisible - 1))}
|
||||
disabled={currentIdx >= totalVisible - 1}
|
||||
className="px-4 py-2 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors disabled:opacity-30"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -58,22 +58,23 @@ export function ScopeDecisionTab({
|
||||
return 'from-green-500 to-green-600'
|
||||
}
|
||||
|
||||
const getSeverityBadge = (severity: 'low' | 'medium' | 'high' | 'critical') => {
|
||||
const colors = {
|
||||
const getSeverityBadge = (severity: string) => {
|
||||
const s = severity.toLowerCase()
|
||||
const colors: Record<string, string> = {
|
||||
low: 'bg-gray-100 text-gray-800',
|
||||
medium: 'bg-yellow-100 text-yellow-800',
|
||||
high: 'bg-orange-100 text-orange-800',
|
||||
critical: 'bg-red-100 text-red-800',
|
||||
}
|
||||
const labels = {
|
||||
const labels: Record<string, string> = {
|
||||
low: 'Niedrig',
|
||||
medium: 'Mittel',
|
||||
high: 'Hoch',
|
||||
critical: 'Kritisch',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[severity]}`}>
|
||||
{labels[severity]}
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[s] || colors.medium}`}>
|
||||
{labels[s] || severity}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -99,20 +100,20 @@ export function ScopeDecisionTab({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Level Determination */}
|
||||
<div className={`${DEPTH_LEVEL_COLORS[decision.level].bg} border-2 ${DEPTH_LEVEL_COLORS[decision.level].border} rounded-xl p-6`}>
|
||||
<div className={`${DEPTH_LEVEL_COLORS[decision.determinedLevel].bg} border-2 ${DEPTH_LEVEL_COLORS[decision.determinedLevel].border} rounded-xl p-6`}>
|
||||
<div className="flex items-start gap-6">
|
||||
<div className={`flex-shrink-0 w-20 h-20 ${DEPTH_LEVEL_COLORS[decision.level].badge} rounded-xl flex items-center justify-center`}>
|
||||
<span className={`text-3xl font-bold ${DEPTH_LEVEL_COLORS[decision.level].text}`}>
|
||||
{decision.level}
|
||||
<div className={`flex-shrink-0 w-20 h-20 ${DEPTH_LEVEL_COLORS[decision.determinedLevel].badge} rounded-xl flex items-center justify-center`}>
|
||||
<span className={`text-3xl font-bold ${DEPTH_LEVEL_COLORS[decision.determinedLevel].text}`}>
|
||||
{decision.determinedLevel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className={`text-2xl font-bold ${DEPTH_LEVEL_COLORS[decision.level].text} mb-2`}>
|
||||
{DEPTH_LEVEL_LABELS[decision.level]}
|
||||
<h2 className={`text-2xl font-bold ${DEPTH_LEVEL_COLORS[decision.determinedLevel].text} mb-2`}>
|
||||
{DEPTH_LEVEL_LABELS[decision.determinedLevel]}
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-3">{DEPTH_LEVEL_DESCRIPTIONS[decision.level]}</p>
|
||||
{decision.reasoning && (
|
||||
<p className="text-sm text-gray-600 italic">{decision.reasoning}</p>
|
||||
<p className="text-gray-700 mb-3">{DEPTH_LEVEL_DESCRIPTIONS[decision.determinedLevel]}</p>
|
||||
{decision.reasoning && decision.reasoning.length > 0 && (
|
||||
<p className="text-sm text-gray-600 italic">{decision.reasoning.map(r => r.description).filter(Boolean).join('. ')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,11 +124,11 @@ export function ScopeDecisionTab({
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Score-Analyse</h3>
|
||||
<div className="space-y-4">
|
||||
{renderScoreBar('Risiko-Score', decision.scores.riskScore)}
|
||||
{renderScoreBar('Komplexitäts-Score', decision.scores.complexityScore)}
|
||||
{renderScoreBar('Assurance-Score', decision.scores.assuranceScore)}
|
||||
{renderScoreBar('Risiko-Score', decision.scores.risk_score)}
|
||||
{renderScoreBar('Komplexitäts-Score', decision.scores.complexity_score)}
|
||||
{renderScoreBar('Assurance-Score', decision.scores.assurance_need)}
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
{renderScoreBar('Gesamt-Score', decision.scores.compositeScore)}
|
||||
{renderScoreBar('Gesamt-Score', decision.scores.composite_score)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,16 +232,14 @@ export function ScopeDecisionTab({
|
||||
)}
|
||||
|
||||
{/* Hard Triggers */}
|
||||
{decision.hardTriggers && decision.hardTriggers.length > 0 && (
|
||||
{decision.triggeredHardTriggers && decision.triggeredHardTriggers.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Hard-Trigger</h3>
|
||||
<div className="space-y-3">
|
||||
{decision.hardTriggers.map((trigger, idx) => (
|
||||
{decision.triggeredHardTriggers.map((trigger, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`border rounded-lg overflow-hidden ${
|
||||
trigger.matched ? 'border-red-300 bg-red-50' : 'border-gray-200 bg-gray-50'
|
||||
}`}
|
||||
className="border rounded-lg overflow-hidden border-red-300 bg-red-50"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -248,17 +247,18 @@ export function ScopeDecisionTab({
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-opacity-80 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{trigger.matched && (
|
||||
<svg className="w-5 h-5 text-red-600" 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>
|
||||
)}
|
||||
<span className="font-medium text-gray-900">{trigger.label}</span>
|
||||
<svg className="w-5 h-5 text-red-600" 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>
|
||||
<span className="font-medium text-gray-900">{trigger.description}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-red-200 text-red-800 font-medium">
|
||||
Min. {trigger.minimumLevel}
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-500 transition-transform ${
|
||||
@@ -279,11 +279,14 @@ export function ScopeDecisionTab({
|
||||
<span className="font-medium">Rechtsgrundlage:</span> {trigger.legalReference}
|
||||
</p>
|
||||
)}
|
||||
{trigger.matchedValue && (
|
||||
{trigger.mandatoryDocuments && trigger.mandatoryDocuments.length > 0 && (
|
||||
<p className="text-xs text-gray-700">
|
||||
<span className="font-medium">Erfasster Wert:</span> {trigger.matchedValue}
|
||||
<span className="font-medium">Pflichtdokumente:</span> {trigger.mandatoryDocuments.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{trigger.requiresDSFA && (
|
||||
<p className="text-xs text-orange-700 font-medium mt-1">DSFA erforderlich</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -300,10 +303,10 @@ export function ScopeDecisionTab({
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Typ</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Tiefe</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Dokument</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Priorität</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Aufwand</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Status</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Trigger</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -315,21 +318,21 @@ export function ScopeDecisionTab({
|
||||
<span className="font-medium text-gray-900">
|
||||
{DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType}
|
||||
</span>
|
||||
{doc.isMandatory && (
|
||||
{doc.requirement === 'mandatory' && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
|
||||
Pflicht
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-700">{doc.depthDescription}</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-700 capitalize">{doc.priority}</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-700">
|
||||
{doc.effortEstimate ? `${doc.effortEstimate.days} Tage` : '-'}
|
||||
{doc.estimatedEffort ? `${doc.estimatedEffort}h` : '-'}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
{doc.triggeredByHardTrigger && (
|
||||
{doc.triggeredBy && doc.triggeredBy.length > 0 && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
|
||||
Hard-Trigger
|
||||
{doc.triggeredBy.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
@@ -359,10 +362,12 @@ export function ScopeDecisionTab({
|
||||
{decision.riskFlags.map((flag, idx) => (
|
||||
<div key={idx} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-semibold text-gray-900">{flag.title}</h4>
|
||||
<h4 className="font-semibold text-gray-900">{flag.message}</h4>
|
||||
{getSeverityBadge(flag.severity)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 mb-2">{flag.description}</p>
|
||||
{flag.legalReference && (
|
||||
<p className="text-xs text-gray-500 mb-2">{flag.legalReference}</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-medium">Empfehlung:</span> {flag.recommendation}
|
||||
</p>
|
||||
@@ -373,33 +378,26 @@ export function ScopeDecisionTab({
|
||||
)}
|
||||
|
||||
{/* Gap Analysis */}
|
||||
{decision.gapAnalysis && decision.gapAnalysis.length > 0 && (
|
||||
{decision.gaps && decision.gaps.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Gap-Analyse</h3>
|
||||
<div className="space-y-4">
|
||||
{decision.gapAnalysis.map((gap, idx) => (
|
||||
{decision.gaps.map((gap, idx) => (
|
||||
<div key={idx} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-semibold text-gray-900">{gap.title}</h4>
|
||||
<h4 className="font-semibold text-gray-900">{gap.description}</h4>
|
||||
{getSeverityBadge(gap.severity)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 mb-2">{gap.description}</p>
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
<span className="font-medium">Empfehlung:</span> {gap.recommendation}
|
||||
<p className="text-sm text-gray-700 mb-2">
|
||||
<span className="font-medium">Ist:</span> {gap.currentState}
|
||||
</p>
|
||||
{gap.relatedDocuments && gap.relatedDocuments.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<span className="text-xs text-gray-500">Betroffene Dokumente: </span>
|
||||
{gap.relatedDocuments.map((doc, docIdx) => (
|
||||
<span
|
||||
key={docIdx}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 mr-1"
|
||||
>
|
||||
{DOCUMENT_TYPE_LABELS[doc] || doc}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
<span className="font-medium">Soll:</span> {gap.targetState}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>Aufwand: ~{gap.effort}h</span>
|
||||
<span>Level: {gap.requiredFor}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -414,21 +412,21 @@ export function ScopeDecisionTab({
|
||||
{decision.nextActions.map((action, idx) => (
|
||||
<div key={idx} className="flex gap-4">
|
||||
<div className="flex-shrink-0 w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-purple-700">{action.priority}</span>
|
||||
<span className="text-sm font-bold text-purple-700">{idx + 1}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900 mb-1">{action.title}</h4>
|
||||
<p className="text-sm text-gray-700 mb-2">{action.description}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{action.effortDays && (
|
||||
{action.estimatedEffort > 0 && (
|
||||
<span className="text-xs text-gray-600">
|
||||
<span className="font-medium">Aufwand:</span> {action.effortDays} Tage
|
||||
<span className="font-medium">Aufwand:</span> ~{action.estimatedEffort}h
|
||||
</span>
|
||||
)}
|
||||
{action.relatedDocuments && action.relatedDocuments.length > 0 && (
|
||||
<span className="text-xs text-gray-600">
|
||||
<span className="font-medium">Dokumente:</span> {action.relatedDocuments.length}
|
||||
</span>
|
||||
{action.sdkStepUrl && (
|
||||
<a href={action.sdkStepUrl} className="text-xs text-purple-600 hover:text-purple-700">
|
||||
Zum SDK-Schritt →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -467,8 +465,8 @@ export function ScopeDecisionTab({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Audit Trail */}
|
||||
{decision.auditTrail && decision.auditTrail.length > 0 && (
|
||||
{/* Audit Trail (from reasoning) */}
|
||||
{decision.reasoning && decision.reasoning.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<button
|
||||
type="button"
|
||||
@@ -487,17 +485,20 @@ export function ScopeDecisionTab({
|
||||
</button>
|
||||
{showAuditTrail && (
|
||||
<div className="space-y-3">
|
||||
{decision.auditTrail.map((entry, idx) => (
|
||||
{decision.reasoning.map((entry, idx) => (
|
||||
<div key={idx} className="border-l-2 border-purple-300 pl-4 py-2">
|
||||
<h4 className="font-medium text-gray-900 mb-1">{entry.step}</h4>
|
||||
<p className="text-sm text-gray-700 mb-2">{entry.description}</p>
|
||||
{entry.details && entry.details.length > 0 && (
|
||||
{entry.factors && entry.factors.length > 0 && (
|
||||
<ul className="text-xs text-gray-600 space-y-1">
|
||||
{entry.details.map((detail, detailIdx) => (
|
||||
<li key={detailIdx}>• {detail}</li>
|
||||
{entry.factors.map((factor, factorIdx) => (
|
||||
<li key={factorIdx}>• {factor}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{entry.impact && (
|
||||
<p className="text-xs text-purple-700 font-medium mt-1">{entry.impact}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -31,12 +31,12 @@ export function ScopeExportTab({ decision: decisionProp, answers: answersProp, s
|
||||
const handleDownloadCSV = useCallback(() => {
|
||||
if (!decision || !decision.requiredDocuments) return
|
||||
|
||||
const headers = ['Typ', 'Tiefe', 'Aufwand (Tage)', 'Pflicht', 'Hard-Trigger']
|
||||
const headers = ['Typ', 'Priorität', 'Aufwand (Stunden)', 'Pflicht', 'Hard-Trigger']
|
||||
const rows = decision.requiredDocuments.map((doc) => [
|
||||
DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType,
|
||||
doc.depth,
|
||||
doc.estimatedEffort || '0',
|
||||
doc.required ? 'Ja' : 'Nein',
|
||||
doc.priority,
|
||||
String(doc.estimatedEffort || 0),
|
||||
doc.requirement === 'mandatory' ? 'Ja' : 'Nein',
|
||||
doc.triggeredBy.length > 0 ? 'Ja' : 'Nein',
|
||||
])
|
||||
|
||||
@@ -58,8 +58,8 @@ export function ScopeExportTab({ decision: decisionProp, answers: answersProp, s
|
||||
markdown += `**Datum:** ${new Date().toLocaleDateString('de-DE')}\n\n`
|
||||
markdown += `## Einstufung\n\n`
|
||||
markdown += `**Level:** ${decision.determinedLevel} - ${DEPTH_LEVEL_LABELS[decision.determinedLevel]}\n\n`
|
||||
if (decision.reasoning) {
|
||||
markdown += `**Begründung:** ${decision.reasoning}\n\n`
|
||||
if (decision.reasoning && decision.reasoning.length > 0) {
|
||||
markdown += `**Begründung:** ${decision.reasoning.map(r => r.description).filter(Boolean).join('. ')}\n\n`
|
||||
}
|
||||
|
||||
if (decision.scores) {
|
||||
@@ -73,10 +73,10 @@ export function ScopeExportTab({ decision: decisionProp, answers: answersProp, s
|
||||
if (decision.triggeredHardTriggers && decision.triggeredHardTriggers.length > 0) {
|
||||
markdown += `## Aktive Hard-Trigger\n\n`
|
||||
decision.triggeredHardTriggers.forEach((trigger) => {
|
||||
markdown += `- **${trigger.rule.label}**\n`
|
||||
markdown += ` - ${trigger.rule.description}\n`
|
||||
if (trigger.rule.legalReference) {
|
||||
markdown += ` - Rechtsgrundlage: ${trigger.rule.legalReference}\n`
|
||||
markdown += `- **${trigger.description}**\n`
|
||||
markdown += ` - ${trigger.description}\n`
|
||||
if (trigger.legalReference) {
|
||||
markdown += ` - Rechtsgrundlage: ${trigger.legalReference}\n`
|
||||
}
|
||||
})
|
||||
markdown += `\n`
|
||||
@@ -84,12 +84,12 @@ export function ScopeExportTab({ decision: decisionProp, answers: answersProp, s
|
||||
|
||||
if (decision.requiredDocuments && decision.requiredDocuments.length > 0) {
|
||||
markdown += `## Erforderliche Dokumente\n\n`
|
||||
markdown += `| Typ | Tiefe | Aufwand | Pflicht | Hard-Trigger |\n`
|
||||
markdown += `|-----|-------|---------|---------|-------------|\n`
|
||||
markdown += `| Typ | Priorität | Aufwand (h) | Pflicht | Hard-Trigger |\n`
|
||||
markdown += `|-----|-----------|-------------|---------|-------------|\n`
|
||||
decision.requiredDocuments.forEach((doc) => {
|
||||
markdown += `| ${DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType} | ${doc.depth} | ${
|
||||
doc.estimatedEffort || '0'
|
||||
} | ${doc.required ? 'Ja' : 'Nein'} | ${doc.triggeredBy.length > 0 ? 'Ja' : 'Nein'} |\n`
|
||||
markdown += `| ${DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType} | ${doc.priority} | ${
|
||||
doc.estimatedEffort || 0
|
||||
} | ${doc.requirement === 'mandatory' ? 'Ja' : 'Nein'} | ${doc.triggeredBy.length > 0 ? 'Ja' : 'Nein'} |\n`
|
||||
})
|
||||
markdown += `\n`
|
||||
}
|
||||
@@ -97,19 +97,29 @@ export function ScopeExportTab({ decision: decisionProp, answers: answersProp, s
|
||||
if (decision.riskFlags && decision.riskFlags.length > 0) {
|
||||
markdown += `## Risiko-Flags\n\n`
|
||||
decision.riskFlags.forEach((flag) => {
|
||||
markdown += `### ${flag.title} (${flag.severity})\n\n`
|
||||
markdown += `${flag.description}\n\n`
|
||||
markdown += `### ${flag.message} (${flag.severity})\n\n`
|
||||
if (flag.legalReference) markdown += `Rechtsgrundlage: ${flag.legalReference}\n\n`
|
||||
markdown += `**Empfehlung:** ${flag.recommendation}\n\n`
|
||||
})
|
||||
}
|
||||
|
||||
if (decision.gaps && decision.gaps.length > 0) {
|
||||
markdown += `## Gap-Analyse\n\n`
|
||||
decision.gaps.forEach((gap) => {
|
||||
markdown += `### ${gap.description} (${gap.severity})\n\n`
|
||||
markdown += `- **Ist:** ${gap.currentState}\n`
|
||||
markdown += `- **Soll:** ${gap.targetState}\n`
|
||||
markdown += `- **Aufwand:** ~${gap.effort}h\n\n`
|
||||
})
|
||||
}
|
||||
|
||||
if (decision.nextActions && decision.nextActions.length > 0) {
|
||||
markdown += `## Nächste Schritte\n\n`
|
||||
decision.nextActions.forEach((action) => {
|
||||
markdown += `${action.priority}. **${action.title}**\n`
|
||||
decision.nextActions.forEach((action, idx) => {
|
||||
markdown += `${idx + 1}. **${action.title}**\n`
|
||||
markdown += ` ${action.description}\n`
|
||||
if (action.estimatedEffort) {
|
||||
markdown += ` Aufwand: ${action.estimatedEffort}\n`
|
||||
markdown += ` Aufwand: ~${action.estimatedEffort}h\n`
|
||||
}
|
||||
markdown += `\n`
|
||||
})
|
||||
|
||||
@@ -51,7 +51,7 @@ export function ScopeOverviewTab({ scopeState, completionStats, onStartProfiling
|
||||
}
|
||||
|
||||
const renderLevelBadge = () => {
|
||||
if (!decision?.level) {
|
||||
if (!decision?.determinedLevel) {
|
||||
return (
|
||||
<div className="bg-gray-100 border border-gray-300 rounded-xl p-8 text-center">
|
||||
<div className="inline-flex items-center justify-center w-24 h-24 bg-gray-200 rounded-full mb-4">
|
||||
@@ -65,28 +65,22 @@ export function ScopeOverviewTab({ scopeState, completionStats, onStartProfiling
|
||||
)
|
||||
}
|
||||
|
||||
const levelColors = DEPTH_LEVEL_COLORS[decision.level]
|
||||
const levelColors = DEPTH_LEVEL_COLORS[decision.determinedLevel]
|
||||
return (
|
||||
<div className={`${levelColors.bg} border-2 ${levelColors.border} rounded-xl p-8 text-center`}>
|
||||
<div className={`inline-flex items-center justify-center w-24 h-24 ${levelColors.badge} rounded-full mb-4`}>
|
||||
<span className={`text-4xl font-bold ${levelColors.text}`}>{decision.level}</span>
|
||||
<span className={`text-4xl font-bold ${levelColors.text}`}>{decision.determinedLevel}</span>
|
||||
</div>
|
||||
<h3 className={`text-xl font-semibold ${levelColors.text} mb-2`}>
|
||||
{DEPTH_LEVEL_LABELS[decision.level]}
|
||||
{DEPTH_LEVEL_LABELS[decision.determinedLevel]}
|
||||
</h3>
|
||||
<p className="text-gray-600">{DEPTH_LEVEL_DESCRIPTIONS[decision.level]}</p>
|
||||
<p className="text-gray-600">{DEPTH_LEVEL_DESCRIPTIONS[decision.determinedLevel]}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderActiveHardTriggers = () => {
|
||||
if (!decision?.hardTriggers || decision.hardTriggers.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const activeHardTriggers = decision.hardTriggers.filter((ht) => ht.matched)
|
||||
|
||||
if (activeHardTriggers.length === 0) {
|
||||
if (!decision?.triggeredHardTriggers || decision.triggeredHardTriggers.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -104,23 +98,22 @@ export function ScopeOverviewTab({ scopeState, completionStats, onStartProfiling
|
||||
<h3 className="text-lg font-semibold text-gray-900">Aktive Hard-Trigger</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{activeHardTriggers.map((trigger, idx) => (
|
||||
{decision.triggeredHardTriggers.map((trigger, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="border-l-4 border-red-500 bg-red-50 rounded-r-lg p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900">{trigger.label}</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">{trigger.description}</p>
|
||||
<h4 className="font-semibold text-gray-900">{trigger.description}</h4>
|
||||
{trigger.legalReference && (
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
<span className="font-medium">Rechtsgrundlage:</span> {trigger.legalReference}
|
||||
</p>
|
||||
)}
|
||||
{trigger.matchedValue && (
|
||||
{trigger.minimumLevel && (
|
||||
<p className="text-xs text-gray-700 mt-1">
|
||||
<span className="font-medium">Erfasster Wert:</span> {trigger.matchedValue}
|
||||
<span className="font-medium">Mindest-Level:</span> {trigger.minimumLevel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -137,12 +130,9 @@ export function ScopeOverviewTab({ scopeState, completionStats, onStartProfiling
|
||||
return null
|
||||
}
|
||||
|
||||
const mandatoryDocs = decision.requiredDocuments.filter((doc) => doc.isMandatory)
|
||||
const optionalDocs = decision.requiredDocuments.filter((doc) => !doc.isMandatory)
|
||||
const totalEffortDays = decision.requiredDocuments.reduce(
|
||||
(sum, doc) => sum + (doc.effortEstimate?.days ?? 0),
|
||||
0
|
||||
)
|
||||
const mandatoryDocs = decision.requiredDocuments.filter((doc) => doc.requirement === 'mandatory')
|
||||
const optionalDocs = decision.requiredDocuments.filter((doc) => doc.requirement === 'recommended')
|
||||
const totalEffortHours = decision.requiredDocuments.reduce((sum, doc) => sum + (doc.estimatedEffort ?? 0), 0)
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
@@ -157,8 +147,8 @@ export function ScopeOverviewTab({ scopeState, completionStats, onStartProfiling
|
||||
<div className="text-sm text-gray-600 mt-1">Optional</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-gray-900">{totalEffortDays}</div>
|
||||
<div className="text-sm text-gray-600 mt-1">Tage Aufwand (geschätzt)</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{totalEffortHours}h</div>
|
||||
<div className="text-sm text-gray-600 mt-1">Aufwand (geschätzt)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -227,11 +217,11 @@ export function ScopeOverviewTab({ scopeState, completionStats, onStartProfiling
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Score-Übersicht</h3>
|
||||
<div className="space-y-4">
|
||||
{renderScoreGauge('Risiko-Score', decision.scores?.riskScore)}
|
||||
{renderScoreGauge('Komplexitäts-Score', decision.scores?.complexityScore)}
|
||||
{renderScoreGauge('Assurance-Score', decision.scores?.assuranceScore)}
|
||||
{renderScoreGauge('Risiko-Score', decision.scores?.risk_score)}
|
||||
{renderScoreGauge('Komplexitäts-Score', decision.scores?.complexity_score)}
|
||||
{renderScoreGauge('Assurance-Score', decision.scores?.assurance_need)}
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
{renderScoreGauge('Gesamt-Score', decision.scores?.compositeScore)}
|
||||
{renderScoreGauge('Gesamt-Score', decision.scores?.composite_score)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import type { ScopeProfilingAnswer, ScopeProfilingQuestion } from '@/lib/sdk/compliance-scope-types'
|
||||
import { SCOPE_QUESTION_BLOCKS, getBlockProgress, getTotalProgress, getAnswerValue, prefillFromCompanyProfile, getProfileInfoForBlock, getAutoFilledScoringAnswers } from '@/lib/sdk/compliance-scope-profiling'
|
||||
import { SCOPE_QUESTION_BLOCKS, getBlockProgress, getTotalProgress, getAnswerValue, prefillFromCompanyProfile, getProfileInfoForBlock, getAutoFilledScoringAnswers, getUnansweredRequiredQuestions } from '@/lib/sdk/compliance-scope-profiling'
|
||||
import { DEPARTMENT_DATA_CATEGORIES } from '@/lib/sdk/vvt-profiling'
|
||||
import type { ScopeQuestionBlockId } from '@/lib/sdk/compliance-scope-types'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
|
||||
@@ -346,6 +347,14 @@ export function ScopeWizardTab({
|
||||
{SCOPE_QUESTION_BLOCKS.map((block, idx) => {
|
||||
const progress = getBlockProgress(answers, block.id)
|
||||
const isActive = idx === currentBlockIndex
|
||||
const unanswered = getUnansweredRequiredQuestions(answers, block.id)
|
||||
const hasRequired = block.questions.some(q => q.required)
|
||||
const allRequiredDone = hasRequired && unanswered.length === 0
|
||||
// For optional-only blocks: check if any questions were answered
|
||||
const answeredIds = new Set(answers.map(a => a.questionId))
|
||||
const hasAnyAnswer = block.questions.some(q => answeredIds.has(q.id))
|
||||
const optionalDone = !hasRequired && hasAnyAnswer
|
||||
|
||||
return (
|
||||
<button
|
||||
key={block.id}
|
||||
@@ -361,13 +370,30 @@ export function ScopeWizardTab({
|
||||
<span className={`text-sm font-medium ${isActive ? 'text-purple-700' : 'text-gray-700'}`}>
|
||||
{block.title}
|
||||
</span>
|
||||
<span className={`text-xs font-semibold ${isActive ? 'text-purple-600' : 'text-gray-500'}`}>
|
||||
{progress}%
|
||||
</span>
|
||||
{allRequiredDone || optionalDone ? (
|
||||
<span className="flex items-center gap-1 text-xs font-semibold text-green-600">
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{!hasRequired && <span>(optional)</span>}
|
||||
</span>
|
||||
) : !hasRequired ? (
|
||||
<span className="text-xs text-gray-400">(nur optional)</span>
|
||||
) : (
|
||||
<span className="text-xs font-semibold text-orange-600">
|
||||
{unanswered.length} offen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5 overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${isActive ? 'bg-purple-500' : 'bg-gray-400'}`}
|
||||
className={`h-full transition-all ${
|
||||
allRequiredDone || optionalDone
|
||||
? 'bg-green-500'
|
||||
: !hasRequired
|
||||
? 'bg-gray-300'
|
||||
: 'bg-orange-400'
|
||||
}`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -397,6 +423,40 @@ export function ScopeWizardTab({
|
||||
style={{ width: `${totalProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Clickable unanswered required questions summary */}
|
||||
{(() => {
|
||||
const allUnanswered = getUnansweredRequiredQuestions(answers)
|
||||
if (allUnanswered.length === 0) return null
|
||||
|
||||
// Group by block
|
||||
const byBlock = new Map<string, { blockTitle: string; blockIndex: number; count: number }>()
|
||||
for (const item of allUnanswered) {
|
||||
if (!byBlock.has(item.blockId)) {
|
||||
const blockIndex = SCOPE_QUESTION_BLOCKS.findIndex(b => b.id === item.blockId)
|
||||
byBlock.set(item.blockId, { blockTitle: item.blockTitle, blockIndex, count: 0 })
|
||||
}
|
||||
byBlock.get(item.blockId)!.count++
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span className="text-orange-600 font-medium">⚠ Offene Pflichtfragen:</span>
|
||||
{Array.from(byBlock.entries()).map(([blockId, info], i) => (
|
||||
<React.Fragment key={blockId}>
|
||||
{i > 0 && <span className="text-gray-300">·</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentBlockIndex(info.blockIndex)}
|
||||
className="text-orange-700 hover:text-orange-900 hover:underline font-medium"
|
||||
>
|
||||
{info.blockTitle} ({info.count})
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Current Block */}
|
||||
@@ -455,11 +515,26 @@ export function ScopeWizardTab({
|
||||
|
||||
{/* Questions */}
|
||||
<div className="space-y-6">
|
||||
{currentBlock.questions.map((question) => (
|
||||
<div key={question.id} className="border-b border-gray-100 pb-6 last:border-b-0 last:pb-0">
|
||||
{renderQuestion(question)}
|
||||
</div>
|
||||
))}
|
||||
{currentBlock.id === 'datenkategorien_detail' ? (
|
||||
<DatenkategorienBlock9
|
||||
answers={answers}
|
||||
onAnswerChange={handleAnswerChange}
|
||||
/>
|
||||
) : (
|
||||
currentBlock.questions.map((question) => {
|
||||
const isAnswered = answers.some(a => a.questionId === question.id)
|
||||
const borderClass = question.required
|
||||
? isAnswered
|
||||
? 'border-l-4 border-l-green-400 pl-4'
|
||||
: 'border-l-4 border-l-orange-400 pl-4'
|
||||
: ''
|
||||
return (
|
||||
<div key={question.id} className={`border-b border-gray-100 pb-6 last:border-b-0 last:pb-0 ${borderClass}`}>
|
||||
{renderQuestion(question)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -499,3 +574,221 @@ export function ScopeWizardTab({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BLOCK 9: Datenkategorien pro Abteilung (aufklappbare Kacheln)
|
||||
// =============================================================================
|
||||
|
||||
/** Mapping Block 8 vvt_departments values → DEPARTMENT_DATA_CATEGORIES keys */
|
||||
const DEPT_VALUE_TO_KEY: Record<string, string[]> = {
|
||||
personal: ['dept_hr', 'dept_recruiting'],
|
||||
finanzen: ['dept_finance'],
|
||||
vertrieb: ['dept_sales'],
|
||||
marketing: ['dept_marketing'],
|
||||
it: ['dept_it'],
|
||||
recht: ['dept_recht'],
|
||||
kundenservice: ['dept_support'],
|
||||
produktion: ['dept_produktion'],
|
||||
logistik: ['dept_logistik'],
|
||||
einkauf: ['dept_einkauf'],
|
||||
facility: ['dept_facility'],
|
||||
}
|
||||
|
||||
/** Mapping department key → scope question ID for Block 9 */
|
||||
const DEPT_KEY_TO_QUESTION: Record<string, string> = {
|
||||
dept_hr: 'dk_dept_hr',
|
||||
dept_recruiting: 'dk_dept_recruiting',
|
||||
dept_finance: 'dk_dept_finance',
|
||||
dept_sales: 'dk_dept_sales',
|
||||
dept_marketing: 'dk_dept_marketing',
|
||||
dept_support: 'dk_dept_support',
|
||||
dept_it: 'dk_dept_it',
|
||||
dept_recht: 'dk_dept_recht',
|
||||
dept_produktion: 'dk_dept_produktion',
|
||||
dept_logistik: 'dk_dept_logistik',
|
||||
dept_einkauf: 'dk_dept_einkauf',
|
||||
dept_facility: 'dk_dept_facility',
|
||||
}
|
||||
|
||||
function DatenkategorienBlock9({
|
||||
answers,
|
||||
onAnswerChange,
|
||||
}: {
|
||||
answers: ScopeProfilingAnswer[]
|
||||
onAnswerChange: (questionId: string, value: string | string[] | boolean | number) => void
|
||||
}) {
|
||||
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set())
|
||||
const [initializedDepts, setInitializedDepts] = useState<Set<string>>(new Set())
|
||||
|
||||
// Get selected departments from Block 8
|
||||
const deptAnswer = answers.find(a => a.questionId === 'vvt_departments')
|
||||
const selectedDepts = Array.isArray(deptAnswer?.value) ? (deptAnswer.value as string[]) : []
|
||||
|
||||
// Resolve which department keys are active
|
||||
const activeDeptKeys: string[] = []
|
||||
for (const deptValue of selectedDepts) {
|
||||
const keys = DEPT_VALUE_TO_KEY[deptValue]
|
||||
if (keys) {
|
||||
for (const k of keys) {
|
||||
if (!activeDeptKeys.includes(k)) activeDeptKeys.push(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDept = (deptKey: string) => {
|
||||
setExpandedDepts(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(deptKey)) {
|
||||
next.delete(deptKey)
|
||||
} else {
|
||||
next.add(deptKey)
|
||||
// Prefill typical categories on first expand
|
||||
if (!initializedDepts.has(deptKey)) {
|
||||
const config = DEPARTMENT_DATA_CATEGORIES[deptKey]
|
||||
const questionId = DEPT_KEY_TO_QUESTION[deptKey]
|
||||
if (config && questionId) {
|
||||
const existing = answers.find(a => a.questionId === questionId)
|
||||
if (!existing) {
|
||||
const typicalIds = config.categories.filter(c => c.isTypical).map(c => c.id)
|
||||
onAnswerChange(questionId, typicalIds)
|
||||
}
|
||||
}
|
||||
setInitializedDepts(p => new Set(p).add(deptKey))
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleCategoryToggle = (deptKey: string, catId: string) => {
|
||||
const questionId = DEPT_KEY_TO_QUESTION[deptKey]
|
||||
if (!questionId) return
|
||||
const existing = answers.find(a => a.questionId === questionId)
|
||||
const current = Array.isArray(existing?.value) ? (existing.value as string[]) : []
|
||||
const updated = current.includes(catId)
|
||||
? current.filter(id => id !== catId)
|
||||
: [...current, catId]
|
||||
onAnswerChange(questionId, updated)
|
||||
}
|
||||
|
||||
if (activeDeptKeys.length === 0) {
|
||||
return (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center">
|
||||
<p className="text-sm text-yellow-800">
|
||||
Bitte waehlen Sie zuerst in <strong>Block 8 (Verarbeitungstaetigkeiten)</strong> die
|
||||
Abteilungen aus, in denen personenbezogene Daten verarbeitet werden.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{activeDeptKeys.map(deptKey => {
|
||||
const config = DEPARTMENT_DATA_CATEGORIES[deptKey]
|
||||
if (!config) return null
|
||||
const questionId = DEPT_KEY_TO_QUESTION[deptKey]
|
||||
const isExpanded = expandedDepts.has(deptKey)
|
||||
const existing = answers.find(a => a.questionId === questionId)
|
||||
const selectedCategories = Array.isArray(existing?.value) ? (existing.value as string[]) : []
|
||||
const hasArt9Selected = config.categories
|
||||
.filter(c => c.isArt9)
|
||||
.some(c => selectedCategories.includes(c.id))
|
||||
|
||||
return (
|
||||
<div
|
||||
key={deptKey}
|
||||
className={`border rounded-xl overflow-hidden transition-all ${
|
||||
isExpanded ? 'border-purple-400 bg-white shadow-sm' : 'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDept(deptKey)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">{config.icon}</span>
|
||||
<div className="text-left">
|
||||
<span className="text-sm font-medium text-gray-900">{config.label}</span>
|
||||
{selectedCategories.length > 0 && (
|
||||
<span className="ml-2 text-xs text-gray-400">
|
||||
({selectedCategories.length} Kategorien)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasArt9Selected && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-orange-100 text-orange-700 rounded">
|
||||
Art. 9
|
||||
</span>
|
||||
)}
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expandable categories panel */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-100 px-4 pt-3 pb-4">
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
|
||||
Datenkategorien
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{config.categories.map(cat => {
|
||||
const isChecked = selectedCategories.includes(cat.id)
|
||||
return (
|
||||
<label
|
||||
key={cat.id}
|
||||
className={`flex items-start gap-3 p-2.5 rounded-lg cursor-pointer transition-colors ${
|
||||
cat.isArt9
|
||||
? isChecked ? 'bg-orange-50 hover:bg-orange-100' : 'bg-gray-50 hover:bg-orange-50'
|
||||
: isChecked ? 'bg-purple-50 hover:bg-purple-100' : 'bg-gray-50 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => handleCategoryToggle(deptKey, cat.id)}
|
||||
className={`w-4 h-4 mt-0.5 rounded ${cat.isArt9 ? 'text-orange-500' : 'text-purple-600'}`}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">{cat.label}</span>
|
||||
{cat.isArt9 && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-semibold bg-orange-100 text-orange-700 rounded">
|
||||
Art. 9
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{cat.info}</p>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Art. 9 warning */}
|
||||
{hasArt9Selected && (
|
||||
<div className="mt-3 p-3 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<p className="text-xs text-orange-800">
|
||||
<span className="font-semibold">Art. 9 DSGVO:</span> Sie verarbeiten besondere Kategorien
|
||||
personenbezogener Daten. Eine zusaetzliche Rechtsgrundlage nach Art. 9 Abs. 2 DSGVO ist
|
||||
erforderlich (z.B. § 26 Abs. 3 BDSG fuer Beschaeftigtendaten).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
272
admin-compliance/components/sdk/iace/TechFileEditor.tsx
Normal file
272
admin-compliance/components/sdk/iace/TechFileEditor.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useEffect, useRef } from 'react'
|
||||
import { useEditor, EditorContent, type Editor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Table from '@tiptap/extension-table'
|
||||
import TableRow from '@tiptap/extension-table-row'
|
||||
import TableHeader from '@tiptap/extension-table-header'
|
||||
import TableCell from '@tiptap/extension-table-cell'
|
||||
import Image from '@tiptap/extension-image'
|
||||
|
||||
interface TechFileEditorProps {
|
||||
content: string
|
||||
onSave: (content: string) => void
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
function normalizeContent(content: string): string {
|
||||
if (!content) return '<p></p>'
|
||||
const trimmed = content.trim()
|
||||
// If it looks like JSON array or has no HTML tags, wrap in <p>
|
||||
if (trimmed.startsWith('[') || !/<[a-z][\s\S]*>/i.test(trimmed)) {
|
||||
return `<p>${trimmed.replace(/\n/g, '</p><p>')}</p>`
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
interface ToolbarButtonProps {
|
||||
onClick: () => void
|
||||
isActive?: boolean
|
||||
disabled?: boolean
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function ToolbarButton({ onClick, isActive, disabled, title, children }: ToolbarButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={`p-1.5 rounded text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300'
|
||||
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'
|
||||
} disabled:opacity-40 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function TechFileEditor({ content, onSave, readOnly = false }: TechFileEditorProps) {
|
||||
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const onSaveRef = useRef(onSave)
|
||||
onSaveRef.current = onSave
|
||||
|
||||
const debouncedSave = useCallback((html: string) => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current)
|
||||
}
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
onSaveRef.current(html)
|
||||
}, 3000)
|
||||
}, [])
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [2, 3, 4] },
|
||||
}),
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
HTMLAttributes: { class: 'border-collapse border border-gray-300' },
|
||||
}),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
Image.configure({
|
||||
HTMLAttributes: { class: 'max-w-full rounded' },
|
||||
}),
|
||||
],
|
||||
content: normalizeContent(content),
|
||||
editable: !readOnly,
|
||||
onUpdate: ({ editor: ed }: { editor: Editor }) => {
|
||||
if (!readOnly) {
|
||||
debouncedSave(ed.getHTML())
|
||||
}
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-sm max-w-none dark:prose-invert focus:outline-none min-h-[300px] px-4 py-3',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Update content when parent prop changes
|
||||
useEffect(() => {
|
||||
if (editor && content) {
|
||||
const normalized = normalizeContent(content)
|
||||
const currentHTML = editor.getHTML()
|
||||
if (normalized !== currentHTML) {
|
||||
editor.commands.setContent(normalized)
|
||||
}
|
||||
}
|
||||
}, [content, editor])
|
||||
|
||||
// Update editable state when readOnly changes
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.setEditable(!readOnly)
|
||||
}
|
||||
}, [readOnly, editor])
|
||||
|
||||
// Cleanup debounce timer
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!editor) {
|
||||
return (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 min-h-[300px] flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
{!readOnly && (
|
||||
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1.5 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||
{/* Text formatting */}
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
isActive={editor.isActive('bold')}
|
||||
title="Fett (Ctrl+B)"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
isActive={editor.isActive('italic')}
|
||||
title="Kursiv (Ctrl+I)"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||
|
||||
{/* Headings */}
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
isActive={editor.isActive('heading', { level: 2 })}
|
||||
title="Ueberschrift 2"
|
||||
>
|
||||
<span className="text-xs font-bold">H2</span>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
isActive={editor.isActive('heading', { level: 3 })}
|
||||
title="Ueberschrift 3"
|
||||
>
|
||||
<span className="text-xs font-bold">H3</span>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 4 }).run()}
|
||||
isActive={editor.isActive('heading', { level: 4 })}
|
||||
title="Ueberschrift 4"
|
||||
>
|
||||
<span className="text-xs font-bold">H4</span>
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||
|
||||
{/* Lists */}
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
isActive={editor.isActive('bulletList')}
|
||||
title="Aufzaehlung"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
isActive={editor.isActive('orderedList')}
|
||||
title="Nummerierte Liste"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||
|
||||
{/* Table */}
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
|
||||
title="Tabelle einfuegen (3x3)"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<line x1="3" y1="9" x2="21" y2="9" />
|
||||
<line x1="3" y1="15" x2="21" y2="15" />
|
||||
<line x1="9" y1="3" x2="9" y2="21" />
|
||||
<line x1="15" y1="3" x2="15" y2="21" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
|
||||
{/* Blockquote */}
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
isActive={editor.isActive('blockquote')}
|
||||
title="Zitat"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
|
||||
{/* Code Block */}
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
isActive={editor.isActive('codeBlock')}
|
||||
title="Code-Block"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="16,18 22,12 16,6" />
|
||||
<polyline points="8,6 2,12 8,18" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||
|
||||
{/* Undo / Redo */}
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
title="Rueckgaengig (Ctrl+Z)"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
title="Wiederholen (Ctrl+Shift+Z)"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.4 10.6C16.55 8.99 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editor Content */}
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import type { Obligation, ObligationComplianceCheckResult } from '@/lib/sdk/obligations-compliance'
|
||||
import {
|
||||
buildObligationDocumentHtml,
|
||||
createDefaultObligationDocumentOrgHeader,
|
||||
type ObligationDocumentOrgHeader,
|
||||
type ObligationDocumentRevision,
|
||||
} from '@/lib/sdk/obligations-document'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface ObligationDocumentTabProps {
|
||||
obligations: Obligation[]
|
||||
complianceResult: ObligationComplianceCheckResult | null
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
function ObligationDocumentTab({ obligations, complianceResult }: ObligationDocumentTabProps) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const [orgHeader, setOrgHeader] = useState<ObligationDocumentOrgHeader>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('bp_obligation_document_orgheader')
|
||||
if (saved) {
|
||||
try { return JSON.parse(saved) } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return createDefaultObligationDocumentOrgHeader()
|
||||
})
|
||||
|
||||
const [revisions, setRevisions] = useState<ObligationDocumentRevision[]>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('bp_obligation_document_revisions')
|
||||
if (saved) {
|
||||
try { return JSON.parse(saved) } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// localStorage persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('bp_obligation_document_orgheader', JSON.stringify(orgHeader))
|
||||
}, [orgHeader])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('bp_obligation_document_revisions', JSON.stringify(revisions))
|
||||
}, [revisions])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const obligationCount = obligations.length
|
||||
|
||||
const completedCount = useMemo(() => {
|
||||
return obligations.filter(o => o.status === 'completed').length
|
||||
}, [obligations])
|
||||
|
||||
const distinctSources = useMemo(() => {
|
||||
const sources = new Set(obligations.map(o => o.source || 'Sonstig'))
|
||||
return sources.size
|
||||
}, [obligations])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handlePrintDocument = useCallback(() => {
|
||||
const html = buildObligationDocumentHtml(
|
||||
obligations,
|
||||
orgHeader,
|
||||
complianceResult,
|
||||
revisions,
|
||||
)
|
||||
const printWindow = window.open('', '_blank')
|
||||
if (printWindow) {
|
||||
printWindow.document.write(html)
|
||||
printWindow.document.close()
|
||||
setTimeout(() => printWindow.print(), 300)
|
||||
}
|
||||
}, [obligations, orgHeader, complianceResult, revisions])
|
||||
|
||||
const handleDownloadDocumentHtml = useCallback(() => {
|
||||
const html = buildObligationDocumentHtml(
|
||||
obligations,
|
||||
orgHeader,
|
||||
complianceResult,
|
||||
revisions,
|
||||
)
|
||||
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `Pflichtenregister-${orgHeader.organizationName || 'Organisation'}-${new Date().toISOString().split('T')[0]}.html`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, [obligations, orgHeader, complianceResult, revisions])
|
||||
|
||||
const handleAddRevision = useCallback(() => {
|
||||
setRevisions(prev => [...prev, {
|
||||
version: String(prev.length + 1) + '.0',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
author: '',
|
||||
changes: '',
|
||||
}])
|
||||
}, [])
|
||||
|
||||
const handleUpdateRevision = useCallback((index: number, field: keyof ObligationDocumentRevision, value: string) => {
|
||||
setRevisions(prev => prev.map((r, i) => i === index ? { ...r, [field]: value } : r))
|
||||
}, [])
|
||||
|
||||
const handleRemoveRevision = useCallback((index: number) => {
|
||||
setRevisions(prev => prev.filter((_, i) => i !== index))
|
||||
}, [])
|
||||
|
||||
const updateOrgHeader = useCallback((field: keyof ObligationDocumentOrgHeader, value: string) => {
|
||||
setOrgHeader(prev => ({ ...prev, [field]: value }))
|
||||
}, [])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 1. Action Bar */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Pflichtenregister</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Auditfaehiges Dokument mit {obligationCount} Pflichten generieren
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleDownloadDocumentHtml}
|
||||
disabled={obligationCount === 0}
|
||||
className="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
HTML herunterladen
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePrintDocument}
|
||||
disabled={obligationCount === 0}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 text-sm font-medium transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Als PDF drucken
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Org Header Form */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-base font-semibold text-gray-900 mb-4">Organisationsdaten</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Organisation</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.organizationName}
|
||||
onChange={e => updateOrgHeader('organizationName', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Branche</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.industry}
|
||||
onChange={e => updateOrgHeader('industry', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Datenschutzbeauftragter</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.dpoName}
|
||||
onChange={e => updateOrgHeader('dpoName', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">DSB-Kontakt</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.dpoContact}
|
||||
onChange={e => updateOrgHeader('dpoContact', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verantwortlicher</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.responsiblePerson}
|
||||
onChange={e => updateOrgHeader('responsiblePerson', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsabteilung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.legalDepartment}
|
||||
onChange={e => updateOrgHeader('legalDepartment', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Dokumentversion</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.documentVersion}
|
||||
onChange={e => updateOrgHeader('documentVersion', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Pruefintervall</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.reviewInterval}
|
||||
onChange={e => updateOrgHeader('reviewInterval', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Letzte Pruefung</label>
|
||||
<input
|
||||
type="date"
|
||||
value={orgHeader.lastReviewDate}
|
||||
onChange={e => updateOrgHeader('lastReviewDate', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Naechste Pruefung</label>
|
||||
<input
|
||||
type="date"
|
||||
value={orgHeader.nextReviewDate}
|
||||
onChange={e => updateOrgHeader('nextReviewDate', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Revisions Manager */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-base font-semibold text-gray-900">Aenderungshistorie</h4>
|
||||
<button
|
||||
onClick={handleAddRevision}
|
||||
className="text-sm text-purple-600 hover:text-purple-700 font-medium"
|
||||
>
|
||||
+ Version hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
{revisions.length > 0 ? (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-2 text-gray-600 font-medium">Version</th>
|
||||
<th className="text-left py-2 text-gray-600 font-medium">Datum</th>
|
||||
<th className="text-left py-2 text-gray-600 font-medium">Autor</th>
|
||||
<th className="text-left py-2 text-gray-600 font-medium">Aenderungen</th>
|
||||
<th className="py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{revisions.map((revision, index) => (
|
||||
<tr key={index} className="border-b border-gray-100">
|
||||
<td className="py-2 pr-2">
|
||||
<input
|
||||
type="text"
|
||||
value={revision.version}
|
||||
onChange={e => handleUpdateRevision(index, 'version', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<input
|
||||
type="date"
|
||||
value={revision.date}
|
||||
onChange={e => handleUpdateRevision(index, 'date', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<input
|
||||
type="text"
|
||||
value={revision.author}
|
||||
onChange={e => handleUpdateRevision(index, 'author', e.target.value)}
|
||||
placeholder="Name"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<input
|
||||
type="text"
|
||||
value={revision.changes}
|
||||
onChange={e => handleUpdateRevision(index, 'changes', e.target.value)}
|
||||
placeholder="Beschreibung der Aenderungen"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
onClick={() => handleRemoveRevision(index)}
|
||||
className="text-sm text-red-600 hover:text-red-700 font-medium"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 italic">
|
||||
Noch keine Revisionen. Die erste Version wird automatisch im Dokument eingetragen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 4. Document Preview */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-base font-semibold text-gray-900 mb-4">Dokument-Vorschau</h4>
|
||||
{obligationCount === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">Erfassen Sie Pflichten, um das Pflichtenregister zu generieren.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Cover preview */}
|
||||
<div className="bg-purple-50 rounded-lg p-4 text-center">
|
||||
<p className="text-purple-700 font-semibold text-lg">Pflichtenregister</p>
|
||||
<p className="text-purple-600 text-sm">
|
||||
Regulatorische Pflichten — {orgHeader.organizationName || 'Organisation'}
|
||||
</p>
|
||||
<p className="text-purple-500 text-xs mt-1">
|
||||
Version {orgHeader.documentVersion} | Stand: {new Date().toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">{obligationCount}</p>
|
||||
<p className="text-xs text-gray-500">Pflichten</p>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-green-700">{completedCount}</p>
|
||||
<p className="text-xs text-gray-500">Abgeschlossen</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-purple-700">{distinctSources}</p>
|
||||
<p className="text-xs text-gray-500">Regulierungen</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">12</p>
|
||||
<p className="text-xs text-gray-500">Sektionen</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{complianceResult ? complianceResult.score : '—'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Compliance-Score</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 12 Sections list */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">12 Dokument-Sektionen:</p>
|
||||
<ol className="list-decimal list-inside text-sm text-gray-600 space-y-1">
|
||||
<li>Ziel und Zweck</li>
|
||||
<li>Geltungsbereich</li>
|
||||
<li>Methodik</li>
|
||||
<li>Regulatorische Grundlagen</li>
|
||||
<li>Pflichtenuebersicht</li>
|
||||
<li>Detaillierte Pflichten</li>
|
||||
<li>Verantwortlichkeiten</li>
|
||||
<li>Fristen und Termine</li>
|
||||
<li>Nachweisverzeichnis</li>
|
||||
<li>Compliance-Status</li>
|
||||
<li>Aenderungshistorie</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { ObligationDocumentTab }
|
||||
449
admin-compliance/components/sdk/tom-dashboard/TOMDocumentTab.tsx
Normal file
449
admin-compliance/components/sdk/tom-dashboard/TOMDocumentTab.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import type { TOMGeneratorState } from '@/lib/sdk/tom-generator/types'
|
||||
import type { TOMComplianceCheckResult } from '@/lib/sdk/tom-compliance'
|
||||
import {
|
||||
buildTOMDocumentHtml,
|
||||
createDefaultTOMDocumentOrgHeader,
|
||||
type TOMDocumentOrgHeader,
|
||||
type TOMDocumentRevision,
|
||||
} from '@/lib/sdk/tom-document'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface TOMDocumentTabProps {
|
||||
state: TOMGeneratorState
|
||||
complianceResult: TOMComplianceCheckResult | null
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
function TOMDocumentTab({ state, complianceResult }: TOMDocumentTabProps) {
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const [orgHeader, setOrgHeader] = useState<TOMDocumentOrgHeader>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('bp_tom_document_orgheader')
|
||||
if (saved) {
|
||||
try { return JSON.parse(saved) } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return createDefaultTOMDocumentOrgHeader()
|
||||
})
|
||||
|
||||
const [revisions, setRevisions] = useState<TOMDocumentRevision[]>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = localStorage.getItem('bp_tom_document_revisions')
|
||||
if (saved) {
|
||||
try { return JSON.parse(saved) } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// localStorage persistence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('bp_tom_document_orgheader', JSON.stringify(orgHeader))
|
||||
}, [orgHeader])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('bp_tom_document_revisions', JSON.stringify(revisions))
|
||||
}, [revisions])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const tomCount = useMemo(() => {
|
||||
if (!state?.derivedTOMs) return 0
|
||||
return Array.isArray(state.derivedTOMs) ? state.derivedTOMs.length : 0
|
||||
}, [state?.derivedTOMs])
|
||||
|
||||
const applicableTOMs = useMemo(() => {
|
||||
if (!state?.derivedTOMs || !Array.isArray(state.derivedTOMs)) return []
|
||||
return state.derivedTOMs.filter(t => t.applicability !== 'NOT_APPLICABLE')
|
||||
}, [state?.derivedTOMs])
|
||||
|
||||
const implementedCount = useMemo(() => {
|
||||
return applicableTOMs.filter(t => t.implementationStatus === 'IMPLEMENTED').length
|
||||
}, [applicableTOMs])
|
||||
|
||||
const [canonicalCount, setCanonicalCount] = useState(0)
|
||||
useEffect(() => {
|
||||
if (tomCount === 0) return
|
||||
fetch('/api/sdk/v1/compliance/tom-mappings/stats')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => { if (data?.sync_state?.canonical_controls_matched) setCanonicalCount(data.sync_state.canonical_controls_matched) })
|
||||
.catch(() => {})
|
||||
}, [tomCount])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handlePrintTOMDocument = useCallback(() => {
|
||||
const html = buildTOMDocumentHtml(
|
||||
state?.derivedTOMs || [],
|
||||
orgHeader,
|
||||
state?.companyProfile || null,
|
||||
state?.riskProfile || null,
|
||||
complianceResult,
|
||||
revisions,
|
||||
)
|
||||
const printWindow = window.open('', '_blank')
|
||||
if (printWindow) {
|
||||
printWindow.document.write(html)
|
||||
printWindow.document.close()
|
||||
setTimeout(() => printWindow.print(), 300)
|
||||
}
|
||||
}, [state, orgHeader, complianceResult, revisions])
|
||||
|
||||
const handleDownloadTOMDocumentHtml = useCallback(() => {
|
||||
const html = buildTOMDocumentHtml(
|
||||
state?.derivedTOMs || [],
|
||||
orgHeader,
|
||||
state?.companyProfile || null,
|
||||
state?.riskProfile || null,
|
||||
complianceResult,
|
||||
revisions,
|
||||
)
|
||||
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `TOM-Dokumentation-${orgHeader.organizationName || 'Organisation'}-${new Date().toISOString().split('T')[0]}.html`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}, [state, orgHeader, complianceResult, revisions])
|
||||
|
||||
const handleAddRevision = useCallback(() => {
|
||||
setRevisions(prev => [...prev, {
|
||||
version: String(prev.length + 1) + '.0',
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
author: '',
|
||||
changes: '',
|
||||
}])
|
||||
}, [])
|
||||
|
||||
const handleUpdateRevision = useCallback((index: number, field: keyof TOMDocumentRevision, value: string) => {
|
||||
setRevisions(prev => prev.map((r, i) => i === index ? { ...r, [field]: value } : r))
|
||||
}, [])
|
||||
|
||||
const handleRemoveRevision = useCallback((index: number) => {
|
||||
setRevisions(prev => prev.filter((_, i) => i !== index))
|
||||
}, [])
|
||||
|
||||
const updateOrgHeader = useCallback((field: keyof TOMDocumentOrgHeader, value: string | string[]) => {
|
||||
setOrgHeader(prev => ({ ...prev, [field]: value }))
|
||||
}, [])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 1. Action Bar */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">TOM-Dokument (Art. 32 DSGVO)</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Auditfaehiges Dokument mit {applicableTOMs.length} Massnahmen generieren
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleDownloadTOMDocumentHtml}
|
||||
disabled={tomCount === 0}
|
||||
className="bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 rounded-lg px-4 py-2 text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
HTML herunterladen
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePrintTOMDocument}
|
||||
disabled={tomCount === 0}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 text-sm font-medium transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Als PDF drucken
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Org Header Form */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-base font-semibold text-gray-900 mb-4">Organisationsdaten</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Organisation</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.organizationName}
|
||||
onChange={e => updateOrgHeader('organizationName', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Branche</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.industry}
|
||||
onChange={e => updateOrgHeader('industry', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Datenschutzbeauftragter</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.dpoName}
|
||||
onChange={e => updateOrgHeader('dpoName', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">DSB-Kontakt</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.dpoContact}
|
||||
onChange={e => updateOrgHeader('dpoContact', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verantwortlicher</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.responsiblePerson}
|
||||
onChange={e => updateOrgHeader('responsiblePerson', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">IT-Sicherheitskontakt</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.itSecurityContact}
|
||||
onChange={e => updateOrgHeader('itSecurityContact', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Mitarbeiteranzahl</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.employeeCount}
|
||||
onChange={e => updateOrgHeader('employeeCount', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Standorte</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.locations.join(', ')}
|
||||
onChange={e => updateOrgHeader('locations', e.target.value.split(',').map(s => s.trim()))}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Dokumentversion</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.documentVersion}
|
||||
onChange={e => updateOrgHeader('documentVersion', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Pruefintervall</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgHeader.reviewInterval}
|
||||
onChange={e => updateOrgHeader('reviewInterval', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Letzte Pruefung</label>
|
||||
<input
|
||||
type="date"
|
||||
value={orgHeader.lastReviewDate}
|
||||
onChange={e => updateOrgHeader('lastReviewDate', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Naechste Pruefung</label>
|
||||
<input
|
||||
type="date"
|
||||
value={orgHeader.nextReviewDate}
|
||||
onChange={e => updateOrgHeader('nextReviewDate', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Revisions Manager */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-base font-semibold text-gray-900">Aenderungshistorie</h4>
|
||||
<button
|
||||
onClick={handleAddRevision}
|
||||
className="text-sm text-purple-600 hover:text-purple-700 font-medium"
|
||||
>
|
||||
+ Version hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
{revisions.length > 0 ? (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-2 text-gray-600 font-medium">Version</th>
|
||||
<th className="text-left py-2 text-gray-600 font-medium">Datum</th>
|
||||
<th className="text-left py-2 text-gray-600 font-medium">Autor</th>
|
||||
<th className="text-left py-2 text-gray-600 font-medium">Aenderungen</th>
|
||||
<th className="py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{revisions.map((revision, index) => (
|
||||
<tr key={index} className="border-b border-gray-100">
|
||||
<td className="py-2 pr-2">
|
||||
<input
|
||||
type="text"
|
||||
value={revision.version}
|
||||
onChange={e => handleUpdateRevision(index, 'version', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<input
|
||||
type="date"
|
||||
value={revision.date}
|
||||
onChange={e => handleUpdateRevision(index, 'date', e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<input
|
||||
type="text"
|
||||
value={revision.author}
|
||||
onChange={e => handleUpdateRevision(index, 'author', e.target.value)}
|
||||
placeholder="Name"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<input
|
||||
type="text"
|
||||
value={revision.changes}
|
||||
onChange={e => handleUpdateRevision(index, 'changes', e.target.value)}
|
||||
placeholder="Beschreibung der Aenderungen"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
onClick={() => handleRemoveRevision(index)}
|
||||
className="text-sm text-red-600 hover:text-red-700 font-medium"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 italic">
|
||||
Noch keine Revisionen. Die erste Version wird automatisch im Dokument eingetragen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 4. Document Preview */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-base font-semibold text-gray-900 mb-4">Dokument-Vorschau</h4>
|
||||
{tomCount === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">Starten Sie den TOM-Generator, um Massnahmen abzuleiten.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Cover preview */}
|
||||
<div className="bg-purple-50 rounded-lg p-4 text-center">
|
||||
<p className="text-purple-700 font-semibold text-lg">TOM-Dokumentation</p>
|
||||
<p className="text-purple-600 text-sm">
|
||||
Art. 32 DSGVO — {orgHeader.organizationName || 'Organisation'}
|
||||
</p>
|
||||
<p className="text-purple-500 text-xs mt-1">
|
||||
Version {orgHeader.documentVersion} | Stand: {new Date().toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">{applicableTOMs.length}</p>
|
||||
<p className="text-xs text-gray-500">Massnahmen</p>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-green-700">{implementedCount}</p>
|
||||
<p className="text-xs text-gray-500">Umgesetzt</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-purple-700">{canonicalCount || '-'}</p>
|
||||
<p className="text-xs text-gray-500">Belegende Controls</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">12</p>
|
||||
<p className="text-xs text-gray-500">Sektionen</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{complianceResult ? complianceResult.score : '-'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Compliance-Score</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 12 Sections list */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">12 Dokument-Sektionen:</p>
|
||||
<ol className="list-decimal list-inside text-sm text-gray-600 space-y-1">
|
||||
<li>Ziel und Zweck</li>
|
||||
<li>Geltungsbereich</li>
|
||||
<li>Grundprinzipien Art. 32</li>
|
||||
<li>Schutzbedarf und Risikoanalyse</li>
|
||||
<li>Massnahmen-Uebersicht</li>
|
||||
<li>Detaillierte Massnahmen</li>
|
||||
<li>SDM Gewaehrleistungsziele</li>
|
||||
<li>Verantwortlichkeiten</li>
|
||||
<li>Pruef- und Revisionszyklus</li>
|
||||
<li>Compliance-Status</li>
|
||||
<li>Aenderungshistorie</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { TOMDocumentTab }
|
||||
@@ -1,9 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState, useEffect } from 'react'
|
||||
import { useMemo, useState, useEffect, useCallback } from 'react'
|
||||
import { DerivedTOM, TOMGeneratorState } from '@/lib/sdk/tom-generator/types'
|
||||
import { getControlById } from '@/lib/sdk/tom-generator/controls/loader'
|
||||
|
||||
interface CanonicalMapping {
|
||||
id: string
|
||||
canonical_control_code: string
|
||||
canonical_title: string | null
|
||||
canonical_severity: string | null
|
||||
canonical_objective: string | null
|
||||
mapping_type: string
|
||||
}
|
||||
|
||||
interface TOMEditorTabProps {
|
||||
state: TOMGeneratorState
|
||||
selectedTOMId: string | null
|
||||
@@ -46,6 +55,17 @@ export function TOMEditorTab({ state, selectedTOMId, onUpdateTOM, onBack }: TOME
|
||||
const [notes, setNotes] = useState('')
|
||||
const [linkedEvidence, setLinkedEvidence] = useState<string[]>([])
|
||||
const [selectedEvidenceId, setSelectedEvidenceId] = useState('')
|
||||
const [canonicalMappings, setCanonicalMappings] = useState<CanonicalMapping[]>([])
|
||||
const [showCanonical, setShowCanonical] = useState(false)
|
||||
|
||||
// Load canonical controls for this TOM's category
|
||||
useEffect(() => {
|
||||
if (!control?.category) { setCanonicalMappings([]); return }
|
||||
fetch(`/api/sdk/v1/compliance/tom-mappings/by-tom/${encodeURIComponent(control.category)}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => { if (data?.mappings) setCanonicalMappings(data.mappings) })
|
||||
.catch(() => setCanonicalMappings([]))
|
||||
}, [control?.category])
|
||||
|
||||
useEffect(() => {
|
||||
if (tom) {
|
||||
@@ -341,6 +361,62 @@ export function TOMEditorTab({ state, selectedTOMId, onUpdateTOM, onBack }: TOME
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Canonical Controls (Belegende Security-Controls) */}
|
||||
{canonicalMappings.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700">
|
||||
Belegende Security-Controls ({canonicalMappings.length})
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowCanonical(!showCanonical)}
|
||||
className="text-xs text-purple-600 hover:text-purple-700 font-medium"
|
||||
>
|
||||
{showCanonical ? 'Einklappen' : 'Alle anzeigen'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(showCanonical ? canonicalMappings : canonicalMappings.slice(0, 5)).map(m => (
|
||||
<div key={m.id} className="flex items-start gap-3 bg-gray-50 rounded-lg px-3 py-2">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-xs font-mono bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded">
|
||||
{m.canonical_control_code}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-700 font-medium truncate">{m.canonical_title || m.canonical_control_code}</p>
|
||||
{m.canonical_objective && showCanonical && (
|
||||
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{m.canonical_objective}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center gap-1.5">
|
||||
{m.canonical_severity && (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded-full font-medium ${
|
||||
m.canonical_severity === 'critical' ? 'bg-red-100 text-red-700' :
|
||||
m.canonical_severity === 'high' ? 'bg-orange-100 text-orange-700' :
|
||||
m.canonical_severity === 'medium' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{m.canonical_severity}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded-full ${
|
||||
m.mapping_type === 'manual' ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
{m.mapping_type === 'manual' ? 'manuell' : 'auto'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!showCanonical && canonicalMappings.length > 5 && (
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
+ {canonicalMappings.length - 5} weitere Controls
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Framework Mappings */}
|
||||
{control?.mappings && control.mappings.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo, useState, useEffect, useCallback } from 'react'
|
||||
import { DerivedTOM, TOMGeneratorState } from '@/lib/sdk/tom-generator/types'
|
||||
import { getControlById, getControlsByCategory, getAllCategories } from '@/lib/sdk/tom-generator/controls/loader'
|
||||
import { SDM_GOAL_LABELS, getSDMCoverageStats, SDMGewaehrleistungsziel } from '@/lib/sdk/tom-generator/sdm-mapping'
|
||||
@@ -11,6 +11,18 @@ interface TOMOverviewTabProps {
|
||||
onStartGenerator: () => void
|
||||
}
|
||||
|
||||
interface MappingStats {
|
||||
sync_state: {
|
||||
profile_hash: string | null
|
||||
total_mappings: number
|
||||
canonical_controls_matched: number
|
||||
tom_controls_covered: number
|
||||
last_synced_at: string | null
|
||||
}
|
||||
category_breakdown: { tom_category: string; total_mappings: number; unique_controls: number }[]
|
||||
total_canonical_controls_available: number
|
||||
}
|
||||
|
||||
const STATUS_BADGES: Record<string, { label: string; className: string }> = {
|
||||
IMPLEMENTED: { label: 'Implementiert', className: 'bg-green-100 text-green-700' },
|
||||
PARTIAL: { label: 'Teilweise', className: 'bg-yellow-100 text-yellow-700' },
|
||||
@@ -34,9 +46,41 @@ export function TOMOverviewTab({ state, onSelectTOM, onStartGenerator }: TOMOver
|
||||
const [typeFilter, setTypeFilter] = useState<string>('ALL')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('ALL')
|
||||
const [applicabilityFilter, setApplicabilityFilter] = useState<string>('ALL')
|
||||
const [mappingStats, setMappingStats] = useState<MappingStats | null>(null)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
|
||||
const categories = useMemo(() => getAllCategories(), [])
|
||||
|
||||
// Load mapping stats
|
||||
useEffect(() => {
|
||||
if (state.derivedTOMs.length === 0) return
|
||||
fetch('/api/sdk/v1/compliance/tom-mappings/stats')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => { if (data) setMappingStats(data) })
|
||||
.catch(() => {})
|
||||
}, [state.derivedTOMs.length])
|
||||
|
||||
const handleSyncControls = useCallback(async () => {
|
||||
setSyncing(true)
|
||||
try {
|
||||
const resp = await fetch('/api/sdk/v1/compliance/tom-mappings/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
industry: state.companyProfile?.industry || null,
|
||||
company_size: state.companyProfile?.size || null,
|
||||
force: false,
|
||||
}),
|
||||
})
|
||||
if (resp.ok) {
|
||||
// Reload stats after sync
|
||||
const statsResp = await fetch('/api/sdk/v1/compliance/tom-mappings/stats')
|
||||
if (statsResp.ok) setMappingStats(await statsResp.json())
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setSyncing(false)
|
||||
}, [state.companyProfile])
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const toms = state.derivedTOMs
|
||||
return {
|
||||
@@ -159,6 +203,59 @@ export function TOMOverviewTab({ state, onSelectTOM, onStartGenerator }: TOMOver
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canonical Control Library Coverage */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700">Canonical Control Library</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Belegende Security-Controls aus OWASP, NIST, ENISA
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSyncControls}
|
||||
disabled={syncing}
|
||||
className="bg-purple-600 text-white hover:bg-purple-700 disabled:bg-gray-300 rounded-lg px-4 py-2 text-xs font-medium transition-colors"
|
||||
>
|
||||
{syncing ? 'Synchronisiere...' : 'Controls synchronisieren'}
|
||||
</button>
|
||||
</div>
|
||||
{mappingStats ? (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<div className="text-xl font-bold text-gray-900">{mappingStats.sync_state.total_mappings}</div>
|
||||
<div className="text-xs text-gray-500">Zugeordnete Controls</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<div className="text-xl font-bold text-purple-600">{mappingStats.sync_state.canonical_controls_matched}</div>
|
||||
<div className="text-xs text-gray-500">Einzigartige Controls</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<div className="text-xl font-bold text-gray-900">{mappingStats.sync_state.tom_controls_covered}/13</div>
|
||||
<div className="text-xs text-gray-500">Kategorien abgedeckt</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
||||
<div className="text-xl font-bold text-gray-900">{mappingStats.total_canonical_controls_available}</div>
|
||||
<div className="text-xs text-gray-500">Verfuegbare Controls</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
Noch keine Controls synchronisiert. Klicken Sie "Controls synchronisieren", um relevante
|
||||
Security-Controls aus der Canonical Control Library zuzuordnen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{mappingStats?.sync_state?.last_synced_at && (
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Letzte Synchronisation: {new Date(mappingStats.sync_state.last_synced_at).toLocaleDateString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Controls */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { TOMOverviewTab } from './TOMOverviewTab'
|
||||
export { TOMEditorTab } from './TOMEditorTab'
|
||||
export { TOMGapExportTab } from './TOMGapExportTab'
|
||||
export { TOMDocumentTab } from './TOMDocumentTab'
|
||||
|
||||
@@ -10,6 +10,8 @@ interface AssessmentResult {
|
||||
dsfa_recommended: boolean
|
||||
art22_risk: boolean
|
||||
training_allowed: string
|
||||
betrvg_conflict_score?: number
|
||||
betrvg_consultation_required?: boolean
|
||||
summary: string
|
||||
recommendation: string
|
||||
alternative_approach?: string
|
||||
@@ -76,6 +78,21 @@ export function AssessmentResultCard({ result }: AssessmentResultCardProps) {
|
||||
Art. 22 Risiko
|
||||
</span>
|
||||
)}
|
||||
{result.betrvg_conflict_score != null && result.betrvg_conflict_score > 0 && (
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
result.betrvg_conflict_score >= 75 ? 'bg-red-100 text-red-700' :
|
||||
result.betrvg_conflict_score >= 50 ? 'bg-orange-100 text-orange-700' :
|
||||
result.betrvg_conflict_score >= 25 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
BR-Konflikt: {result.betrvg_conflict_score}/100
|
||||
</span>
|
||||
)}
|
||||
{result.betrvg_consultation_required && (
|
||||
<span className="px-3 py-1 rounded-full text-sm bg-purple-100 text-purple-700">
|
||||
BR-Konsultation erforderlich
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-700">{result.summary}</p>
|
||||
<p className="text-sm text-gray-500 mt-2">{result.recommendation}</p>
|
||||
|
||||
321
admin-compliance/components/training/InteractiveVideoPlayer.tsx
Normal file
321
admin-compliance/components/training/InteractiveVideoPlayer.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||
import type {
|
||||
InteractiveVideoManifest,
|
||||
CheckpointEntry,
|
||||
CheckpointQuizResult,
|
||||
} from '@/lib/sdk/training/types'
|
||||
import { submitCheckpointQuiz } from '@/lib/sdk/training/api'
|
||||
|
||||
interface Props {
|
||||
manifest: InteractiveVideoManifest
|
||||
assignmentId: string
|
||||
onAllCheckpointsPassed?: () => void
|
||||
}
|
||||
|
||||
export default function InteractiveVideoPlayer({ manifest, assignmentId, onAllCheckpointsPassed }: Props) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const [currentCheckpoint, setCurrentCheckpoint] = useState<CheckpointEntry | null>(null)
|
||||
const [showOverlay, setShowOverlay] = useState(false)
|
||||
const [answers, setAnswers] = useState<Record<number, number>>({})
|
||||
const [quizResult, setQuizResult] = useState<CheckpointQuizResult | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [passedCheckpoints, setPassedCheckpoints] = useState<Set<string>>(new Set())
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
|
||||
// Initialize passed checkpoints from manifest progress
|
||||
useEffect(() => {
|
||||
const passed = new Set<string>()
|
||||
for (const cp of manifest.checkpoints) {
|
||||
if (cp.progress?.passed) {
|
||||
passed.add(cp.checkpoint_id)
|
||||
}
|
||||
}
|
||||
setPassedCheckpoints(passed)
|
||||
}, [manifest])
|
||||
|
||||
// Find next unpassed checkpoint
|
||||
const getNextUnpassedCheckpoint = useCallback((): CheckpointEntry | null => {
|
||||
for (const cp of manifest.checkpoints) {
|
||||
if (!passedCheckpoints.has(cp.checkpoint_id)) {
|
||||
return cp
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [manifest.checkpoints, passedCheckpoints])
|
||||
|
||||
// Time update handler — check for checkpoint triggers
|
||||
const handleTimeUpdate = useCallback(() => {
|
||||
if (!videoRef.current || showOverlay) return
|
||||
const time = videoRef.current.currentTime
|
||||
setCurrentTime(time)
|
||||
|
||||
for (const cp of manifest.checkpoints) {
|
||||
if (passedCheckpoints.has(cp.checkpoint_id)) continue
|
||||
// Trigger checkpoint when video reaches its timestamp (within 0.5s)
|
||||
if (time >= cp.timestamp_seconds && time < cp.timestamp_seconds + 1.0) {
|
||||
videoRef.current.pause()
|
||||
setCurrentCheckpoint(cp)
|
||||
setShowOverlay(true)
|
||||
setAnswers({})
|
||||
setQuizResult(null)
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [manifest.checkpoints, passedCheckpoints, showOverlay])
|
||||
|
||||
// Seek protection — prevent skipping past unpassed checkpoints
|
||||
const handleSeeking = useCallback(() => {
|
||||
if (!videoRef.current) return
|
||||
const seekTarget = videoRef.current.currentTime
|
||||
const nextUnpassed = getNextUnpassedCheckpoint()
|
||||
if (nextUnpassed && seekTarget > nextUnpassed.timestamp_seconds) {
|
||||
videoRef.current.currentTime = nextUnpassed.timestamp_seconds - 0.5
|
||||
}
|
||||
}, [getNextUnpassedCheckpoint])
|
||||
|
||||
// Submit checkpoint quiz
|
||||
async function handleSubmitQuiz() {
|
||||
if (!currentCheckpoint) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const answerList = currentCheckpoint.questions.map((_, i) => answers[i] ?? -1)
|
||||
const result = await submitCheckpointQuiz(
|
||||
currentCheckpoint.checkpoint_id,
|
||||
assignmentId,
|
||||
answerList,
|
||||
)
|
||||
setQuizResult(result)
|
||||
|
||||
if (result.passed) {
|
||||
setPassedCheckpoints(prev => {
|
||||
const next = new Set(prev)
|
||||
next.add(currentCheckpoint.checkpoint_id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Checkpoint quiz submission failed:', e)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Continue video after passing checkpoint
|
||||
function handleContinue() {
|
||||
setShowOverlay(false)
|
||||
setCurrentCheckpoint(null)
|
||||
setQuizResult(null)
|
||||
setAnswers({})
|
||||
if (videoRef.current) {
|
||||
videoRef.current.play()
|
||||
}
|
||||
|
||||
// Check if all checkpoints passed
|
||||
const allPassed = manifest.checkpoints.every(cp => passedCheckpoints.has(cp.checkpoint_id))
|
||||
if (allPassed && onAllCheckpointsPassed) {
|
||||
onAllCheckpointsPassed()
|
||||
}
|
||||
}
|
||||
|
||||
// Retry quiz
|
||||
function handleRetry() {
|
||||
setQuizResult(null)
|
||||
setAnswers({})
|
||||
}
|
||||
|
||||
// Resume to last unpassed checkpoint
|
||||
useEffect(() => {
|
||||
if (!videoRef.current || !manifest.checkpoints.length) return
|
||||
const nextUnpassed = getNextUnpassedCheckpoint()
|
||||
if (nextUnpassed && nextUnpassed.timestamp_seconds > 0) {
|
||||
// Start a bit before the checkpoint
|
||||
const startTime = Math.max(0, nextUnpassed.timestamp_seconds - 2)
|
||||
videoRef.current.currentTime = startTime
|
||||
}
|
||||
}, []) // Only on mount
|
||||
|
||||
const handleLoadedMetadata = useCallback(() => {
|
||||
if (videoRef.current) {
|
||||
setDuration(videoRef.current.duration)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Progress bar percentage
|
||||
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="relative bg-black rounded-lg overflow-hidden">
|
||||
{/* Video element */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full"
|
||||
src={manifest.stream_url}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onSeeking={handleSeeking}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
controls={!showOverlay}
|
||||
/>
|
||||
|
||||
{/* Custom progress bar with checkpoint markers */}
|
||||
<div className="relative h-2 bg-gray-700">
|
||||
{/* Progress fill */}
|
||||
<div
|
||||
className="h-full bg-indigo-500 transition-all"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
{/* Checkpoint markers */}
|
||||
{manifest.checkpoints.map(cp => {
|
||||
const pos = duration > 0 ? (cp.timestamp_seconds / duration) * 100 : 0
|
||||
const isPassed = passedCheckpoints.has(cp.checkpoint_id)
|
||||
return (
|
||||
<div
|
||||
key={cp.checkpoint_id}
|
||||
className={`absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full border-2 border-white ${
|
||||
isPassed ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ left: `${pos}%` }}
|
||||
title={`${cp.title} (${isPassed ? 'Bestanden' : 'Ausstehend'})`}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Checkpoint overlay */}
|
||||
{showOverlay && currentCheckpoint && (
|
||||
<div className="absolute inset-0 bg-black/80 flex items-center justify-center p-6 overflow-y-auto">
|
||||
<div className="bg-white rounded-xl p-6 max-w-2xl w-full max-h-[90%] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-4 pb-3 border-b border-gray-200">
|
||||
<div className="w-8 h-8 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-red-600 font-bold text-sm">
|
||||
{currentCheckpoint.index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">Checkpoint: {currentCheckpoint.title}</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Beantworten Sie die Fragen, um fortzufahren
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{quizResult ? (
|
||||
/* Quiz result */
|
||||
<div>
|
||||
<div className={`text-center p-6 rounded-lg mb-4 ${
|
||||
quizResult.passed ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'
|
||||
}`}>
|
||||
<div className="text-3xl mb-2">{quizResult.passed ? '\u2705' : '\u274C'}</div>
|
||||
<h4 className="text-lg font-bold mb-1">
|
||||
{quizResult.passed ? 'Checkpoint bestanden!' : 'Nicht bestanden'}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
Ergebnis: {Math.round(quizResult.score)}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
<div className="space-y-3 mb-4">
|
||||
{quizResult.feedback.map((fb, i) => (
|
||||
<div key={i} className={`p-3 rounded-lg text-sm ${
|
||||
fb.correct ? 'bg-green-50 border-l-4 border-green-400' : 'bg-red-50 border-l-4 border-red-400'
|
||||
}`}>
|
||||
<p className="font-medium">{fb.question}</p>
|
||||
{!fb.correct && (
|
||||
<p className="text-gray-600 mt-1">{fb.explanation}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{quizResult.passed ? (
|
||||
<button
|
||||
onClick={handleContinue}
|
||||
className="w-full px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||
>
|
||||
Video fortsetzen
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Quiz questions */
|
||||
<div>
|
||||
<div className="space-y-4">
|
||||
{currentCheckpoint.questions.map((q, qIdx) => (
|
||||
<div key={qIdx} className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="font-medium text-gray-900 mb-3 text-sm">
|
||||
<span className="text-indigo-600 mr-1">Frage {qIdx + 1}.</span>
|
||||
{q.question}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{q.options.map((opt, oIdx) => (
|
||||
<label
|
||||
key={oIdx}
|
||||
className={`flex items-center gap-3 p-2.5 rounded-lg border cursor-pointer transition-colors text-sm ${
|
||||
answers[qIdx] === oIdx
|
||||
? 'border-indigo-500 bg-indigo-50'
|
||||
: 'border-gray-200 hover:bg-white'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={`checkpoint-q-${qIdx}`}
|
||||
checked={answers[qIdx] === oIdx}
|
||||
onChange={() => setAnswers(prev => ({ ...prev, [qIdx]: oIdx }))}
|
||||
className="text-indigo-600"
|
||||
/>
|
||||
<span className="text-gray-700">{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmitQuiz}
|
||||
disabled={submitting || Object.keys(answers).length < currentCheckpoint.questions.length}
|
||||
className="w-full mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Wird ausgewertet...' : `Antworten absenden (${Object.keys(answers).length}/${currentCheckpoint.questions.length})`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Checkpoint status bar */}
|
||||
<div className="bg-gray-800 px-4 py-2 flex items-center gap-2 text-xs text-gray-300">
|
||||
<span>Checkpoints:</span>
|
||||
{manifest.checkpoints.map(cp => (
|
||||
<span
|
||||
key={cp.checkpoint_id}
|
||||
className={`px-2 py-0.5 rounded-full ${
|
||||
passedCheckpoints.has(cp.checkpoint_id)
|
||||
? 'bg-green-700 text-green-100'
|
||||
: 'bg-gray-600 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{cp.title}
|
||||
</span>
|
||||
))}
|
||||
{manifest.checkpoints.length > 0 && (
|
||||
<span className="ml-auto">
|
||||
{passedCheckpoints.size}/{manifest.checkpoints.length} bestanden
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,12 @@ const mockFetch = vi.fn()
|
||||
global.fetch = mockFetch
|
||||
|
||||
// Import after mocking
|
||||
import { sdkApiClient } from '../api-client'
|
||||
import { SDKApiClient } from '../api-client'
|
||||
|
||||
const client = new SDKApiClient({
|
||||
baseUrl: '/api/sdk/v1',
|
||||
tenantId: 'test-tenant',
|
||||
})
|
||||
|
||||
describe('SDK API Client', () => {
|
||||
beforeEach(() => {
|
||||
@@ -19,39 +24,35 @@ describe('SDK API Client', () => {
|
||||
it('fetches modules from backend', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([{ id: 'mod-1', name: 'DSGVO' }]),
|
||||
json: () => Promise.resolve({ modules: [{ id: 'mod-1', name: 'DSGVO' }], total: 1 }),
|
||||
})
|
||||
|
||||
const result = await sdkApiClient.getModules()
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('DSGVO')
|
||||
const result = await client.getModules()
|
||||
expect(result.modules).toHaveLength(1)
|
||||
expect(result.total).toBe(1)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/sdk/v1/modules'),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
|
||||
it('returns empty array on error', async () => {
|
||||
it('throws on network error', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
||||
const result = await sdkApiClient.getModules()
|
||||
expect(result).toEqual([])
|
||||
await expect(client.getModules()).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('analyzeDocument', () => {
|
||||
it('sends FormData to import analyze endpoint', async () => {
|
||||
const mockResponse = {
|
||||
document_id: 'doc-1',
|
||||
detected_type: 'DSFA',
|
||||
confidence: 0.85,
|
||||
}
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
json: () => Promise.resolve({
|
||||
data: { document_id: 'doc-1', detected_type: 'DSFA', confidence: 0.85 },
|
||||
}),
|
||||
})
|
||||
|
||||
const formData = new FormData()
|
||||
const result = await sdkApiClient.analyzeDocument(formData)
|
||||
const result = await client.analyzeDocument(formData) as any
|
||||
expect(result.document_id).toBe('doc-1')
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/sdk/v1/import/analyze'),
|
||||
@@ -62,19 +63,15 @@ describe('SDK API Client', () => {
|
||||
|
||||
describe('scanDependencies', () => {
|
||||
it('sends FormData to screening scan endpoint', async () => {
|
||||
const mockResponse = {
|
||||
id: 'scan-1',
|
||||
status: 'completed',
|
||||
total_components: 10,
|
||||
total_issues: 2,
|
||||
}
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
json: () => Promise.resolve({
|
||||
data: { id: 'scan-1', status: 'completed', total_components: 10, total_issues: 2 },
|
||||
}),
|
||||
})
|
||||
|
||||
const formData = new FormData()
|
||||
const result = await sdkApiClient.scanDependencies(formData)
|
||||
const result = await client.scanDependencies(formData) as any
|
||||
expect(result.id).toBe('scan-1')
|
||||
expect(result.total_components).toBe(10)
|
||||
})
|
||||
@@ -82,16 +79,15 @@ describe('SDK API Client', () => {
|
||||
|
||||
describe('assessUseCase', () => {
|
||||
it('sends intake data to UCCA assess endpoint', async () => {
|
||||
const mockResult = { id: 'assessment-1', feasibility: 'GREEN' }
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResult),
|
||||
json: () => Promise.resolve({ id: 'assessment-1', feasibility: 'GREEN' }),
|
||||
})
|
||||
|
||||
const result = await sdkApiClient.assessUseCase({
|
||||
const result = await client.assessUseCase({
|
||||
name: 'Test Use Case',
|
||||
domain: 'education',
|
||||
})
|
||||
}) as any
|
||||
expect(result.feasibility).toBe('GREEN')
|
||||
})
|
||||
})
|
||||
@@ -100,10 +96,10 @@ describe('SDK API Client', () => {
|
||||
it('fetches assessment list', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([{ id: 'a1' }, { id: 'a2' }]),
|
||||
json: () => Promise.resolve({ data: [{ id: 'a1' }, { id: 'a2' }] }),
|
||||
})
|
||||
|
||||
const result = await sdkApiClient.getAssessments()
|
||||
const result = await client.getAssessments()
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { complianceScopeEngine } from '../compliance-scope-engine'
|
||||
|
||||
// Helper: create an answer object (engine reads answerValue + questionId)
|
||||
function ans(questionId: string, answerValue: unknown) {
|
||||
return { questionId, answerValue } as any
|
||||
// Helper: create an answer object (engine reads value + questionId)
|
||||
function ans(questionId: string, value: unknown) {
|
||||
return { questionId, value } as any
|
||||
}
|
||||
|
||||
// Helper: create a minimal triggered-trigger (engine shape, not types shape)
|
||||
@@ -18,10 +18,10 @@ function trigger(ruleId: string, minimumLevel: string, opts: Record<string, unkn
|
||||
describe('calculateScores', () => {
|
||||
it('returns zero composite for empty answers', () => {
|
||||
const scores = complianceScopeEngine.calculateScores([])
|
||||
expect(scores.composite).toBe(0)
|
||||
expect(scores.risk).toBe(0)
|
||||
expect(scores.complexity).toBe(0)
|
||||
expect(scores.assurance).toBe(0)
|
||||
expect(scores.composite_score).toBe(0)
|
||||
expect(scores.risk_score).toBe(0)
|
||||
expect(scores.complexity_score).toBe(0)
|
||||
expect(scores.assurance_need).toBe(0)
|
||||
})
|
||||
|
||||
it('all-false boolean answers → zero composite', () => {
|
||||
@@ -30,25 +30,25 @@ describe('calculateScores', () => {
|
||||
ans('data_minors', false),
|
||||
ans('proc_ai_usage', false),
|
||||
])
|
||||
expect(scores.composite).toBe(0)
|
||||
expect(scores.composite_score).toBe(0)
|
||||
})
|
||||
|
||||
it('boolean true answer increases risk score', () => {
|
||||
const scoresFalse = complianceScopeEngine.calculateScores([ans('data_art9', false)])
|
||||
const scoresTrue = complianceScopeEngine.calculateScores([ans('data_art9', true)])
|
||||
expect(scoresTrue.risk).toBeGreaterThan(scoresFalse.risk)
|
||||
expect(scoresTrue.risk_score).toBeGreaterThan(scoresFalse.risk_score)
|
||||
})
|
||||
|
||||
it('composite is weighted sum: risk×0.4 + complexity×0.3 + assurance×0.3', () => {
|
||||
const scores = complianceScopeEngine.calculateScores([ans('data_art9', true)])
|
||||
const expected = Math.round((scores.risk * 0.4 + scores.complexity * 0.3 + scores.assurance * 0.3) * 10) / 10
|
||||
expect(scores.composite).toBe(expected)
|
||||
const expected = Math.round((scores.risk_score * 0.4 + scores.complexity_score * 0.3 + scores.assurance_need * 0.3) * 10) / 10
|
||||
expect(scores.composite_score).toBe(expected)
|
||||
})
|
||||
|
||||
it('numeric answer uses logarithmic normalization — higher value → higher score', () => {
|
||||
const scoresLow = complianceScopeEngine.calculateScores([ans('data_volume', 10)])
|
||||
const scoresHigh = complianceScopeEngine.calculateScores([ans('data_volume', 999)])
|
||||
expect(scoresHigh.composite).toBeGreaterThan(scoresLow.composite)
|
||||
expect(scoresHigh.composite_score).toBeGreaterThan(scoresLow.composite_score)
|
||||
})
|
||||
|
||||
it('array answer score proportional to count (max 1.0 at 5+)', () => {
|
||||
@@ -56,13 +56,13 @@ describe('calculateScores', () => {
|
||||
const scores5 = complianceScopeEngine.calculateScores([
|
||||
ans('data_art9', ['gesundheit', 'biometrie', 'genetik', 'politisch', 'religion']),
|
||||
])
|
||||
expect(scores5.composite).toBeGreaterThan(scores1.composite)
|
||||
expect(scores5.composite_score).toBeGreaterThan(scores1.composite_score)
|
||||
})
|
||||
|
||||
it('empty array answer → zero contribution', () => {
|
||||
const scoresEmpty = complianceScopeEngine.calculateScores([ans('data_art9', [])])
|
||||
const scoresNone = complianceScopeEngine.calculateScores([])
|
||||
expect(scoresEmpty.composite).toBe(scoresNone.composite)
|
||||
expect(scoresEmpty.composite_score).toBe(scoresNone.composite_score)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -72,56 +72,56 @@ describe('calculateScores', () => {
|
||||
|
||||
describe('determineLevel', () => {
|
||||
it('composite ≤25 → L1', () => {
|
||||
const level = complianceScopeEngine.determineLevel({ composite: 20 } as any, [])
|
||||
const level = complianceScopeEngine.determineLevel({ composite_score: 20 } as any, [])
|
||||
expect(level).toBe('L1')
|
||||
})
|
||||
|
||||
it('composite exactly 25 → L1', () => {
|
||||
const level = complianceScopeEngine.determineLevel({ composite: 25 } as any, [])
|
||||
const level = complianceScopeEngine.determineLevel({ composite_score: 25 } as any, [])
|
||||
expect(level).toBe('L1')
|
||||
})
|
||||
|
||||
it('composite 26–50 → L2', () => {
|
||||
const level = complianceScopeEngine.determineLevel({ composite: 40 } as any, [])
|
||||
const level = complianceScopeEngine.determineLevel({ composite_score: 40 } as any, [])
|
||||
expect(level).toBe('L2')
|
||||
})
|
||||
|
||||
it('composite exactly 50 → L2', () => {
|
||||
const level = complianceScopeEngine.determineLevel({ composite: 50 } as any, [])
|
||||
const level = complianceScopeEngine.determineLevel({ composite_score: 50 } as any, [])
|
||||
expect(level).toBe('L2')
|
||||
})
|
||||
|
||||
it('composite 51–75 → L3', () => {
|
||||
const level = complianceScopeEngine.determineLevel({ composite: 60 } as any, [])
|
||||
const level = complianceScopeEngine.determineLevel({ composite_score: 60 } as any, [])
|
||||
expect(level).toBe('L3')
|
||||
})
|
||||
|
||||
it('composite >75 → L4', () => {
|
||||
const level = complianceScopeEngine.determineLevel({ composite: 80 } as any, [])
|
||||
const level = complianceScopeEngine.determineLevel({ composite_score: 80 } as any, [])
|
||||
expect(level).toBe('L4')
|
||||
})
|
||||
|
||||
it('hard trigger with minimumLevel L3 overrides score-based L1', () => {
|
||||
const t = trigger('HT-A01', 'L3')
|
||||
const level = complianceScopeEngine.determineLevel({ composite: 10 } as any, [t])
|
||||
const level = complianceScopeEngine.determineLevel({ composite_score: 10 } as any, [t])
|
||||
expect(level).toBe('L3')
|
||||
})
|
||||
|
||||
it('hard trigger with minimumLevel L4 overrides score-based L2', () => {
|
||||
const t = trigger('HT-F01', 'L4')
|
||||
const level = complianceScopeEngine.determineLevel({ composite: 40 } as any, [t])
|
||||
const level = complianceScopeEngine.determineLevel({ composite_score: 40 } as any, [t])
|
||||
expect(level).toBe('L4')
|
||||
})
|
||||
|
||||
it('level = max(score-level, trigger-level) — score wins when higher', () => {
|
||||
const t = trigger('HT-B01', 'L2')
|
||||
// score gives L3, trigger gives L2 → max = L3
|
||||
const level = complianceScopeEngine.determineLevel({ composite: 60 } as any, [t])
|
||||
const level = complianceScopeEngine.determineLevel({ composite_score: 60 } as any, [t])
|
||||
expect(level).toBe('L3')
|
||||
})
|
||||
|
||||
it('no triggers with zero composite → L1', () => {
|
||||
const level = complianceScopeEngine.determineLevel({ composite: 0 } as any, [])
|
||||
const level = complianceScopeEngine.determineLevel({ composite_score: 0 } as any, [])
|
||||
expect(level).toBe('L1')
|
||||
})
|
||||
})
|
||||
@@ -215,7 +215,7 @@ describe('evaluate — integration', () => {
|
||||
|
||||
it('composite score non-negative', () => {
|
||||
const decision = complianceScopeEngine.evaluate([ans('org_employee_count', '50-249')])
|
||||
expect((decision.scores as any).composite).toBeGreaterThanOrEqual(0)
|
||||
expect((decision.scores as any).composite_score).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('evaluate returns array types for collections', () => {
|
||||
@@ -265,6 +265,42 @@ describe('buildDocumentScope', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes UPPERCASE trigger doc names to lowercase ScopeDocumentType', () => {
|
||||
const t = trigger('HT-test', 'L2', {
|
||||
category: 'test',
|
||||
mandatoryDocuments: ['VVT', 'TOM', 'DSFA'],
|
||||
})
|
||||
const docs = complianceScopeEngine.buildDocumentScope('L2', [t], [])
|
||||
const vvt = docs.find((d: any) => d.documentType === 'vvt')
|
||||
const tom = docs.find((d: any) => d.documentType === 'tom')
|
||||
const dsfa = docs.find((d: any) => d.documentType === 'dsfa')
|
||||
expect(vvt).toBeDefined()
|
||||
expect(vvt!.requirement).toBe('mandatory')
|
||||
expect(vvt!.triggeredBy).toContain('HT-test')
|
||||
expect(tom).toBeDefined()
|
||||
expect(tom!.requirement).toBe('mandatory')
|
||||
expect(dsfa).toBeDefined()
|
||||
expect(dsfa!.requirement).toBe('mandatory')
|
||||
})
|
||||
|
||||
it('normalizes aliased doc names (DSE→dsi, LOESCHKONZEPT→lf)', () => {
|
||||
const t = trigger('HT-alias', 'L2', {
|
||||
category: 'test',
|
||||
mandatoryDocuments: ['DSE', 'LOESCHKONZEPT', 'DSR_PROZESS'],
|
||||
})
|
||||
const docs = complianceScopeEngine.buildDocumentScope('L2', [t], [])
|
||||
const dsi = docs.find((d: any) => d.documentType === 'dsi')
|
||||
const lf = docs.find((d: any) => d.documentType === 'lf')
|
||||
const betroffenenrechte = docs.find((d: any) => d.documentType === 'betroffenenrechte')
|
||||
expect(dsi).toBeDefined()
|
||||
expect(dsi!.requirement).toBe('mandatory')
|
||||
expect(dsi!.triggeredBy).toContain('HT-alias')
|
||||
expect(lf).toBeDefined()
|
||||
expect(lf!.requirement).toBe('mandatory')
|
||||
expect(betroffenenrechte).toBeDefined()
|
||||
expect(betroffenenrechte!.requirement).toBe('mandatory')
|
||||
})
|
||||
|
||||
it('documents sorted: mandatory first', () => {
|
||||
const decision = complianceScopeEngine.evaluate([
|
||||
ans('data_art9', ['gesundheit']),
|
||||
@@ -370,3 +406,101 @@ describe('evaluateRiskFlags', () => {
|
||||
expect(orgFlag).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// HT-H01a/b: B2B vs B2C Webshop Trigger Split
|
||||
// ============================================================================
|
||||
|
||||
describe('HT-H01a/b — B2B vs B2C Webshop', () => {
|
||||
it('B2C webshop triggers HT-H01a with Verbraucherschutz documents', () => {
|
||||
const triggers = complianceScopeEngine.evaluateHardTriggers([
|
||||
ans('prod_webshop', true),
|
||||
ans('org_business_model', 'B2C'),
|
||||
])
|
||||
const h01a = triggers.find((t: any) => t.ruleId === 'HT-H01a')
|
||||
const h01b = triggers.find((t: any) => t.ruleId === 'HT-H01b')
|
||||
expect(h01a).toBeDefined()
|
||||
expect(h01b).toBeUndefined()
|
||||
expect(h01a!.mandatoryDocuments).toContain('WIDERRUFSBELEHRUNG')
|
||||
expect(h01a!.mandatoryDocuments).toContain('PREISANGABEN')
|
||||
expect(h01a!.mandatoryDocuments).toContain('FERNABSATZ_INFO')
|
||||
expect(h01a!.mandatoryDocuments).toContain('STREITBEILEGUNG')
|
||||
})
|
||||
|
||||
it('B2B webshop triggers HT-H01b without Verbraucherschutz documents', () => {
|
||||
const triggers = complianceScopeEngine.evaluateHardTriggers([
|
||||
ans('prod_webshop', true),
|
||||
ans('org_business_model', 'B2B'),
|
||||
])
|
||||
const h01a = triggers.find((t: any) => t.ruleId === 'HT-H01a')
|
||||
const h01b = triggers.find((t: any) => t.ruleId === 'HT-H01b')
|
||||
expect(h01a).toBeUndefined()
|
||||
expect(h01b).toBeDefined()
|
||||
expect(h01b!.mandatoryDocuments).toContain('DSE')
|
||||
expect(h01b!.mandatoryDocuments).toContain('AGB')
|
||||
expect(h01b!.mandatoryDocuments).toContain('COOKIE_BANNER')
|
||||
expect(h01b!.mandatoryDocuments).not.toContain('WIDERRUFSBELEHRUNG')
|
||||
expect(h01b!.mandatoryDocuments).not.toContain('PREISANGABEN')
|
||||
})
|
||||
|
||||
it('B2B_B2C (hybrid) webshop triggers HT-H01a (Verbraucherschutz applies)', () => {
|
||||
const triggers = complianceScopeEngine.evaluateHardTriggers([
|
||||
ans('prod_webshop', true),
|
||||
ans('org_business_model', 'B2B_B2C'),
|
||||
])
|
||||
const h01a = triggers.find((t: any) => t.ruleId === 'HT-H01a')
|
||||
const h01b = triggers.find((t: any) => t.ruleId === 'HT-H01b')
|
||||
expect(h01a).toBeDefined()
|
||||
expect(h01b).toBeUndefined()
|
||||
})
|
||||
|
||||
it('no webshop → neither HT-H01a nor HT-H01b fires', () => {
|
||||
const triggers = complianceScopeEngine.evaluateHardTriggers([
|
||||
ans('prod_webshop', false),
|
||||
ans('org_business_model', 'B2C'),
|
||||
])
|
||||
const h01a = triggers.find((t: any) => t.ruleId === 'HT-H01a')
|
||||
const h01b = triggers.find((t: any) => t.ruleId === 'HT-H01b')
|
||||
expect(h01a).toBeUndefined()
|
||||
expect(h01b).toBeUndefined()
|
||||
})
|
||||
|
||||
it('webshop without business_model answer → HT-H01a fires (excludeWhen not matched)', () => {
|
||||
const triggers = complianceScopeEngine.evaluateHardTriggers([
|
||||
ans('prod_webshop', true),
|
||||
])
|
||||
const h01a = triggers.find((t: any) => t.ruleId === 'HT-H01a')
|
||||
const h01b = triggers.find((t: any) => t.ruleId === 'HT-H01b')
|
||||
// excludeWhen B2B: not matched (undefined !== 'B2B') → fires
|
||||
expect(h01a).toBeDefined()
|
||||
// requireWhen B2B: not matched (undefined !== 'B2B') → does not fire
|
||||
expect(h01b).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// excludeWhen / requireWhen Logic (unit)
|
||||
// ============================================================================
|
||||
|
||||
describe('excludeWhen / requireWhen — generic logic', () => {
|
||||
it('excludeWhen with array value excludes any matching value', () => {
|
||||
// HT-H01a has excludeWhen: { questionId: 'org_business_model', value: 'B2B' }
|
||||
// This test verifies the single-value case works (B2B excluded)
|
||||
const triggers = complianceScopeEngine.evaluateHardTriggers([
|
||||
ans('prod_webshop', true),
|
||||
ans('org_business_model', 'B2B'),
|
||||
])
|
||||
const h01a = triggers.find((t: any) => t.ruleId === 'HT-H01a')
|
||||
expect(h01a).toBeUndefined()
|
||||
})
|
||||
|
||||
it('requireWhen with non-matching value prevents trigger', () => {
|
||||
// HT-H01b has requireWhen: { questionId: 'org_business_model', value: 'B2B' }
|
||||
const triggers = complianceScopeEngine.evaluateHardTriggers([
|
||||
ans('prod_webshop', true),
|
||||
ans('org_business_model', 'B2C'),
|
||||
])
|
||||
const h01b = triggers.find((t: any) => t.ruleId === 'HT-H01b')
|
||||
expect(h01b).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
193
admin-compliance/lib/sdk/__tests__/scope-to-facts.test.ts
Normal file
193
admin-compliance/lib/sdk/__tests__/scope-to-facts.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
parseEmployeeRange,
|
||||
parseRevenueRange,
|
||||
buildAssessmentPayload,
|
||||
} from '../scope-to-facts'
|
||||
import type { CompanyProfile } from '../types'
|
||||
import type { ScopeProfilingAnswer, ScopeDecision } from '../compliance-scope-types'
|
||||
|
||||
// =============================================================================
|
||||
// parseEmployeeRange
|
||||
// =============================================================================
|
||||
|
||||
describe('parseEmployeeRange', () => {
|
||||
it('returns 5 for "1-9"', () => {
|
||||
expect(parseEmployeeRange('1-9')).toBe(5)
|
||||
})
|
||||
|
||||
it('returns 30 for "10-49"', () => {
|
||||
expect(parseEmployeeRange('10-49')).toBe(30)
|
||||
})
|
||||
|
||||
it('returns 150 for "50-249"', () => {
|
||||
expect(parseEmployeeRange('50-249')).toBe(150)
|
||||
})
|
||||
|
||||
it('returns 625 for "250-999"', () => {
|
||||
expect(parseEmployeeRange('250-999')).toBe(625)
|
||||
})
|
||||
|
||||
it('returns 1500 for "1000+"', () => {
|
||||
expect(parseEmployeeRange('1000+')).toBe(1500)
|
||||
})
|
||||
|
||||
it('returns 10 for null', () => {
|
||||
expect(parseEmployeeRange(null)).toBe(10)
|
||||
})
|
||||
|
||||
it('returns 10 for undefined', () => {
|
||||
expect(parseEmployeeRange(undefined)).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// parseRevenueRange
|
||||
// =============================================================================
|
||||
|
||||
describe('parseRevenueRange', () => {
|
||||
it('returns 1000000 for "< 2 Mio"', () => {
|
||||
expect(parseRevenueRange('< 2 Mio')).toBe(1000000)
|
||||
})
|
||||
|
||||
it('returns 6000000 for "2-10 Mio"', () => {
|
||||
expect(parseRevenueRange('2-10 Mio')).toBe(6000000)
|
||||
})
|
||||
|
||||
it('returns 30000000 for "10-50 Mio"', () => {
|
||||
expect(parseRevenueRange('10-50 Mio')).toBe(30000000)
|
||||
})
|
||||
|
||||
it('returns 75000000 for "> 50 Mio"', () => {
|
||||
expect(parseRevenueRange('> 50 Mio')).toBe(75000000)
|
||||
})
|
||||
|
||||
it('returns 1000000 for null', () => {
|
||||
expect(parseRevenueRange(null)).toBe(1000000)
|
||||
})
|
||||
|
||||
it('returns 1000000 for undefined', () => {
|
||||
expect(parseRevenueRange(undefined)).toBe(1000000)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// buildAssessmentPayload
|
||||
// =============================================================================
|
||||
|
||||
describe('buildAssessmentPayload', () => {
|
||||
const baseProfile: CompanyProfile = {
|
||||
companyName: 'Test GmbH',
|
||||
legalForm: 'GmbH',
|
||||
industry: ['IT', 'Software'],
|
||||
employeeCount: '50-249',
|
||||
annualRevenue: '10-50 Mio',
|
||||
headquartersCountry: 'DE',
|
||||
headquartersState: 'BW',
|
||||
isDataController: true,
|
||||
isDataProcessor: false,
|
||||
offerings: ['software_saas'],
|
||||
}
|
||||
|
||||
const baseAnswers: ScopeProfilingAnswer[] = [
|
||||
{ questionId: 'data_art9', value: false, blockId: 'data' },
|
||||
{ questionId: 'data_minors', value: false, blockId: 'data' },
|
||||
{ questionId: 'data_hr', value: true, blockId: 'data' },
|
||||
{ questionId: 'data_financial', value: false, blockId: 'data' },
|
||||
{ questionId: 'tech_third_country', value: true, blockId: 'tech' },
|
||||
{ questionId: 'tech_subprocessors', value: true, blockId: 'tech' },
|
||||
{ questionId: 'proc_adm_scoring', value: false, blockId: 'processing' },
|
||||
{ questionId: 'proc_employee_monitoring', value: false, blockId: 'processing' },
|
||||
{ questionId: 'proc_video_surveillance', value: false, blockId: 'processing' },
|
||||
{ questionId: 'proc_tracking', value: false, blockId: 'processing' },
|
||||
{ questionId: 'prod_cookies_consent', value: true, blockId: 'product' },
|
||||
{ questionId: 'data_volume', value: false, blockId: 'data' },
|
||||
{ questionId: 'ai_uses_ai', value: true, blockId: 'ai' },
|
||||
{ questionId: 'ai_categories', value: ['ai_provider'], blockId: 'ai' },
|
||||
{ questionId: 'ai_risk_assessment', value: 'limited', blockId: 'ai' },
|
||||
{ questionId: 'org_cert_target', value: 'iso27001', blockId: 'organisation' },
|
||||
]
|
||||
|
||||
it('maps a full profile correctly', () => {
|
||||
const payload = buildAssessmentPayload(baseProfile, baseAnswers, null)
|
||||
|
||||
expect(payload.employee_count).toBe(150)
|
||||
expect(payload.annual_revenue).toBe(30000000)
|
||||
expect(payload.country).toBe('DE')
|
||||
expect(payload.industry).toBe('IT, Software')
|
||||
expect(payload.legal_form).toBe('GmbH')
|
||||
expect(payload.is_controller).toBe(true)
|
||||
expect(payload.is_processor).toBe(false)
|
||||
expect(payload.cross_border_transfer).toBe(true)
|
||||
expect(payload.uses_processors).toBe(true)
|
||||
expect(payload.uses_cookies).toBe(true)
|
||||
expect(payload.processes_employee_data).toBe(true)
|
||||
expect(payload.operates_platform).toBe(true)
|
||||
expect(payload.proc_ai_usage).toBe(true)
|
||||
expect(payload.cert_target).toBe('iso27001')
|
||||
})
|
||||
|
||||
it('uses defaults for null/undefined profile fields', () => {
|
||||
const emptyProfile: CompanyProfile = {
|
||||
companyName: 'Minimal',
|
||||
}
|
||||
const payload = buildAssessmentPayload(emptyProfile, [], null)
|
||||
|
||||
expect(payload.employee_count).toBe(10) // parseEmployeeRange(undefined)
|
||||
expect(payload.annual_revenue).toBe(1000000)
|
||||
expect(payload.country).toBe('DE') // default
|
||||
expect(payload.industry).toBe('')
|
||||
expect(payload.legal_form).toBe('')
|
||||
expect(payload.is_controller).toBe(true) // default
|
||||
expect(payload.is_processor).toBe(false) // default
|
||||
expect(payload.determined_level).toBe('L2') // default
|
||||
})
|
||||
|
||||
it('detects AI provider from ai_categories', () => {
|
||||
const payload = buildAssessmentPayload(baseProfile, baseAnswers, null)
|
||||
|
||||
expect(payload.is_ai_provider).toBe(true)
|
||||
expect(payload.is_ai_deployer).toBe(false)
|
||||
expect(payload.limited_risk_ai).toBe(true)
|
||||
expect(payload.high_risk_ai).toBe(false)
|
||||
})
|
||||
|
||||
it('detects AI deployer from ai_categories', () => {
|
||||
const deployerAnswers = baseAnswers.map(a =>
|
||||
a.questionId === 'ai_categories'
|
||||
? { ...a, value: ['ai_deployer'] }
|
||||
: a
|
||||
)
|
||||
const payload = buildAssessmentPayload(baseProfile, deployerAnswers, null)
|
||||
|
||||
expect(payload.is_ai_provider).toBe(false)
|
||||
expect(payload.is_ai_deployer).toBe(true)
|
||||
})
|
||||
|
||||
it('detects financial institution from industry', () => {
|
||||
const finProfile: CompanyProfile = {
|
||||
...baseProfile,
|
||||
industry: ['Finanzdienstleistungen', 'Banking'],
|
||||
}
|
||||
const payload = buildAssessmentPayload(finProfile, baseAnswers, null)
|
||||
|
||||
expect(payload.is_financial_institution).toBe(true)
|
||||
})
|
||||
|
||||
it('includes decision data when provided', () => {
|
||||
const decision: ScopeDecision = {
|
||||
determinedLevel: 'L3',
|
||||
triggeredHardTriggers: [
|
||||
{ rule: { id: 'rule-1', name: 'Test Rule', description: '', targetLevel: 'L3', trigger: { field: '', op: 'eq', value: true } }, factValue: true },
|
||||
],
|
||||
requiredDocuments: [
|
||||
{ documentType: 'dsfa', reason: 'test', regulation: 'dsgvo' },
|
||||
],
|
||||
} as any
|
||||
const payload = buildAssessmentPayload(baseProfile, baseAnswers, decision)
|
||||
|
||||
expect(payload.determined_level).toBe('L3')
|
||||
expect(payload.triggered_rules).toEqual(['rule-1'])
|
||||
expect(payload.required_documents).toEqual(['dsfa'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { resolveAuthorities } from '../supervisory-authority-resolver'
|
||||
|
||||
// =============================================================================
|
||||
// Datenschutz-Aufsichtsbehoerde (DSGVO)
|
||||
// =============================================================================
|
||||
|
||||
describe('resolveAuthorities — Datenschutz', () => {
|
||||
it('resolves DE + BW to LfDI BW', () => {
|
||||
const results = resolveAuthorities('BW', 'DE', ['dsgvo'])
|
||||
const dp = results.find(r => r.domain === 'Datenschutz')
|
||||
expect(dp).toBeDefined()
|
||||
expect(dp!.authority.abbreviation).toBe('LfDI BW')
|
||||
})
|
||||
|
||||
it('resolves DE + BY to BayLDA', () => {
|
||||
const results = resolveAuthorities('BY', 'DE', ['dsgvo'])
|
||||
const dp = results.find(r => r.domain === 'Datenschutz')
|
||||
expect(dp).toBeDefined()
|
||||
expect(dp!.authority.abbreviation).toBe('BayLDA')
|
||||
})
|
||||
|
||||
it('resolves DE without state to BfDI', () => {
|
||||
const results = resolveAuthorities(undefined, 'DE', ['dsgvo'])
|
||||
const dp = results.find(r => r.domain === 'Datenschutz')
|
||||
expect(dp).toBeDefined()
|
||||
expect(dp!.authority.abbreviation).toBe('BfDI')
|
||||
})
|
||||
|
||||
it('resolves AT to DSB AT', () => {
|
||||
const results = resolveAuthorities(undefined, 'AT', ['dsgvo'])
|
||||
const dp = results.find(r => r.domain === 'Datenschutz')
|
||||
expect(dp).toBeDefined()
|
||||
expect(dp!.authority.abbreviation).toBe('DSB AT')
|
||||
})
|
||||
|
||||
it('resolves CH to EDOEB', () => {
|
||||
const results = resolveAuthorities(undefined, 'CH', ['dsgvo'])
|
||||
const dp = results.find(r => r.domain === 'Datenschutz')
|
||||
expect(dp).toBeDefined()
|
||||
expect(dp!.authority.abbreviation).toBe('EDOEB')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Regulierungs-spezifische Behoerden
|
||||
// =============================================================================
|
||||
|
||||
describe('resolveAuthorities — regulation-specific', () => {
|
||||
it('includes BSI for NIS2 in DE', () => {
|
||||
const results = resolveAuthorities('BW', 'DE', ['dsgvo', 'nis2'])
|
||||
const nis2 = results.find(r => r.domain.includes('NIS2'))
|
||||
expect(nis2).toBeDefined()
|
||||
expect(nis2!.authority.abbreviation).toBe('BSI')
|
||||
})
|
||||
|
||||
it('includes BaFin for financial_policy in DE', () => {
|
||||
const results = resolveAuthorities('BW', 'DE', ['dsgvo', 'financial_policy'])
|
||||
const fin = results.find(r => r.domain.includes('Finanzaufsicht'))
|
||||
expect(fin).toBeDefined()
|
||||
expect(fin!.authority.abbreviation).toBe('BaFin')
|
||||
})
|
||||
|
||||
it('includes BNetzA for ai_act in DE', () => {
|
||||
const results = resolveAuthorities('BW', 'DE', ['dsgvo', 'ai_act'])
|
||||
const ai = results.find(r => r.domain.includes('KI-Aufsicht'))
|
||||
expect(ai).toBeDefined()
|
||||
expect(ai!.authority.abbreviation).toBe('BNetzA')
|
||||
})
|
||||
|
||||
it('includes NCSA for NIS2 outside DE', () => {
|
||||
const results = resolveAuthorities(undefined, 'AT', ['dsgvo', 'nis2'])
|
||||
const nis2 = results.find(r => r.domain.includes('NIS2'))
|
||||
expect(nis2).toBeDefined()
|
||||
expect(nis2!.authority.abbreviation).toBe('NCSA')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Edge Cases
|
||||
// =============================================================================
|
||||
|
||||
describe('resolveAuthorities — edge cases', () => {
|
||||
it('returns empty array when no regulations', () => {
|
||||
const results = resolveAuthorities('BW', 'DE', [])
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it('returns multiple authorities for multiple regulations', () => {
|
||||
const results = resolveAuthorities('BW', 'DE', ['dsgvo', 'nis2', 'ai_act', 'financial_policy'])
|
||||
expect(results.length).toBe(4)
|
||||
})
|
||||
|
||||
it('does not include BaFin for non-DE financial_policy', () => {
|
||||
const results = resolveAuthorities(undefined, 'AT', ['financial_policy'])
|
||||
const fin = results.find(r => r.domain.includes('Finanzaufsicht'))
|
||||
expect(fin).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not include BNetzA for non-DE ai_act', () => {
|
||||
const results = resolveAuthorities(undefined, 'AT', ['ai_act'])
|
||||
const ai = results.find(r => r.domain.includes('KI-Aufsicht'))
|
||||
expect(ai).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -41,9 +41,9 @@ describe('SDK_STEPS', () => {
|
||||
|
||||
describe('getStepById', () => {
|
||||
it('should return the correct step for a valid ID', () => {
|
||||
const step = getStepById('use-case-workshop')
|
||||
const step = getStepById('use-case-assessment')
|
||||
expect(step).toBeDefined()
|
||||
expect(step?.name).toBe('Use Case Workshop')
|
||||
expect(step?.name).toBe('Anwendungsfall-Erfassung')
|
||||
})
|
||||
|
||||
it('should return undefined for an invalid ID', () => {
|
||||
@@ -62,7 +62,7 @@ describe('getStepByUrl', () => {
|
||||
it('should return the correct step for a valid URL', () => {
|
||||
const step = getStepByUrl('/sdk/advisory-board')
|
||||
expect(step).toBeDefined()
|
||||
expect(step?.id).toBe('use-case-workshop')
|
||||
expect(step?.id).toBe('use-case-assessment')
|
||||
})
|
||||
|
||||
it('should return undefined for an invalid URL', () => {
|
||||
@@ -79,9 +79,9 @@ describe('getStepByUrl', () => {
|
||||
|
||||
describe('getNextStep', () => {
|
||||
it('should return the next step in sequence', () => {
|
||||
const nextStep = getNextStep('use-case-workshop')
|
||||
const nextStep = getNextStep('use-case-assessment')
|
||||
expect(nextStep).toBeDefined()
|
||||
expect(nextStep?.id).toBe('screening')
|
||||
expect(nextStep?.id).toBe('import')
|
||||
})
|
||||
|
||||
it('should return undefined for the last step', () => {
|
||||
@@ -103,11 +103,11 @@ describe('getPreviousStep', () => {
|
||||
it('should return the previous step in sequence', () => {
|
||||
const prevStep = getPreviousStep('screening')
|
||||
expect(prevStep).toBeDefined()
|
||||
expect(prevStep?.id).toBe('use-case-workshop')
|
||||
expect(prevStep?.id).toBe('import')
|
||||
})
|
||||
|
||||
it('should return undefined for the first step', () => {
|
||||
const prevStep = getPreviousStep('use-case-workshop')
|
||||
const prevStep = getPreviousStep('company-profile')
|
||||
expect(prevStep).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -120,7 +120,7 @@ describe('getCompletionPercentage', () => {
|
||||
userId: 'test',
|
||||
subscription: 'PROFESSIONAL',
|
||||
currentPhase: 1,
|
||||
currentStep: 'use-case-workshop',
|
||||
currentStep: 'use-case-assessment',
|
||||
completedSteps,
|
||||
checkpoints: {},
|
||||
useCases: [],
|
||||
@@ -189,7 +189,7 @@ describe('getPhaseCompletionPercentage', () => {
|
||||
userId: 'test',
|
||||
subscription: 'PROFESSIONAL',
|
||||
currentPhase: 1,
|
||||
currentStep: 'use-case-workshop',
|
||||
currentStep: 'use-case-assessment',
|
||||
completedSteps,
|
||||
checkpoints: {},
|
||||
useCases: [],
|
||||
|
||||
510
admin-compliance/lib/sdk/__tests__/vvt-scope-integration.test.ts
Normal file
510
admin-compliance/lib/sdk/__tests__/vvt-scope-integration.test.ts
Normal file
@@ -0,0 +1,510 @@
|
||||
/**
|
||||
* Integration Tests: Company Profile → Compliance Scope → VVT Generator
|
||||
*
|
||||
* Tests the complete data pipeline from Company Profile master data
|
||||
* through the Compliance Scope Engine to VVT activity generation.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
prefillFromCompanyProfile,
|
||||
exportToVVTAnswers,
|
||||
getAutoFilledScoringAnswers,
|
||||
SCOPE_QUESTION_BLOCKS,
|
||||
} from '../compliance-scope-profiling'
|
||||
import {
|
||||
generateActivities,
|
||||
PROFILING_QUESTIONS,
|
||||
DEPARTMENT_DATA_CATEGORIES,
|
||||
SCOPE_PREFILLED_VVT_QUESTIONS,
|
||||
} from '../vvt-profiling'
|
||||
import type { ScopeProfilingAnswer } from '../compliance-scope-types'
|
||||
|
||||
// Helper
|
||||
function ans(questionId: string, value: unknown): ScopeProfilingAnswer {
|
||||
return { questionId, value } as ScopeProfilingAnswer
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 1. Company Profile → Scope Prefill
|
||||
// =============================================================================
|
||||
|
||||
describe('CompanyProfile → Scope prefill', () => {
|
||||
it('prefills org_has_dsb when dpoName is set', () => {
|
||||
const profile = { dpoName: 'Max Mustermann' } as any
|
||||
const answers = prefillFromCompanyProfile(profile)
|
||||
expect(answers.find((a) => a.questionId === 'org_has_dsb')?.value).toBe(true)
|
||||
})
|
||||
|
||||
it('does NOT prefill org_has_dsb when dpoName is empty', () => {
|
||||
const profile = { dpoName: '' } as any
|
||||
const answers = prefillFromCompanyProfile(profile)
|
||||
expect(answers.find((a) => a.questionId === 'org_has_dsb')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('maps offerings to prod_type correctly', () => {
|
||||
const profile = { offerings: ['WebApp', 'SaaS', 'API'] } as any
|
||||
const answers = prefillFromCompanyProfile(profile)
|
||||
const prodType = answers.find((a) => a.questionId === 'prod_type')
|
||||
expect(prodType?.value).toEqual(expect.arrayContaining(['webapp', 'saas', 'api']))
|
||||
})
|
||||
|
||||
it('detects webshop in offerings', () => {
|
||||
const profile = { offerings: ['Webshop'] } as any
|
||||
const answers = prefillFromCompanyProfile(profile)
|
||||
expect(answers.find((a) => a.questionId === 'prod_webshop')?.value).toBe(true)
|
||||
})
|
||||
|
||||
it('returns empty array when profile has no relevant data', () => {
|
||||
const profile = {} as any
|
||||
const answers = prefillFromCompanyProfile(profile)
|
||||
expect(answers).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('CompanyProfile → Scope scoring answers', () => {
|
||||
it('maps employeeCount to org_employee_count', () => {
|
||||
const profile = { employeeCount: '50-249' } as any
|
||||
const answers = getAutoFilledScoringAnswers(profile)
|
||||
expect(answers.find((a) => a.questionId === 'org_employee_count')?.value).toBe('50-249')
|
||||
})
|
||||
|
||||
it('maps industry to org_industry', () => {
|
||||
const profile = { industry: ['IT & Software', 'Finanzdienstleistungen'] } as any
|
||||
const answers = getAutoFilledScoringAnswers(profile)
|
||||
expect(answers.find((a) => a.questionId === 'org_industry')?.value).toBe(
|
||||
'IT & Software, Finanzdienstleistungen'
|
||||
)
|
||||
})
|
||||
|
||||
it('maps annualRevenue to org_annual_revenue', () => {
|
||||
const profile = { annualRevenue: '1-10M' } as any
|
||||
const answers = getAutoFilledScoringAnswers(profile)
|
||||
expect(answers.find((a) => a.questionId === 'org_annual_revenue')?.value).toBe('1-10M')
|
||||
})
|
||||
|
||||
it('maps businessModel to org_business_model', () => {
|
||||
const profile = { businessModel: 'B2B' } as any
|
||||
const answers = getAutoFilledScoringAnswers(profile)
|
||||
expect(answers.find((a) => a.questionId === 'org_business_model')?.value).toBe('B2B')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 2. Scope → VVT Answer Mapping (exportToVVTAnswers)
|
||||
// =============================================================================
|
||||
|
||||
describe('Scope → VVT answer export', () => {
|
||||
it('maps scope questions with mapsToVVTQuestion property', () => {
|
||||
// Block 9: dk_dept_hr maps to dept_hr_categories
|
||||
const scopeAnswers: ScopeProfilingAnswer[] = [
|
||||
ans('dk_dept_hr', ['NAME', 'SALARY_DATA', 'HEALTH_DATA']),
|
||||
]
|
||||
const vvtAnswers = exportToVVTAnswers(scopeAnswers)
|
||||
expect(vvtAnswers.dept_hr_categories).toEqual(['NAME', 'SALARY_DATA', 'HEALTH_DATA'])
|
||||
})
|
||||
|
||||
it('maps multiple department data categories', () => {
|
||||
const scopeAnswers: ScopeProfilingAnswer[] = [
|
||||
ans('dk_dept_hr', ['NAME', 'BANK_ACCOUNT']),
|
||||
ans('dk_dept_finance', ['INVOICE_DATA', 'TAX_ID']),
|
||||
ans('dk_dept_marketing', ['EMAIL', 'TRACKING_DATA']),
|
||||
]
|
||||
const vvtAnswers = exportToVVTAnswers(scopeAnswers)
|
||||
expect(vvtAnswers.dept_hr_categories).toEqual(['NAME', 'BANK_ACCOUNT'])
|
||||
expect(vvtAnswers.dept_finance_categories).toEqual(['INVOICE_DATA', 'TAX_ID'])
|
||||
expect(vvtAnswers.dept_marketing_categories).toEqual(['EMAIL', 'TRACKING_DATA'])
|
||||
})
|
||||
|
||||
it('ignores scope questions without mapsToVVTQuestion', () => {
|
||||
const scopeAnswers: ScopeProfilingAnswer[] = [
|
||||
ans('vvt_has_vvt', true), // No mapsToVVTQuestion property
|
||||
]
|
||||
const vvtAnswers = exportToVVTAnswers(scopeAnswers)
|
||||
expect(Object.keys(vvtAnswers)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles empty scope answers', () => {
|
||||
const vvtAnswers = exportToVVTAnswers([])
|
||||
expect(vvtAnswers).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 3. Scope → VVT Profiling Prefill
|
||||
// Note: prefillFromScopeAnswers() uses dynamic require('./compliance-scope-profiling')
|
||||
// which doesn't resolve in vitest. We test the same pipeline by calling
|
||||
// exportToVVTAnswers() directly (which is what prefillFromScopeAnswers wraps).
|
||||
// =============================================================================
|
||||
|
||||
describe('Scope → VVT Profiling Prefill (via exportToVVTAnswers)', () => {
|
||||
it('converts scope answers to VVT ProfilingAnswers format', () => {
|
||||
const scopeAnswers: ScopeProfilingAnswer[] = [
|
||||
ans('dk_dept_hr', ['NAME', 'HEALTH_DATA']),
|
||||
ans('dk_dept_finance', ['BANK_ACCOUNT']),
|
||||
]
|
||||
const exported = exportToVVTAnswers(scopeAnswers)
|
||||
// Same transformation as prefillFromScopeAnswers
|
||||
const profiling: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(exported)) {
|
||||
if (value !== undefined && value !== null) profiling[key] = value
|
||||
}
|
||||
expect(profiling.dept_hr_categories).toEqual(['NAME', 'HEALTH_DATA'])
|
||||
expect(profiling.dept_finance_categories).toEqual(['BANK_ACCOUNT'])
|
||||
})
|
||||
|
||||
it('filters out null/undefined values', () => {
|
||||
const scopeAnswers: ScopeProfilingAnswer[] = [ans('dk_dept_hr', null)]
|
||||
const exported = exportToVVTAnswers(scopeAnswers)
|
||||
const profiling: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(exported)) {
|
||||
if (value !== undefined && value !== null) profiling[key] = value
|
||||
}
|
||||
expect(profiling.dept_hr_categories).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 4. VVT Generator — generateActivities
|
||||
// =============================================================================
|
||||
|
||||
describe('generateActivities', () => {
|
||||
it('always generates 4 IT baseline activities', () => {
|
||||
const result = generateActivities({})
|
||||
const names = result.generatedActivities.map((a) => a.name)
|
||||
expect(result.generatedActivities.length).toBeGreaterThanOrEqual(4)
|
||||
// IT baselines are always added
|
||||
const itTemplates = result.generatedActivities.filter(
|
||||
(a) => a.businessFunction === 'it_operations'
|
||||
)
|
||||
expect(itTemplates.length).toBeGreaterThanOrEqual(4)
|
||||
})
|
||||
|
||||
it('triggers HR templates when dept_hr=true', () => {
|
||||
const result = generateActivities({ dept_hr: true })
|
||||
const hrActivities = result.generatedActivities.filter(
|
||||
(a) => a.businessFunction === 'hr'
|
||||
)
|
||||
expect(hrActivities.length).toBeGreaterThanOrEqual(3) // mitarbeiter, gehalt, zeiterfassung
|
||||
})
|
||||
|
||||
it('triggers finance templates when dept_finance=true', () => {
|
||||
const result = generateActivities({ dept_finance: true })
|
||||
const financeActivities = result.generatedActivities.filter(
|
||||
(a) => a.businessFunction === 'finance'
|
||||
)
|
||||
expect(financeActivities.length).toBeGreaterThanOrEqual(2) // buchhaltung, zahlungsverkehr
|
||||
})
|
||||
|
||||
it('enriches activities with US cloud third-country transfer', () => {
|
||||
const result = generateActivities({ dept_hr: true, transfer_cloud_us: true })
|
||||
// Every activity should have a US third-country transfer
|
||||
for (const activity of result.generatedActivities) {
|
||||
expect(activity.thirdCountryTransfers.some((t) => t.country === 'US')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('adds HEALTH_DATA to HR activities when data_health=true', () => {
|
||||
const result = generateActivities({ dept_hr: true, data_health: true })
|
||||
const hrActivities = result.generatedActivities.filter(
|
||||
(a) => a.businessFunction === 'hr'
|
||||
)
|
||||
expect(hrActivities.length).toBeGreaterThan(0)
|
||||
for (const hr of hrActivities) {
|
||||
expect(hr.personalDataCategories).toContain('HEALTH_DATA')
|
||||
}
|
||||
})
|
||||
|
||||
it('calculates Art. 30 Abs. 5 exemption correctly', () => {
|
||||
// < 250 employees, no special categories → exempt
|
||||
const result1 = generateActivities({ org_employees: 50 })
|
||||
expect(result1.art30Abs5Exempt).toBe(true)
|
||||
|
||||
// >= 250 employees → not exempt
|
||||
const result2 = generateActivities({ org_employees: 500 })
|
||||
expect(result2.art30Abs5Exempt).toBe(false)
|
||||
|
||||
// < 250 but with special categories → not exempt
|
||||
const result3 = generateActivities({ org_employees: 50, data_health: true })
|
||||
expect(result3.art30Abs5Exempt).toBe(false)
|
||||
})
|
||||
|
||||
it('generates unique VVT IDs for all activities', () => {
|
||||
const result = generateActivities({
|
||||
dept_hr: true,
|
||||
dept_finance: true,
|
||||
dept_sales: true,
|
||||
dept_marketing: true,
|
||||
})
|
||||
const ids = result.generatedActivities.map((a) => a.vvtId)
|
||||
const uniqueIds = new Set(ids)
|
||||
expect(uniqueIds.size).toBe(ids.length)
|
||||
})
|
||||
|
||||
it('calculates coverage score > 0 for template-generated activities', () => {
|
||||
const result = generateActivities({ dept_hr: true })
|
||||
expect(result.coverageScore).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 5. Full Pipeline: Company Profile → Scope → VVT
|
||||
// =============================================================================
|
||||
|
||||
describe('Full Pipeline: CompanyProfile → Scope → VVT Generation', () => {
|
||||
// Helper: replicate what prefillFromScopeAnswers does (avoiding dynamic require)
|
||||
function scopeToProfilingAnswers(
|
||||
scopeAnswers: ScopeProfilingAnswer[]
|
||||
): Record<string, string | string[] | number | boolean> {
|
||||
const exported = exportToVVTAnswers(scopeAnswers)
|
||||
const profiling: Record<string, string | string[] | number | boolean> = {}
|
||||
for (const [key, value] of Object.entries(exported)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
profiling[key] = value as string | string[] | number | boolean
|
||||
}
|
||||
}
|
||||
return profiling
|
||||
}
|
||||
|
||||
it('complete flow: profile with DSB → scope prefill → VVT generation', () => {
|
||||
// Step 1: Company Profile
|
||||
const profile = {
|
||||
dpoName: 'Dr. Datenschutz',
|
||||
employeeCount: '50-249',
|
||||
industry: ['IT & Software'],
|
||||
offerings: ['WebApp', 'SaaS'],
|
||||
} as any
|
||||
|
||||
// Step 2: Prefill scope from profile
|
||||
const profileAnswers = prefillFromCompanyProfile(profile)
|
||||
const scoringAnswers = getAutoFilledScoringAnswers(profile)
|
||||
|
||||
// Simulate user answering scope questions + auto-prefilled from profile
|
||||
const userAnswers: ScopeProfilingAnswer[] = [
|
||||
// Block 8: departments
|
||||
ans('vvt_departments', ['personal', 'finanzen', 'it']),
|
||||
// Block 9: data categories per department
|
||||
ans('dk_dept_hr', ['NAME', 'ADDRESS', 'SALARY_DATA', 'HEALTH_DATA']),
|
||||
ans('dk_dept_finance', ['NAME', 'BANK_ACCOUNT', 'INVOICE_DATA', 'TAX_ID']),
|
||||
ans('dk_dept_it', ['USER_ACCOUNTS', 'LOG_DATA', 'DEVICE_DATA']),
|
||||
// Block 2: data types
|
||||
ans('data_art9', true),
|
||||
ans('data_minors', false),
|
||||
]
|
||||
|
||||
const allScopeAnswers = [...profileAnswers, ...scoringAnswers, ...userAnswers]
|
||||
|
||||
// Step 3: Export to VVT format
|
||||
const vvtAnswers = exportToVVTAnswers(allScopeAnswers)
|
||||
expect(vvtAnswers.dept_hr_categories).toEqual([
|
||||
'NAME',
|
||||
'ADDRESS',
|
||||
'SALARY_DATA',
|
||||
'HEALTH_DATA',
|
||||
])
|
||||
expect(vvtAnswers.dept_finance_categories).toEqual([
|
||||
'NAME',
|
||||
'BANK_ACCOUNT',
|
||||
'INVOICE_DATA',
|
||||
'TAX_ID',
|
||||
])
|
||||
|
||||
// Step 4: Prefill VVT profiling from scope (via direct export)
|
||||
const profilingAnswers = scopeToProfilingAnswers(allScopeAnswers)
|
||||
|
||||
// Verify data survived the transformation
|
||||
expect(profilingAnswers.dept_hr_categories).toEqual([
|
||||
'NAME',
|
||||
'ADDRESS',
|
||||
'SALARY_DATA',
|
||||
'HEALTH_DATA',
|
||||
])
|
||||
|
||||
// Step 5: Generate VVT activities
|
||||
// Add department triggers that match Block 8 selections
|
||||
profilingAnswers.dept_hr = true
|
||||
profilingAnswers.dept_finance = true
|
||||
|
||||
const result = generateActivities(profilingAnswers)
|
||||
|
||||
// Verify activities were generated
|
||||
expect(result.generatedActivities.length).toBeGreaterThan(4) // 4 IT baseline + HR + Finance
|
||||
|
||||
// Verify HR activities exist
|
||||
const hrActivities = result.generatedActivities.filter(
|
||||
(a) => a.businessFunction === 'hr'
|
||||
)
|
||||
expect(hrActivities.length).toBeGreaterThanOrEqual(3)
|
||||
|
||||
// Verify finance activities exist
|
||||
const financeActivities = result.generatedActivities.filter(
|
||||
(a) => a.businessFunction === 'finance'
|
||||
)
|
||||
expect(financeActivities.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('end-to-end: departments selected in scope generate correct VVT activities', () => {
|
||||
// Simulate a complete scope session with department selections
|
||||
const scopeAnswers: ScopeProfilingAnswer[] = [
|
||||
// Block 2: data_art9 maps to data_health in VVT
|
||||
ans('data_art9', true),
|
||||
// Block 4: tech_third_country maps to transfer_cloud_us
|
||||
ans('tech_third_country', true),
|
||||
// Block 8: departments
|
||||
ans('vvt_departments', ['personal', 'marketing', 'kundenservice']),
|
||||
// Block 9: per-department data categories
|
||||
ans('dk_dept_hr', ['NAME', 'HEALTH_DATA', 'RELIGIOUS_BELIEFS']),
|
||||
ans('dk_dept_marketing', ['EMAIL', 'TRACKING_DATA', 'CONSENT_DATA']),
|
||||
ans('dk_dept_support', ['NAME', 'TICKET_DATA', 'COMMUNICATION_DATA']),
|
||||
]
|
||||
|
||||
// Transform to VVT answers
|
||||
const vvtAnswers = exportToVVTAnswers(scopeAnswers)
|
||||
|
||||
// Verify Block 9 data categories are mapped correctly
|
||||
expect(vvtAnswers.dept_hr_categories).toEqual(['NAME', 'HEALTH_DATA', 'RELIGIOUS_BELIEFS'])
|
||||
expect(vvtAnswers.dept_marketing_categories).toEqual([
|
||||
'EMAIL',
|
||||
'TRACKING_DATA',
|
||||
'CONSENT_DATA',
|
||||
])
|
||||
expect(vvtAnswers.dept_support_categories).toEqual([
|
||||
'NAME',
|
||||
'TICKET_DATA',
|
||||
'COMMUNICATION_DATA',
|
||||
])
|
||||
|
||||
// Verify the full pipeline using direct export
|
||||
const profilingAnswers = scopeToProfilingAnswers(scopeAnswers)
|
||||
expect(profilingAnswers.dept_hr_categories).toBeDefined()
|
||||
expect(profilingAnswers.dept_marketing_categories).toBeDefined()
|
||||
expect(profilingAnswers.dept_support_categories).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 6. DEPARTMENT_DATA_CATEGORIES Integrity
|
||||
// =============================================================================
|
||||
|
||||
describe('DEPARTMENT_DATA_CATEGORIES consistency', () => {
|
||||
it('all 12 departments are defined', () => {
|
||||
const expected = [
|
||||
'dept_hr',
|
||||
'dept_recruiting',
|
||||
'dept_finance',
|
||||
'dept_sales',
|
||||
'dept_marketing',
|
||||
'dept_support',
|
||||
'dept_it',
|
||||
'dept_recht',
|
||||
'dept_produktion',
|
||||
'dept_logistik',
|
||||
'dept_einkauf',
|
||||
'dept_facility',
|
||||
]
|
||||
for (const dept of expected) {
|
||||
expect(DEPARTMENT_DATA_CATEGORIES[dept]).toBeDefined()
|
||||
expect(DEPARTMENT_DATA_CATEGORIES[dept].categories.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('every department has a label and icon', () => {
|
||||
for (const [, dept] of Object.entries(DEPARTMENT_DATA_CATEGORIES)) {
|
||||
expect(dept.label).toBeTruthy()
|
||||
expect(dept.icon).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it('every category has id and label', () => {
|
||||
for (const [, dept] of Object.entries(DEPARTMENT_DATA_CATEGORIES)) {
|
||||
for (const cat of dept.categories) {
|
||||
expect(cat.id).toBeTruthy()
|
||||
expect(cat.label).toBeTruthy()
|
||||
expect(cat.info).toBeTruthy()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('Art. 9 categories are correctly flagged', () => {
|
||||
const art9Categories = [
|
||||
{ dept: 'dept_hr', id: 'HEALTH_DATA' },
|
||||
{ dept: 'dept_hr', id: 'RELIGIOUS_BELIEFS' },
|
||||
{ dept: 'dept_recruiting', id: 'HEALTH_DATA' },
|
||||
{ dept: 'dept_recht', id: 'CRIMINAL_DATA' },
|
||||
{ dept: 'dept_produktion', id: 'HEALTH_DATA' },
|
||||
{ dept: 'dept_facility', id: 'HEALTH_DATA' },
|
||||
]
|
||||
|
||||
for (const { dept, id } of art9Categories) {
|
||||
const cat = DEPARTMENT_DATA_CATEGORIES[dept].categories.find((c) => c.id === id)
|
||||
expect(cat?.isArt9).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 7. Block 9 ↔ VVT Mapping Integrity
|
||||
// =============================================================================
|
||||
|
||||
describe('Block 9 Scope ↔ VVT question mapping', () => {
|
||||
it('every Block 9 question has mapsToVVTQuestion', () => {
|
||||
const block9 = SCOPE_QUESTION_BLOCKS.find((b) => b.id === 'datenkategorien_detail')
|
||||
expect(block9).toBeDefined()
|
||||
|
||||
for (const q of block9!.questions) {
|
||||
expect(q.mapsToVVTQuestion).toBeTruthy()
|
||||
expect(q.mapsToVVTQuestion).toMatch(/^dept_\w+_categories$/)
|
||||
}
|
||||
})
|
||||
|
||||
it('Block 9 question options match DEPARTMENT_DATA_CATEGORIES', () => {
|
||||
const block9 = SCOPE_QUESTION_BLOCKS.find((b) => b.id === 'datenkategorien_detail')
|
||||
expect(block9).toBeDefined()
|
||||
|
||||
// dk_dept_hr should have same options as DEPARTMENT_DATA_CATEGORIES.dept_hr
|
||||
const hrQuestion = block9!.questions.find((q) => q.id === 'dk_dept_hr')
|
||||
expect(hrQuestion).toBeDefined()
|
||||
|
||||
const expectedIds = DEPARTMENT_DATA_CATEGORIES.dept_hr.categories.map((c) => c.id)
|
||||
const actualIds = hrQuestion!.options!.map((o) => o.value)
|
||||
expect(actualIds).toEqual(expectedIds)
|
||||
})
|
||||
|
||||
it('SCOPE_PREFILLED_VVT_QUESTIONS lists all cross-module questions', () => {
|
||||
expect(SCOPE_PREFILLED_VVT_QUESTIONS).toContain('org_industry')
|
||||
expect(SCOPE_PREFILLED_VVT_QUESTIONS).toContain('dept_hr')
|
||||
expect(SCOPE_PREFILLED_VVT_QUESTIONS).toContain('data_health')
|
||||
expect(SCOPE_PREFILLED_VVT_QUESTIONS).toContain('transfer_cloud_us')
|
||||
expect(SCOPE_PREFILLED_VVT_QUESTIONS.length).toBeGreaterThanOrEqual(15)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// 8. Edge Cases
|
||||
// =============================================================================
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('generateActivities with no answers still produces IT baselines', () => {
|
||||
const result = generateActivities({})
|
||||
expect(result.generatedActivities.length).toBe(4) // 4 IT baselines
|
||||
expect(result.art30Abs5Exempt).toBe(true) // 0 employees, no special categories
|
||||
})
|
||||
|
||||
it('same template triggered by multiple questions is only generated once', () => {
|
||||
const result = generateActivities({
|
||||
dept_sales: true, // triggers sales-kundenverwaltung
|
||||
sys_crm: true, // also triggers sales-kundenverwaltung
|
||||
})
|
||||
|
||||
const salesKunden = result.generatedActivities.filter((a) =>
|
||||
a.name.toLowerCase().includes('kundenverwaltung')
|
||||
)
|
||||
// Should be deduplicated (Set-based triggeredIds)
|
||||
expect(salesKunden.length).toBe(1)
|
||||
})
|
||||
|
||||
it('empty department category selections produce valid but empty mappings', () => {
|
||||
const scopeAnswers: ScopeProfilingAnswer[] = [ans('dk_dept_hr', [])]
|
||||
const vvtAnswers = exportToVVTAnswers(scopeAnswers)
|
||||
expect(vvtAnswers.dept_hr_categories).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -642,16 +642,31 @@ export const HARD_TRIGGER_RULES: HardTriggerRule[] = [
|
||||
|
||||
// ========== H: Produkt/Business (7 rules) ==========
|
||||
{
|
||||
id: 'HT-H01',
|
||||
id: 'HT-H01a',
|
||||
category: 'product',
|
||||
questionId: 'prod_webshop',
|
||||
condition: 'EQUALS',
|
||||
conditionValue: true,
|
||||
excludeWhen: { questionId: 'org_business_model', value: 'B2B' },
|
||||
minimumLevel: 'L2',
|
||||
requiresDSFA: false,
|
||||
mandatoryDocuments: ['DSE', 'AGB', 'COOKIE_BANNER', 'EINWILLIGUNGEN', 'VERBRAUCHERSCHUTZ'],
|
||||
mandatoryDocuments: ['DSE', 'AGB', 'COOKIE_BANNER', 'EINWILLIGUNGEN',
|
||||
'WIDERRUFSBELEHRUNG', 'PREISANGABEN', 'FERNABSATZ_INFO', 'STREITBEILEGUNG'],
|
||||
legalReference: 'Art. 6 DSGVO + Fernabsatzrecht + PAngV + VSBG',
|
||||
description: 'E-Commerce / Webshop (B2C) — Verbraucherschutzpflichten',
|
||||
},
|
||||
{
|
||||
id: 'HT-H01b',
|
||||
category: 'product',
|
||||
questionId: 'prod_webshop',
|
||||
condition: 'EQUALS',
|
||||
conditionValue: true,
|
||||
requireWhen: { questionId: 'org_business_model', value: 'B2B' },
|
||||
minimumLevel: 'L2',
|
||||
requiresDSFA: false,
|
||||
mandatoryDocuments: ['DSE', 'AGB', 'COOKIE_BANNER'],
|
||||
legalReference: 'Art. 6 DSGVO + eCommerce',
|
||||
description: 'E-Commerce / Webshop-Betrieb',
|
||||
description: 'E-Commerce / Webshop (B2B) — Basis-Pflichten',
|
||||
},
|
||||
{
|
||||
id: 'HT-H02',
|
||||
@@ -1051,10 +1066,10 @@ export class ComplianceScopeEngine {
|
||||
const composite = riskScore * 0.4 + complexityScore * 0.3 + assuranceScore * 0.3
|
||||
|
||||
return {
|
||||
risk: Math.round(riskScore * 10) / 10,
|
||||
complexity: Math.round(complexityScore * 10) / 10,
|
||||
assurance: Math.round(assuranceScore * 10) / 10,
|
||||
composite: Math.round(composite * 10) / 10,
|
||||
risk_score: Math.round(riskScore * 10) / 10,
|
||||
complexity_score: Math.round(complexityScore * 10) / 10,
|
||||
assurance_need: Math.round(assuranceScore * 10) / 10,
|
||||
composite_score: Math.round(composite * 10) / 10,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1062,32 +1077,32 @@ export class ComplianceScopeEngine {
|
||||
* Bestimmt den Multiplikator für eine Antwort (0.0 - 1.0)
|
||||
*/
|
||||
private getAnswerMultiplier(answer: ScopeProfilingAnswer): number {
|
||||
const { questionId, answerValue } = answer
|
||||
const { questionId, value } = answer
|
||||
|
||||
// Boolean
|
||||
if (typeof answerValue === 'boolean') {
|
||||
return answerValue ? 1.0 : 0.0
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 1.0 : 0.0
|
||||
}
|
||||
|
||||
// Number
|
||||
if (typeof answerValue === 'number') {
|
||||
return this.normalizeNumericAnswer(questionId, answerValue)
|
||||
if (typeof value === 'number') {
|
||||
return this.normalizeNumericAnswer(questionId, value)
|
||||
}
|
||||
|
||||
// Single choice
|
||||
if (typeof answerValue === 'string') {
|
||||
if (typeof value === 'string') {
|
||||
const multipliers = ANSWER_MULTIPLIERS[questionId]
|
||||
if (multipliers && multipliers[answerValue] !== undefined) {
|
||||
return multipliers[answerValue]
|
||||
if (multipliers && multipliers[value] !== undefined) {
|
||||
return multipliers[value]
|
||||
}
|
||||
return 0.5 // Fallback
|
||||
}
|
||||
|
||||
// Multi choice
|
||||
if (Array.isArray(answerValue)) {
|
||||
if (answerValue.length === 0) return 0.0
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return 0.0
|
||||
// Simplified: count selected items
|
||||
return Math.min(answerValue.length / 5, 1.0)
|
||||
return Math.min(value.length / 5, 1.0)
|
||||
}
|
||||
|
||||
return 0.0
|
||||
@@ -1110,7 +1125,7 @@ export class ComplianceScopeEngine {
|
||||
*/
|
||||
evaluateHardTriggers(answers: ScopeProfilingAnswer[], companyProfile?: CompanyProfile | null): TriggeredHardTrigger[] {
|
||||
const triggered: TriggeredHardTrigger[] = []
|
||||
const answerMap = new Map(answers.map((a) => [a.questionId, a.answerValue]))
|
||||
const answerMap = new Map(answers.map((a) => [a.questionId, a.value]))
|
||||
|
||||
for (const rule of HARD_TRIGGER_RULES) {
|
||||
const isTriggered = this.checkTriggerCondition(rule, answerMap, answers, companyProfile)
|
||||
@@ -1186,44 +1201,64 @@ export class ComplianceScopeEngine {
|
||||
}
|
||||
|
||||
// Standard answer-based triggers
|
||||
const answerValue = answerMap.get(rule.questionId)
|
||||
if (answerValue === undefined) return false
|
||||
const value = answerMap.get(rule.questionId)
|
||||
if (value === undefined) return false
|
||||
|
||||
// Basis-Check
|
||||
let baseCondition = false
|
||||
|
||||
switch (rule.condition) {
|
||||
case 'EQUALS':
|
||||
baseCondition = answerValue === rule.conditionValue
|
||||
baseCondition = value === rule.conditionValue
|
||||
break
|
||||
case 'CONTAINS':
|
||||
if (Array.isArray(answerValue)) {
|
||||
baseCondition = answerValue.includes(rule.conditionValue)
|
||||
} else if (typeof answerValue === 'string') {
|
||||
baseCondition = answerValue.includes(rule.conditionValue)
|
||||
if (Array.isArray(value)) {
|
||||
baseCondition = value.includes(rule.conditionValue)
|
||||
} else if (typeof value === 'string') {
|
||||
baseCondition = value.includes(rule.conditionValue)
|
||||
}
|
||||
break
|
||||
case 'IN':
|
||||
if (Array.isArray(rule.conditionValue)) {
|
||||
baseCondition = rule.conditionValue.includes(answerValue)
|
||||
baseCondition = rule.conditionValue.includes(value)
|
||||
}
|
||||
break
|
||||
case 'GREATER_THAN':
|
||||
if (typeof answerValue === 'number' && typeof rule.conditionValue === 'number') {
|
||||
baseCondition = answerValue > rule.conditionValue
|
||||
} else if (typeof answerValue === 'string') {
|
||||
if (typeof value === 'number' && typeof rule.conditionValue === 'number') {
|
||||
baseCondition = value > rule.conditionValue
|
||||
} else if (typeof value === 'string') {
|
||||
// Parse employee count from string like "1000+"
|
||||
const parsed = this.parseEmployeeCount(answerValue)
|
||||
const parsed = this.parseEmployeeCount(value)
|
||||
baseCondition = parsed > (rule.conditionValue as number)
|
||||
}
|
||||
break
|
||||
case 'NOT_EQUALS':
|
||||
baseCondition = answerValue !== rule.conditionValue
|
||||
baseCondition = value !== rule.conditionValue
|
||||
break
|
||||
}
|
||||
|
||||
if (!baseCondition) return false
|
||||
|
||||
// Exclude-Bedingung: Regel feuert NICHT wenn excludeWhen zutrifft
|
||||
if (rule.excludeWhen) {
|
||||
const exVal = answerMap.get(rule.excludeWhen.questionId)
|
||||
if (Array.isArray(rule.excludeWhen.value)
|
||||
? rule.excludeWhen.value.includes(exVal)
|
||||
: exVal === rule.excludeWhen.value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Require-Bedingung: Regel feuert NUR wenn requireWhen zutrifft
|
||||
if (rule.requireWhen) {
|
||||
const reqVal = answerMap.get(rule.requireWhen.questionId)
|
||||
if (Array.isArray(rule.requireWhen.value)
|
||||
? !rule.requireWhen.value.includes(reqVal)
|
||||
: reqVal !== rule.requireWhen.value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Combined checks
|
||||
if (rule.combineWithArt9) {
|
||||
const art9 = answerMap.get('data_art9')
|
||||
@@ -1276,9 +1311,9 @@ export class ComplianceScopeEngine {
|
||||
): ComplianceDepthLevel {
|
||||
// Score-basiertes Level
|
||||
let levelFromScore: ComplianceDepthLevel
|
||||
if (scores.composite <= 25) levelFromScore = 'L1'
|
||||
else if (scores.composite <= 50) levelFromScore = 'L2'
|
||||
else if (scores.composite <= 75) levelFromScore = 'L3'
|
||||
if (scores.composite_score <= 25) levelFromScore = 'L1'
|
||||
else if (scores.composite_score <= 50) levelFromScore = 'L2'
|
||||
else if (scores.composite_score <= 75) levelFromScore = 'L3'
|
||||
else levelFromScore = 'L4'
|
||||
|
||||
// Höchstes Level aus Triggers
|
||||
@@ -1293,6 +1328,38 @@ export class ComplianceScopeEngine {
|
||||
return maxDepthLevel(levelFromScore, maxTriggerLevel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalisiert UPPERCASE Dokumenttyp-Bezeichner aus den Hard-Trigger-Rules
|
||||
* auf die lowercase ScopeDocumentType-Schlüssel.
|
||||
*/
|
||||
private normalizeDocType(raw: string): ScopeDocumentType | null {
|
||||
const mapping: Record<string, ScopeDocumentType> = {
|
||||
VVT: 'vvt',
|
||||
TOM: 'tom',
|
||||
DSFA: 'dsfa',
|
||||
DSE: 'dsi',
|
||||
AGB: 'vertragsmanagement',
|
||||
AVV: 'av_vertrag',
|
||||
COOKIE_BANNER: 'einwilligung',
|
||||
EINWILLIGUNGEN: 'einwilligung',
|
||||
TRANSFER_DOKU: 'daten_transfer',
|
||||
AUDIT_CHECKLIST: 'audit_log',
|
||||
VENDOR_MANAGEMENT: 'vertragsmanagement',
|
||||
LOESCHKONZEPT: 'lf',
|
||||
DSR_PROZESS: 'betroffenenrechte',
|
||||
NOTFALLPLAN: 'notfallplan',
|
||||
AI_ACT_DOKU: 'ai_act_doku',
|
||||
WIDERRUFSBELEHRUNG: 'widerrufsbelehrung',
|
||||
PREISANGABEN: 'preisangaben',
|
||||
FERNABSATZ_INFO: 'fernabsatz_info',
|
||||
STREITBEILEGUNG: 'streitbeilegung',
|
||||
PRODUKTSICHERHEIT: 'produktsicherheit',
|
||||
}
|
||||
// Falls raw bereits ein gueltiger ScopeDocumentType ist
|
||||
if (raw in DOCUMENT_SCOPE_MATRIX) return raw as ScopeDocumentType
|
||||
return mapping[raw] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut den Dokumenten-Scope basierend auf Level und Triggers
|
||||
*/
|
||||
@@ -1303,11 +1370,18 @@ export class ComplianceScopeEngine {
|
||||
): RequiredDocument[] {
|
||||
const requiredDocs: RequiredDocument[] = []
|
||||
const mandatoryFromTriggers = new Set<ScopeDocumentType>()
|
||||
// Mapping: normalisierter DocType → original Rule-Strings (fuer triggeredBy Lookup)
|
||||
const triggerDocOrigins = new Map<ScopeDocumentType, string[]>()
|
||||
|
||||
// Sammle mandatory docs aus Triggern
|
||||
// Sammle mandatory docs aus Triggern (normalisiert)
|
||||
for (const trigger of triggers) {
|
||||
for (const doc of trigger.mandatoryDocuments) {
|
||||
mandatoryFromTriggers.add(doc as ScopeDocumentType)
|
||||
const normalized = this.normalizeDocType(doc)
|
||||
if (normalized) {
|
||||
mandatoryFromTriggers.add(normalized)
|
||||
if (!triggerDocOrigins.has(normalized)) triggerDocOrigins.set(normalized, [])
|
||||
triggerDocOrigins.get(normalized)!.push(doc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1317,6 +1391,7 @@ export class ComplianceScopeEngine {
|
||||
const isMandatoryFromTrigger = mandatoryFromTriggers.has(docType)
|
||||
|
||||
if (requirement === 'mandatory' || isMandatoryFromTrigger) {
|
||||
const originDocs = triggerDocOrigins.get(docType) ?? []
|
||||
requiredDocs.push({
|
||||
documentType: docType,
|
||||
label: DOCUMENT_TYPE_LABELS[docType],
|
||||
@@ -1326,7 +1401,7 @@ export class ComplianceScopeEngine {
|
||||
sdkStepUrl: DOCUMENT_SDK_STEP_MAP[docType],
|
||||
triggeredBy: isMandatoryFromTrigger
|
||||
? triggers
|
||||
.filter((t) => t.mandatoryDocuments.includes(docType as any))
|
||||
.filter((t) => t.mandatoryDocuments.some((d) => originDocs.includes(d)))
|
||||
.map((t) => t.ruleId)
|
||||
: [],
|
||||
})
|
||||
@@ -1375,25 +1450,33 @@ export class ComplianceScopeEngine {
|
||||
* Schätzt den Aufwand für ein Dokument (in Stunden)
|
||||
*/
|
||||
private estimateEffort(docType: ScopeDocumentType): number {
|
||||
const effortMap: Record<ScopeDocumentType, number> = {
|
||||
VVT: 8,
|
||||
TOM: 12,
|
||||
DSFA: 16,
|
||||
AVV: 4,
|
||||
DSE: 6,
|
||||
EINWILLIGUNGEN: 6,
|
||||
LOESCHKONZEPT: 10,
|
||||
TRANSFER_DOKU: 8,
|
||||
DSR_PROZESS: 8,
|
||||
NOTFALLPLAN: 12,
|
||||
COOKIE_BANNER: 4,
|
||||
AGB: 6,
|
||||
VERBRAUCHERSCHUTZ: 4,
|
||||
AUDIT_CHECKLIST: 8,
|
||||
VENDOR_MANAGEMENT: 10,
|
||||
AI_ACT_DOKU: 12,
|
||||
const effortMap: Partial<Record<ScopeDocumentType, number>> = {
|
||||
vvt: 8,
|
||||
tom: 12,
|
||||
dsfa: 16,
|
||||
av_vertrag: 4,
|
||||
dsi: 6,
|
||||
einwilligung: 6,
|
||||
lf: 10,
|
||||
daten_transfer: 8,
|
||||
betroffenenrechte: 8,
|
||||
notfallplan: 12,
|
||||
vertragsmanagement: 10,
|
||||
audit_log: 8,
|
||||
risikoanalyse: 6,
|
||||
schulung: 4,
|
||||
datenpannen: 6,
|
||||
zertifizierung: 8,
|
||||
datenschutzmanagement: 12,
|
||||
iace_ce_assessment: 8,
|
||||
widerrufsbelehrung: 3,
|
||||
preisangaben: 2,
|
||||
fernabsatz_info: 4,
|
||||
streitbeilegung: 1,
|
||||
produktsicherheit: 8,
|
||||
ai_act_doku: 12,
|
||||
}
|
||||
return effortMap[docType] || 6
|
||||
return effortMap[docType] ?? 6
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1404,7 +1487,7 @@ export class ComplianceScopeEngine {
|
||||
level: ComplianceDepthLevel
|
||||
): RiskFlag[] {
|
||||
const flags: RiskFlag[] = []
|
||||
const answerMap = new Map(answers.map((a) => [a.questionId, a.answerValue]))
|
||||
const answerMap = new Map(answers.map((a) => [a.questionId, a.value]))
|
||||
|
||||
// Process Maturity Gaps (Kategorie I Trigger)
|
||||
const maturityRules = HARD_TRIGGER_RULES.filter((r) => r.category === 'process_maturity')
|
||||
@@ -1503,7 +1586,7 @@ export class ComplianceScopeEngine {
|
||||
level: ComplianceDepthLevel
|
||||
): ScopeGap[] {
|
||||
const gaps: ScopeGap[] = []
|
||||
const answerMap = new Map(answers.map((a) => [a.questionId, a.answerValue]))
|
||||
const answerMap = new Map(answers.map((a) => [a.questionId, a.value]))
|
||||
|
||||
// DSFA Gap (bei L3+)
|
||||
if (getDepthLevelNumeric(level) >= 3) {
|
||||
@@ -1650,12 +1733,12 @@ export class ComplianceScopeEngine {
|
||||
step: 'score_calculation',
|
||||
description: 'Risikobasierte Score-Berechnung aus Profiling-Antworten',
|
||||
factors: [
|
||||
`Risiko-Score: ${scores.risk}/10`,
|
||||
`Komplexitäts-Score: ${scores.complexity}/10`,
|
||||
`Assurance-Score: ${scores.assurance}/10`,
|
||||
`Composite Score: ${scores.composite}/10`,
|
||||
`Risiko-Score: ${scores.risk_score}/10`,
|
||||
`Komplexitäts-Score: ${scores.complexity_score}/10`,
|
||||
`Assurance-Score: ${scores.assurance_need}/10`,
|
||||
`Composite Score: ${scores.composite_score}/10`,
|
||||
],
|
||||
impact: `Score-basiertes Level: ${this.getLevelFromScore(scores.composite)}`,
|
||||
impact: `Score-basiertes Level: ${this.getLevelFromScore(scores.composite_score)}`,
|
||||
})
|
||||
|
||||
// 2. Hard Trigger Evaluation
|
||||
@@ -1664,7 +1747,7 @@ export class ComplianceScopeEngine {
|
||||
step: 'hard_trigger_evaluation',
|
||||
description: `${triggers.length} Hard Trigger Rule(s) aktiviert`,
|
||||
factors: triggers.map(
|
||||
(t) => `${t.ruleId}: ${t.description} (${t.legalReference})`
|
||||
(t) => `${t.ruleId}: ${t.description}${t.legalReference ? ` (${t.legalReference})` : ''}`
|
||||
),
|
||||
impact: `Höchstes Trigger-Level: ${this.getMaxTriggerLevel(triggers)}`,
|
||||
})
|
||||
@@ -1675,7 +1758,7 @@ export class ComplianceScopeEngine {
|
||||
step: 'level_determination',
|
||||
description: 'Finales Compliance-Level durch Maximum aus Score und Triggers',
|
||||
factors: [
|
||||
`Score-Level: ${this.getLevelFromScore(scores.composite)}`,
|
||||
`Score-Level: ${this.getLevelFromScore(scores.composite_score)}`,
|
||||
`Trigger-Level: ${this.getMaxTriggerLevel(triggers)}`,
|
||||
],
|
||||
impact: `Finales Level: ${level}`,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ComplianceScopeState,
|
||||
} from './compliance-scope-types'
|
||||
import type { CompanyProfile } from './types'
|
||||
import { DEPARTMENT_DATA_CATEGORIES } from './vvt-profiling'
|
||||
|
||||
/**
|
||||
* Block 1: Organisation & Reife
|
||||
@@ -20,14 +21,16 @@ export const PROFILE_AUTOFILL_QUESTION_IDS = [
|
||||
'org_industry',
|
||||
'org_business_model',
|
||||
'org_has_dsb',
|
||||
'org_cert_target',
|
||||
'data_volume',
|
||||
'prod_type',
|
||||
'prod_webshop',
|
||||
] as const
|
||||
|
||||
const BLOCK_1_ORGANISATION: ScopeQuestionBlock = {
|
||||
id: 'organisation',
|
||||
title: 'Organisation & Reife',
|
||||
description: 'Grundlegende Informationen zu Ihrer Organisation und Compliance-Zielen',
|
||||
title: 'Kunden & Nutzer',
|
||||
description: 'Informationen zu Ihren Kunden und Nutzern',
|
||||
order: 1,
|
||||
questions: [
|
||||
{
|
||||
@@ -45,22 +48,6 @@ const BLOCK_1_ORGANISATION: ScopeQuestionBlock = {
|
||||
],
|
||||
scoreWeights: { risk: 6, complexity: 7, assurance: 6 },
|
||||
},
|
||||
{
|
||||
id: 'org_cert_target',
|
||||
type: 'multi',
|
||||
question: 'Welche Zertifizierungen streben Sie an oder besitzen Sie bereits?',
|
||||
helpText: 'Mehrfachauswahl möglich. Zertifizierungen erhöhen den Assurance-Bedarf',
|
||||
required: false,
|
||||
options: [
|
||||
{ value: 'ISO27001', label: 'ISO 27001 (Informationssicherheit)' },
|
||||
{ value: 'ISO27701', label: 'ISO 27701 (Datenschutz-Erweiterung)' },
|
||||
{ value: 'TISAX', label: 'TISAX (Automotive)' },
|
||||
{ value: 'SOC2', label: 'SOC 2 (US-Standard)' },
|
||||
{ value: 'BSI-Grundschutz', label: 'BSI IT-Grundschutz' },
|
||||
{ value: 'Keine', label: 'Keine Zertifizierung geplant' },
|
||||
],
|
||||
scoreWeights: { risk: 3, complexity: 5, assurance: 10 },
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -69,7 +56,7 @@ const BLOCK_1_ORGANISATION: ScopeQuestionBlock = {
|
||||
*/
|
||||
const BLOCK_2_DATA: ScopeQuestionBlock = {
|
||||
id: 'data',
|
||||
title: 'Daten & Betroffene',
|
||||
title: 'Datenverarbeitung',
|
||||
description: 'Art und Umfang der verarbeiteten personenbezogenen Daten',
|
||||
order: 2,
|
||||
questions: [
|
||||
@@ -130,21 +117,6 @@ const BLOCK_2_DATA: ScopeQuestionBlock = {
|
||||
mapsToVVTQuestion: 'dept_finance',
|
||||
mapsToLFQuestion: 'data-buchhaltung',
|
||||
},
|
||||
{
|
||||
id: 'data_volume',
|
||||
type: 'single',
|
||||
question: 'Wie viele Personendatensätze verarbeiten Sie insgesamt?',
|
||||
helpText: 'Schätzen Sie die Gesamtzahl betroffener Personen',
|
||||
required: true,
|
||||
options: [
|
||||
{ value: '<1000', label: 'Unter 1.000' },
|
||||
{ value: '1000-10000', label: '1.000 bis 10.000' },
|
||||
{ value: '10000-100000', label: '10.000 bis 100.000' },
|
||||
{ value: '100000-1000000', label: '100.000 bis 1 Mio.' },
|
||||
{ value: '>1000000', label: 'Über 1 Mio.' },
|
||||
],
|
||||
scoreWeights: { risk: 7, complexity: 6, assurance: 6 },
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -223,7 +195,7 @@ const BLOCK_3_PROCESSING: ScopeQuestionBlock = {
|
||||
*/
|
||||
const BLOCK_4_TECH: ScopeQuestionBlock = {
|
||||
id: 'tech',
|
||||
title: 'Technik, Hosting & Transfers',
|
||||
title: 'Hosting & Verarbeitung',
|
||||
description: 'Technische Infrastruktur und Datenübermittlung',
|
||||
order: 4,
|
||||
questions: [
|
||||
@@ -353,7 +325,7 @@ const BLOCK_5_PROCESSES: ScopeQuestionBlock = {
|
||||
*/
|
||||
const BLOCK_6_PRODUCT: ScopeQuestionBlock = {
|
||||
id: 'product',
|
||||
title: 'Produktkontext',
|
||||
title: 'Website und Services',
|
||||
description: 'Spezifische Merkmale Ihrer Produkte und Services',
|
||||
order: 6,
|
||||
questions: [
|
||||
@@ -432,6 +404,20 @@ export const HIDDEN_SCORING_QUESTIONS: ScopeProfilingQuestion[] = [
|
||||
required: false,
|
||||
scoreWeights: { risk: 5, complexity: 3, assurance: 6 },
|
||||
},
|
||||
{
|
||||
id: 'org_cert_target',
|
||||
type: 'multi',
|
||||
question: 'Zertifizierungen (aus Profil)',
|
||||
required: false,
|
||||
scoreWeights: { risk: 3, complexity: 5, assurance: 10 },
|
||||
},
|
||||
{
|
||||
id: 'data_volume',
|
||||
type: 'single',
|
||||
question: 'Personendatensaetze (aus Profil)',
|
||||
required: false,
|
||||
scoreWeights: { risk: 7, complexity: 6, assurance: 6 },
|
||||
},
|
||||
{
|
||||
id: 'prod_type',
|
||||
type: 'multi',
|
||||
@@ -493,10 +479,15 @@ const BLOCK_7_AI_SYSTEMS: ScopeQuestionBlock = {
|
||||
},
|
||||
{
|
||||
id: 'ai_risk_assessment',
|
||||
type: 'boolean',
|
||||
type: 'single',
|
||||
question: 'Haben Sie eine KI-Risikobewertung nach EU AI Act durchgeführt?',
|
||||
helpText: 'Risikoeinstufung der KI-Systeme (verboten / hochriskant / begrenzt / minimal)',
|
||||
required: false,
|
||||
options: [
|
||||
{ value: 'yes', label: 'Ja' },
|
||||
{ value: 'no', label: 'Nein' },
|
||||
{ value: 'not_yet', label: 'Noch nicht' },
|
||||
],
|
||||
scoreWeights: { risk: -5, complexity: 3, assurance: 8 },
|
||||
},
|
||||
],
|
||||
@@ -579,6 +570,175 @@ const BLOCK_8_VVT: ScopeQuestionBlock = {
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Block 9: Datenkategorien pro Abteilung
|
||||
* Generiert Fragen dynamisch aus DEPARTMENT_DATA_CATEGORIES
|
||||
*/
|
||||
const BLOCK_9_DATENKATEGORIEN: ScopeQuestionBlock = {
|
||||
id: 'datenkategorien_detail',
|
||||
title: 'Datenkategorien pro Abteilung',
|
||||
description: 'Detaillierte Erfassung der Datenkategorien je Abteilung — basierend auf Ihrer Abteilungswahl in Block 8',
|
||||
order: 9,
|
||||
questions: [
|
||||
{
|
||||
id: 'dk_dept_hr',
|
||||
type: 'multi',
|
||||
question: 'Welche Datenkategorien verarbeitet Ihre Personalabteilung?',
|
||||
helpText: 'Waehlen Sie alle zutreffenden Datenkategorien fuer den HR-Bereich',
|
||||
required: false,
|
||||
options: DEPARTMENT_DATA_CATEGORIES.dept_hr.categories.map(c => ({
|
||||
value: c.id,
|
||||
label: `${c.label}${c.isArt9 ? ' (Art. 9)' : ''}`,
|
||||
})),
|
||||
scoreWeights: { risk: 6, complexity: 4, assurance: 5 },
|
||||
mapsToVVTQuestion: 'dept_hr_categories',
|
||||
},
|
||||
{
|
||||
id: 'dk_dept_recruiting',
|
||||
type: 'multi',
|
||||
question: 'Welche Datenkategorien verarbeitet Ihr Recruiting?',
|
||||
helpText: 'Waehlen Sie alle zutreffenden Datenkategorien fuer das Bewerbermanagement',
|
||||
required: false,
|
||||
options: DEPARTMENT_DATA_CATEGORIES.dept_recruiting.categories.map(c => ({
|
||||
value: c.id,
|
||||
label: `${c.label}${c.isArt9 ? ' (Art. 9)' : ''}`,
|
||||
})),
|
||||
scoreWeights: { risk: 5, complexity: 3, assurance: 4 },
|
||||
mapsToVVTQuestion: 'dept_recruiting_categories',
|
||||
},
|
||||
{
|
||||
id: 'dk_dept_finance',
|
||||
type: 'multi',
|
||||
question: 'Welche Datenkategorien verarbeitet Ihre Finanzabteilung?',
|
||||
helpText: 'Waehlen Sie alle zutreffenden Datenkategorien fuer Finanzen & Buchhaltung',
|
||||
required: false,
|
||||
options: DEPARTMENT_DATA_CATEGORIES.dept_finance.categories.map(c => ({
|
||||
value: c.id,
|
||||
label: `${c.label}${c.isArt9 ? ' (Art. 9)' : ''}`,
|
||||
})),
|
||||
scoreWeights: { risk: 6, complexity: 4, assurance: 5 },
|
||||
mapsToVVTQuestion: 'dept_finance_categories',
|
||||
},
|
||||
{
|
||||
id: 'dk_dept_sales',
|
||||
type: 'multi',
|
||||
question: 'Welche Datenkategorien verarbeitet Ihr Vertrieb?',
|
||||
helpText: 'Waehlen Sie alle zutreffenden Datenkategorien fuer Vertrieb & CRM',
|
||||
required: false,
|
||||
options: DEPARTMENT_DATA_CATEGORIES.dept_sales.categories.map(c => ({
|
||||
value: c.id,
|
||||
label: `${c.label}${c.isArt9 ? ' (Art. 9)' : ''}`,
|
||||
})),
|
||||
scoreWeights: { risk: 5, complexity: 4, assurance: 4 },
|
||||
mapsToVVTQuestion: 'dept_sales_categories',
|
||||
},
|
||||
{
|
||||
id: 'dk_dept_marketing',
|
||||
type: 'multi',
|
||||
question: 'Welche Datenkategorien verarbeitet Ihr Marketing?',
|
||||
helpText: 'Waehlen Sie alle zutreffenden Datenkategorien fuer Marketing',
|
||||
required: false,
|
||||
options: DEPARTMENT_DATA_CATEGORIES.dept_marketing.categories.map(c => ({
|
||||
value: c.id,
|
||||
label: `${c.label}${c.isArt9 ? ' (Art. 9)' : ''}`,
|
||||
})),
|
||||
scoreWeights: { risk: 6, complexity: 5, assurance: 5 },
|
||||
mapsToVVTQuestion: 'dept_marketing_categories',
|
||||
},
|
||||
{
|
||||
id: 'dk_dept_support',
|
||||
type: 'multi',
|
||||
question: 'Welche Datenkategorien verarbeitet Ihr Kundenservice?',
|
||||
helpText: 'Waehlen Sie alle zutreffenden Datenkategorien fuer Support',
|
||||
required: false,
|
||||
options: DEPARTMENT_DATA_CATEGORIES.dept_support.categories.map(c => ({
|
||||
value: c.id,
|
||||
label: `${c.label}${c.isArt9 ? ' (Art. 9)' : ''}`,
|
||||
})),
|
||||
scoreWeights: { risk: 5, complexity: 3, assurance: 4 },
|
||||
mapsToVVTQuestion: 'dept_support_categories',
|
||||
},
|
||||
{
|
||||
id: 'dk_dept_it',
|
||||
type: 'multi',
|
||||
question: 'Welche Datenkategorien verarbeitet Ihre IT-Abteilung?',
|
||||
helpText: 'Waehlen Sie alle zutreffenden Datenkategorien fuer IT / Administration',
|
||||
required: false,
|
||||
options: DEPARTMENT_DATA_CATEGORIES.dept_it.categories.map(c => ({
|
||||
value: c.id,
|
||||
label: `${c.label}${c.isArt9 ? ' (Art. 9)' : ''}`,
|
||||
})),
|
||||
scoreWeights: { risk: 7, complexity: 5, assurance: 6 },
|
||||
mapsToVVTQuestion: 'dept_it_categories',
|
||||
},
|
||||
{
|
||||
id: 'dk_dept_recht',
|
||||
type: 'multi',
|
||||
question: 'Welche Datenkategorien verarbeitet Ihre Rechtsabteilung?',
|
||||
helpText: 'Waehlen Sie alle zutreffenden Datenkategorien fuer Recht / Compliance',
|
||||
required: false,
|
||||
options: DEPARTMENT_DATA_CATEGORIES.dept_recht.categories.map(c => ({
|
||||
value: c.id,
|
||||
label: `${c.label}${c.isArt9 ? ' (Art. 9)' : ''}`,
|
||||
})),
|
||||
scoreWeights: { risk: 6, complexity: 4, assurance: 6 },
|
||||
mapsToVVTQuestion: 'dept_recht_categories',
|
||||
},
|
||||
{
|
||||
id: 'dk_dept_produktion',
|
||||
type: 'multi',
|
||||
question: 'Welche Datenkategorien verarbeitet Ihre Produktion?',
|
||||
helpText: 'Waehlen Sie alle zutreffenden Datenkategorien fuer Produktion / Fertigung',
|
||||
required: false,
|
||||
options: DEPARTMENT_DATA_CATEGORIES.dept_produktion.categories.map(c => ({
|
||||
value: c.id,
|
||||
label: `${c.label}${c.isArt9 ? ' (Art. 9)' : ''}`,
|
||||
})),
|
||||
scoreWeights: { risk: 6, complexity: 4, assurance: 5 },
|
||||
mapsToVVTQuestion: 'dept_produktion_categories',
|
||||
},
|
||||
{
|
||||
id: 'dk_dept_logistik',
|
||||
type: 'multi',
|
||||
question: 'Welche Datenkategorien verarbeitet Ihre Logistik?',
|
||||
helpText: 'Waehlen Sie alle zutreffenden Datenkategorien fuer Logistik / Versand',
|
||||
required: false,
|
||||
options: DEPARTMENT_DATA_CATEGORIES.dept_logistik.categories.map(c => ({
|
||||
value: c.id,
|
||||
label: `${c.label}${c.isArt9 ? ' (Art. 9)' : ''}`,
|
||||
})),
|
||||
scoreWeights: { risk: 5, complexity: 3, assurance: 4 },
|
||||
mapsToVVTQuestion: 'dept_logistik_categories',
|
||||
},
|
||||
{
|
||||
id: 'dk_dept_einkauf',
|
||||
type: 'multi',
|
||||
question: 'Welche Datenkategorien verarbeitet Ihr Einkauf?',
|
||||
helpText: 'Waehlen Sie alle zutreffenden Datenkategorien fuer Einkauf / Beschaffung',
|
||||
required: false,
|
||||
options: DEPARTMENT_DATA_CATEGORIES.dept_einkauf.categories.map(c => ({
|
||||
value: c.id,
|
||||
label: `${c.label}${c.isArt9 ? ' (Art. 9)' : ''}`,
|
||||
})),
|
||||
scoreWeights: { risk: 4, complexity: 3, assurance: 4 },
|
||||
mapsToVVTQuestion: 'dept_einkauf_categories',
|
||||
},
|
||||
{
|
||||
id: 'dk_dept_facility',
|
||||
type: 'multi',
|
||||
question: 'Welche Datenkategorien verarbeitet Ihr Facility Management?',
|
||||
helpText: 'Waehlen Sie alle zutreffenden Datenkategorien fuer Facility Management',
|
||||
required: false,
|
||||
options: DEPARTMENT_DATA_CATEGORIES.dept_facility.categories.map(c => ({
|
||||
value: c.id,
|
||||
label: `${c.label}${c.isArt9 ? ' (Art. 9)' : ''}`,
|
||||
})),
|
||||
scoreWeights: { risk: 5, complexity: 3, assurance: 4 },
|
||||
mapsToVVTQuestion: 'dept_facility_categories',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* All question blocks in order
|
||||
*/
|
||||
@@ -591,6 +751,7 @@ export const SCOPE_QUESTION_BLOCKS: ScopeQuestionBlock[] = [
|
||||
BLOCK_6_PRODUCT,
|
||||
BLOCK_7_AI_SYSTEMS,
|
||||
BLOCK_8_VVT,
|
||||
BLOCK_9_DATENKATEGORIEN,
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -702,10 +863,10 @@ export function getAutoFilledScoringAnswers(
|
||||
}
|
||||
|
||||
// industry -> org_industry
|
||||
if (profile.industry) {
|
||||
if (profile.industry && profile.industry.length > 0) {
|
||||
answers.push({
|
||||
questionId: 'org_industry',
|
||||
value: profile.industry,
|
||||
value: profile.industry.join(', '),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -738,7 +899,7 @@ export function getProfileInfoForBlock(
|
||||
const items: { label: string; value: string }[] = []
|
||||
|
||||
if (blockId === 'organisation') {
|
||||
if (profile.industry) items.push({ label: 'Branche', value: profile.industry })
|
||||
if (profile.industry && profile.industry.length > 0) items.push({ label: 'Branche', value: profile.industry.join(', ') })
|
||||
if (profile.employeeCount) items.push({ label: 'Mitarbeiter', value: profile.employeeCount })
|
||||
if (profile.annualRevenue) items.push({ label: 'Umsatz', value: profile.annualRevenue })
|
||||
if (profile.businessModel) items.push({ label: 'Geschäftsmodell', value: profile.businessModel })
|
||||
@@ -982,3 +1143,29 @@ export function getAllQuestions(): ScopeProfilingQuestion[] {
|
||||
...HIDDEN_SCORING_QUESTIONS,
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unanswered required questions, optionally filtered by block.
|
||||
* Returns block metadata along with each question for navigation.
|
||||
*/
|
||||
export function getUnansweredRequiredQuestions(
|
||||
answers: ScopeProfilingAnswer[],
|
||||
blockId?: ScopeQuestionBlockId
|
||||
): { blockId: ScopeQuestionBlockId; blockTitle: string; question: ScopeProfilingQuestion }[] {
|
||||
const answeredIds = new Set(answers.map((a) => a.questionId))
|
||||
const blocks = blockId
|
||||
? SCOPE_QUESTION_BLOCKS.filter((b) => b.id === blockId)
|
||||
: SCOPE_QUESTION_BLOCKS
|
||||
|
||||
const result: { blockId: ScopeQuestionBlockId; blockTitle: string; question: ScopeProfilingQuestion }[] = []
|
||||
|
||||
for (const block of blocks) {
|
||||
for (const q of block.questions) {
|
||||
if (q.required && !answeredIds.has(q.id)) {
|
||||
result.push({ blockId: block.id, blockTitle: block.title, question: q })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -49,7 +49,8 @@ export type ScopeQuestionBlockId =
|
||||
| 'processes' // Rechte & Prozesse
|
||||
| 'product' // Produktkontext
|
||||
| 'ai_systems' // KI-Systeme (aus Profil portiert)
|
||||
| 'vvt'; // Verarbeitungstaetigkeiten (aus Profil portiert)
|
||||
| 'vvt' // Verarbeitungstaetigkeiten (aus Profil portiert)
|
||||
| 'datenkategorien_detail'; // Datenkategorien pro Abteilung (Block 9)
|
||||
|
||||
/**
|
||||
* Eine einzelne Frage im Scope-Profiling
|
||||
@@ -129,36 +130,58 @@ export type HardTriggerOperator =
|
||||
export interface HardTriggerRule {
|
||||
/** Eindeutige ID der Regel */
|
||||
id: string;
|
||||
/** Kurze Bezeichnung */
|
||||
label: string;
|
||||
/** Detaillierte Beschreibung */
|
||||
description: string;
|
||||
/** Feld, das geprüft wird (questionId oder company_profile Feld) */
|
||||
conditionField: string;
|
||||
/** Kategorie der Regel */
|
||||
category: string;
|
||||
/** Frage-ID, die geprüft wird */
|
||||
questionId: string;
|
||||
/** Bedingungsoperator */
|
||||
conditionOperator: HardTriggerOperator;
|
||||
condition: HardTriggerOperator;
|
||||
/** Wert, der geprüft wird */
|
||||
conditionValue: unknown;
|
||||
/** Minimal erforderliches Level */
|
||||
minimumLevel: ComplianceDepthLevel;
|
||||
/** Pflichtdokumente bei Trigger */
|
||||
mandatoryDocuments: ScopeDocumentType[];
|
||||
/** DSFA erforderlich? */
|
||||
dsfaRequired: boolean;
|
||||
requiresDSFA: boolean;
|
||||
/** Pflichtdokumente bei Trigger */
|
||||
mandatoryDocuments: string[];
|
||||
/** Rechtsgrundlage */
|
||||
legalReference: string;
|
||||
/** Detaillierte Beschreibung */
|
||||
description: string;
|
||||
/** Kombiniert mit Art. 9 Daten? */
|
||||
combineWithArt9?: boolean;
|
||||
/** Kombiniert mit Minderjährigen-Daten? */
|
||||
combineWithMinors?: boolean;
|
||||
/** Kombiniert mit KI-Nutzung? */
|
||||
combineWithAI?: boolean;
|
||||
/** Kombiniert mit Mitarbeiterüberwachung? */
|
||||
combineWithEmployeeMonitoring?: boolean;
|
||||
/** Kombiniert mit automatisierter Entscheidungsfindung? */
|
||||
combineWithADM?: boolean;
|
||||
/** Regel feuert NICHT wenn diese Bedingung zutrifft */
|
||||
excludeWhen?: { questionId: string; value: string | string[] };
|
||||
/** Regel feuert NUR wenn diese Bedingung zutrifft */
|
||||
requireWhen?: { questionId: string; value: string | string[] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Getriggerter Hard Trigger mit Kontext
|
||||
*/
|
||||
export interface TriggeredHardTrigger {
|
||||
/** Die getriggerte Regel */
|
||||
rule: HardTriggerRule;
|
||||
/** Der tatsächlich gefundene Wert */
|
||||
matchedValue: unknown;
|
||||
/** Erklärung warum getriggert */
|
||||
explanation: string;
|
||||
/** Regel-ID */
|
||||
ruleId: string;
|
||||
/** Kategorie */
|
||||
category: string;
|
||||
/** Beschreibung */
|
||||
description: string;
|
||||
/** Rechtsgrundlage */
|
||||
legalReference?: string;
|
||||
/** Mindest-Level */
|
||||
minimumLevel: ComplianceDepthLevel;
|
||||
/** DSFA erforderlich? */
|
||||
requiresDSFA: boolean;
|
||||
/** Pflichtdokumente */
|
||||
mandatoryDocuments: string[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -186,7 +209,13 @@ export type ScopeDocumentType =
|
||||
| 'notfallplan' // Notfall- & Krisenplan
|
||||
| 'zertifizierung' // Zertifizierungsvorbereitung
|
||||
| 'datenschutzmanagement' // Datenschutzmanagement-System (DSMS)
|
||||
| 'iace_ce_assessment'; // CE-Risikobeurteilung SW/FW/KI (IACE)
|
||||
| 'iace_ce_assessment' // CE-Risikobeurteilung SW/FW/KI (IACE)
|
||||
| 'widerrufsbelehrung' // Widerrufsbelehrung (§ 312g BGB)
|
||||
| 'preisangaben' // Preisangaben (PAngV)
|
||||
| 'fernabsatz_info' // Informationspflichten Fernabsatz (§ 312d BGB)
|
||||
| 'streitbeilegung' // Streitbeilegungshinweis (VSBG § 36)
|
||||
| 'produktsicherheit' // Produktsicherheit (GPSR EU 2023/988)
|
||||
| 'ai_act_doku'; // AI Act Technische Dokumentation (Art. 11)
|
||||
|
||||
// ============================================================================
|
||||
// Decision & Output Types
|
||||
@@ -228,14 +257,12 @@ export interface RequiredDocument {
|
||||
documentType: ScopeDocumentType;
|
||||
/** Anzeigename */
|
||||
label: string;
|
||||
/** Ist Pflicht? */
|
||||
required: boolean;
|
||||
/** Erforderliche Tiefe (z.B. "Basis", "Standard", "Detailliert") */
|
||||
depth: string;
|
||||
/** Konkrete Anforderungen/Inhalte */
|
||||
detailItems: string[];
|
||||
/** Geschätzter Aufwand */
|
||||
estimatedEffort: string;
|
||||
/** Pflicht oder empfohlen */
|
||||
requirement: 'mandatory' | 'recommended';
|
||||
/** Priorität */
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
/** Geschätzter Aufwand in Stunden */
|
||||
estimatedEffort: number;
|
||||
/** Von welchen Triggern/Regeln gefordert */
|
||||
triggeredBy: string[];
|
||||
/** Link zum SDK-Schritt */
|
||||
@@ -246,14 +273,12 @@ export interface RequiredDocument {
|
||||
* Risiko-Flag
|
||||
*/
|
||||
export interface RiskFlag {
|
||||
/** Eindeutige ID */
|
||||
id: string;
|
||||
/** Schweregrad */
|
||||
severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||
/** Titel */
|
||||
title: string;
|
||||
severity: string;
|
||||
/** Kategorie */
|
||||
category: string;
|
||||
/** Beschreibung */
|
||||
description: string;
|
||||
message: string;
|
||||
/** Rechtsgrundlage */
|
||||
legalReference?: string;
|
||||
/** Empfehlung zur Behebung */
|
||||
@@ -264,38 +289,44 @@ export interface RiskFlag {
|
||||
* Identifizierte Lücke in der Compliance
|
||||
*/
|
||||
export interface ScopeGap {
|
||||
/** Eindeutige ID */
|
||||
id: string;
|
||||
/** Gap-Typ */
|
||||
gapType: string;
|
||||
/** Schweregrad */
|
||||
severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||
/** Titel */
|
||||
title: string;
|
||||
severity: string;
|
||||
/** Beschreibung */
|
||||
description: string;
|
||||
/** Empfehlung zur Schließung */
|
||||
recommendation: string;
|
||||
/** Betroffene Dokumente */
|
||||
relatedDocuments: ScopeDocumentType[];
|
||||
/** Erforderlich für Level */
|
||||
requiredFor: ComplianceDepthLevel;
|
||||
/** Aktueller Zustand */
|
||||
currentState: string;
|
||||
/** Zielzustand */
|
||||
targetState: string;
|
||||
/** Aufwand in Stunden */
|
||||
effort: number;
|
||||
/** Priorität */
|
||||
priority: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nächster empfohlener Schritt
|
||||
*/
|
||||
export interface NextAction {
|
||||
/** Eindeutige ID */
|
||||
id: string;
|
||||
/** Priorität (1 = höchste) */
|
||||
priority: number;
|
||||
/** Aktionstyp */
|
||||
actionType: 'create_document' | 'establish_process' | 'implement_technical' | 'organizational_change';
|
||||
/** Titel */
|
||||
title: string;
|
||||
/** Beschreibung */
|
||||
description: string;
|
||||
/** Geschätzter Aufwand */
|
||||
estimatedEffort: string;
|
||||
/** Betroffene Dokumente */
|
||||
relatedDocuments: ScopeDocumentType[];
|
||||
/** Priorität */
|
||||
priority: string;
|
||||
/** Geschätzter Aufwand in Stunden */
|
||||
estimatedEffort: number;
|
||||
/** Dokumenttyp (optional) */
|
||||
documentType?: ScopeDocumentType;
|
||||
/** Link zum SDK-Schritt */
|
||||
sdkStepUrl?: string;
|
||||
/** Blocker */
|
||||
blockers: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -306,8 +337,10 @@ export interface ScopeReasoning {
|
||||
step: string;
|
||||
/** Kurzbeschreibung */
|
||||
description: string;
|
||||
/** Detaillierte Punkte */
|
||||
details: string[];
|
||||
/** Faktoren */
|
||||
factors: string[];
|
||||
/** Auswirkung */
|
||||
impact: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -387,11 +420,11 @@ export const DEPTH_LEVEL_DESCRIPTIONS: Record<ComplianceDepthLevel, string> = {
|
||||
/**
|
||||
* Farben für Compliance-Levels (Tailwind-kompatibel)
|
||||
*/
|
||||
export const DEPTH_LEVEL_COLORS: Record<ComplianceDepthLevel, string> = {
|
||||
L1: 'green',
|
||||
L2: 'blue',
|
||||
L3: 'amber',
|
||||
L4: 'red',
|
||||
export const DEPTH_LEVEL_COLORS: Record<ComplianceDepthLevel, { bg: string; border: string; badge: string; text: string }> = {
|
||||
L1: { bg: 'bg-green-50', border: 'border-green-300', badge: 'bg-green-100', text: 'text-green-800' },
|
||||
L2: { bg: 'bg-blue-50', border: 'border-blue-300', badge: 'bg-blue-100', text: 'text-blue-800' },
|
||||
L3: { bg: 'bg-amber-50', border: 'border-amber-300', badge: 'bg-amber-100', text: 'text-amber-800' },
|
||||
L4: { bg: 'bg-red-50', border: 'border-red-300', badge: 'bg-red-100', text: 'text-red-800' },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -416,6 +449,12 @@ export const DOCUMENT_TYPE_LABELS: Record<ScopeDocumentType, string> = {
|
||||
zertifizierung: 'Zertifizierungsvorbereitung',
|
||||
datenschutzmanagement: 'Datenschutzmanagement-System (DSMS)',
|
||||
iace_ce_assessment: 'CE-Risikobeurteilung SW/FW/KI (IACE)',
|
||||
widerrufsbelehrung: 'Widerrufsbelehrung (§ 312g BGB)',
|
||||
preisangaben: 'Preisangaben (PAngV)',
|
||||
fernabsatz_info: 'Informationspflichten Fernabsatz (§ 312d BGB)',
|
||||
streitbeilegung: 'Streitbeilegungshinweis (VSBG § 36)',
|
||||
produktsicherheit: 'Produktsicherheitsdokumentation (GPSR)',
|
||||
ai_act_doku: 'AI Act Technische Dokumentation (Art. 11)',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1298,6 +1337,231 @@ export const DOCUMENT_SCOPE_MATRIX: Record<ScopeDocumentType, DocumentScopeRequi
|
||||
estimatedEffort: '24 Stunden',
|
||||
},
|
||||
},
|
||||
widerrufsbelehrung: {
|
||||
L1: {
|
||||
required: false,
|
||||
depth: 'Nicht relevant',
|
||||
detailItems: ['Nur bei B2C-Fernabsatz erforderlich'],
|
||||
estimatedEffort: '0',
|
||||
},
|
||||
L2: {
|
||||
required: true,
|
||||
depth: 'Standard',
|
||||
detailItems: [
|
||||
'Muster-Widerrufsbelehrung nach EGBGB Anlage 1',
|
||||
'Muster-Widerrufsformular nach EGBGB Anlage 2',
|
||||
'Integration in Bestellprozess',
|
||||
'14-Tage Widerrufsfrist korrekt dargestellt',
|
||||
],
|
||||
estimatedEffort: '2-4 Stunden',
|
||||
},
|
||||
L3: {
|
||||
required: true,
|
||||
depth: 'Erweitert',
|
||||
detailItems: [
|
||||
'Wie L2 + digitale Inhalte (§ 356 Abs. 5 BGB)',
|
||||
'Ausnahmen dokumentiert (§ 312g Abs. 2 BGB)',
|
||||
],
|
||||
estimatedEffort: '4-6 Stunden',
|
||||
},
|
||||
L4: {
|
||||
required: true,
|
||||
depth: 'Vollstaendig',
|
||||
detailItems: [
|
||||
'Wie L3 + automatisierte Pruefung',
|
||||
'Mehrsprachig bei EU-Verkauf',
|
||||
],
|
||||
estimatedEffort: '6-8 Stunden',
|
||||
},
|
||||
},
|
||||
preisangaben: {
|
||||
L1: {
|
||||
required: false,
|
||||
depth: 'Nicht relevant',
|
||||
detailItems: ['Nur bei B2C-Preisauszeichnung erforderlich'],
|
||||
estimatedEffort: '0',
|
||||
},
|
||||
L2: {
|
||||
required: true,
|
||||
depth: 'Standard',
|
||||
detailItems: [
|
||||
'Gesamtpreisangabe inkl. MwSt (§ 1 PAngV)',
|
||||
'Grundpreisangabe bei Mengenware (§ 4 PAngV)',
|
||||
'Versandkosten deutlich angegeben',
|
||||
],
|
||||
estimatedEffort: '2-3 Stunden',
|
||||
},
|
||||
L3: {
|
||||
required: true,
|
||||
depth: 'Erweitert',
|
||||
detailItems: [
|
||||
'Wie L2 + Preishistorie bei Rabattaktionen (Omnibus-RL)',
|
||||
'Streichpreise korrekt dargestellt',
|
||||
],
|
||||
estimatedEffort: '3-5 Stunden',
|
||||
},
|
||||
L4: {
|
||||
required: true,
|
||||
depth: 'Vollstaendig',
|
||||
detailItems: [
|
||||
'Wie L3 + automatisierte Pruefung',
|
||||
'Mehrwaehrungsunterstuetzung',
|
||||
],
|
||||
estimatedEffort: '5-8 Stunden',
|
||||
},
|
||||
},
|
||||
fernabsatz_info: {
|
||||
L1: {
|
||||
required: false,
|
||||
depth: 'Nicht relevant',
|
||||
detailItems: ['Nur bei Fernabsatzvertraegen erforderlich'],
|
||||
estimatedEffort: '0',
|
||||
},
|
||||
L2: {
|
||||
required: true,
|
||||
depth: 'Standard',
|
||||
detailItems: [
|
||||
'Pflichtinformationen nach § 312d BGB i.V.m. Art. 246a EGBGB',
|
||||
'Wesentliche Eigenschaften der Ware/Dienstleistung',
|
||||
'Identitaet und Anschrift des Unternehmers',
|
||||
'Zahlungs-, Liefer- und Leistungsbedingungen',
|
||||
],
|
||||
estimatedEffort: '3-5 Stunden',
|
||||
},
|
||||
L3: {
|
||||
required: true,
|
||||
depth: 'Erweitert',
|
||||
detailItems: [
|
||||
'Wie L2 + Informationen zu digitalen Inhalten/Diensten',
|
||||
'Funktionalitaet und Interoperabilitaet (§ 327 BGB)',
|
||||
],
|
||||
estimatedEffort: '5-8 Stunden',
|
||||
},
|
||||
L4: {
|
||||
required: true,
|
||||
depth: 'Vollstaendig',
|
||||
detailItems: [
|
||||
'Wie L3 + mehrsprachige Informationspflichten',
|
||||
'Automatisierte Vollstaendigkeitspruefung',
|
||||
],
|
||||
estimatedEffort: '8-12 Stunden',
|
||||
},
|
||||
},
|
||||
streitbeilegung: {
|
||||
L1: {
|
||||
required: false,
|
||||
depth: 'Nicht relevant',
|
||||
detailItems: ['Nur bei B2C-Handel erforderlich'],
|
||||
estimatedEffort: '0',
|
||||
},
|
||||
L2: {
|
||||
required: true,
|
||||
depth: 'Standard',
|
||||
detailItems: [
|
||||
'Hinweis auf OS-Plattform der EU-Kommission (Art. 14 ODR-VO)',
|
||||
'Erklaerung zur Teilnahmebereitschaft an Streitbeilegung (§ 36 VSBG)',
|
||||
'Link zur OS-Plattform im Impressum/AGB',
|
||||
],
|
||||
estimatedEffort: '1-2 Stunden',
|
||||
},
|
||||
L3: {
|
||||
required: true,
|
||||
depth: 'Erweitert',
|
||||
detailItems: [
|
||||
'Wie L2 + Benennung zustaendiger Verbraucherschlichtungsstelle',
|
||||
'Prozess fuer Streitbeilegungsanfragen dokumentiert',
|
||||
],
|
||||
estimatedEffort: '2-3 Stunden',
|
||||
},
|
||||
L4: {
|
||||
required: true,
|
||||
depth: 'Vollstaendig',
|
||||
detailItems: [
|
||||
'Wie L3 + Eskalationsprozess dokumentiert',
|
||||
'Regelmaessige Auswertung von Beschwerden',
|
||||
],
|
||||
estimatedEffort: '3-4 Stunden',
|
||||
},
|
||||
},
|
||||
produktsicherheit: {
|
||||
L1: {
|
||||
required: false,
|
||||
depth: 'Minimal',
|
||||
detailItems: ['Grundlegende Produktkennzeichnung pruefen'],
|
||||
estimatedEffort: '1 Stunde',
|
||||
},
|
||||
L2: {
|
||||
required: true,
|
||||
depth: 'Standard',
|
||||
detailItems: [
|
||||
'Produktsicherheitsbewertung nach GPSR (EU 2023/988)',
|
||||
'CE-Kennzeichnung und Konformitaetserklaerung',
|
||||
'Wirtschaftsakteur-Angaben auf Produkt/Verpackung',
|
||||
'Technische Dokumentation fuer Marktaufsicht',
|
||||
],
|
||||
estimatedEffort: '8-12 Stunden',
|
||||
},
|
||||
L3: {
|
||||
required: true,
|
||||
depth: 'Erweitert',
|
||||
detailItems: [
|
||||
'Wie L2 + Risikoanalyse fuer alle Produktvarianten',
|
||||
'Rueckrufplan und Marktbeobachtungspflichten',
|
||||
'Supply-Chain-Dokumentation',
|
||||
],
|
||||
estimatedEffort: '16-24 Stunden',
|
||||
},
|
||||
L4: {
|
||||
required: true,
|
||||
depth: 'Vollstaendig',
|
||||
detailItems: [
|
||||
'Wie L3 + vollstaendige GPSR-Konformitaetsakte',
|
||||
'Post-Market-Surveillance System',
|
||||
'Audit-Trail fuer alle Sicherheitsbewertungen',
|
||||
],
|
||||
estimatedEffort: '24-40 Stunden',
|
||||
},
|
||||
},
|
||||
ai_act_doku: {
|
||||
L1: {
|
||||
required: false,
|
||||
depth: 'Minimal',
|
||||
detailItems: ['KI-Risikokategorisierung (Art. 6 AI Act)'],
|
||||
estimatedEffort: '2 Stunden',
|
||||
},
|
||||
L2: {
|
||||
required: true,
|
||||
depth: 'Standard',
|
||||
detailItems: [
|
||||
'Technische Dokumentation nach Art. 11 AI Act',
|
||||
'Transparenzpflichten (Art. 52 AI Act)',
|
||||
'Risikomanagement-Grundlagen (Art. 9 AI Act)',
|
||||
'Menschliche Aufsicht dokumentiert (Art. 14 AI Act)',
|
||||
],
|
||||
estimatedEffort: '8-12 Stunden',
|
||||
},
|
||||
L3: {
|
||||
required: true,
|
||||
depth: 'Erweitert',
|
||||
detailItems: [
|
||||
'Wie L2 + Datenqualitaetsmanagement (Art. 10 AI Act)',
|
||||
'Genauigkeits- und Robustheitstests (Art. 15 AI Act)',
|
||||
'Vollstaendige Konformitaetsbewertung fuer Hochrisiko-KI',
|
||||
],
|
||||
estimatedEffort: '16-24 Stunden',
|
||||
},
|
||||
L4: {
|
||||
required: true,
|
||||
depth: 'Audit-Ready',
|
||||
detailItems: [
|
||||
'Wie L3 + Zertifizierungsfertige AI Act Dokumentation',
|
||||
'EU-Datenbank-Registrierung (Art. 60 AI Act)',
|
||||
'Post-Market Monitoring fuer KI-Systeme',
|
||||
'Continuous Compliance Framework fuer KI',
|
||||
],
|
||||
estimatedEffort: '24-40 Stunden',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
@@ -1326,6 +1590,12 @@ export const DOCUMENT_SDK_STEP_MAP: Partial<Record<ScopeDocumentType, string>> =
|
||||
zertifizierung: '/sdk/iace',
|
||||
datenschutzmanagement: '/sdk/dsms',
|
||||
iace_ce_assessment: '/sdk/iace',
|
||||
widerrufsbelehrung: '/sdk/policy-generator',
|
||||
preisangaben: '/sdk/policy-generator',
|
||||
fernabsatz_info: '/sdk/policy-generator',
|
||||
streitbeilegung: '/sdk/policy-generator',
|
||||
produktsicherheit: '/sdk/iace',
|
||||
ai_act_doku: '/sdk/ai-act',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -64,7 +64,8 @@ export function generateDemoState(tenantId: string, userId: string): Partial<SDK
|
||||
companyProfile: {
|
||||
companyName: 'TechStart GmbH',
|
||||
legalForm: 'gmbh',
|
||||
industry: 'Technologie / IT',
|
||||
industry: ['Technologie / IT'],
|
||||
industryOther: '',
|
||||
foundedYear: 2022,
|
||||
businessModel: 'B2B_B2C',
|
||||
offerings: ['app_web', 'software_saas', 'services_consulting'],
|
||||
|
||||
@@ -34,7 +34,7 @@ export function buildAllowedFactsFromDraftContext(
|
||||
return {
|
||||
companyName: profile.name || 'Unbekannt',
|
||||
legalForm: '', // Nicht im DraftContext enthalten
|
||||
industry: profile.industry || '',
|
||||
industry: Array.isArray(profile.industry) ? profile.industry.join(', ') : (profile.industry || ''),
|
||||
location: '', // Nicht im DraftContext enthalten
|
||||
employeeCount: profile.employeeCount || 0,
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ export function buildAllowedFacts(
|
||||
return {
|
||||
companyName: profile?.companyName ?? 'Unbekannt',
|
||||
legalForm: profile?.legalForm ?? '',
|
||||
industry: profile?.industry ?? '',
|
||||
industry: Array.isArray(profile?.industry) ? profile.industry.join(', ') : (profile?.industry ?? ''),
|
||||
location: profile?.headquartersCity ?? '',
|
||||
employeeCount: parseEmployeeCount(profile?.employeeCount),
|
||||
|
||||
|
||||
@@ -14,13 +14,13 @@ import {
|
||||
} from '../types'
|
||||
|
||||
describe('DSFA_SECTIONS', () => {
|
||||
it('should have 5 sections defined', () => {
|
||||
expect(DSFA_SECTIONS.length).toBe(5)
|
||||
it('should have 9 sections defined', () => {
|
||||
expect(DSFA_SECTIONS.length).toBe(9)
|
||||
})
|
||||
|
||||
it('should have sections numbered 1-5', () => {
|
||||
it('should have sections numbered 0-8', () => {
|
||||
const numbers = DSFA_SECTIONS.map(s => s.number)
|
||||
expect(numbers).toEqual([1, 2, 3, 4, 5])
|
||||
expect(numbers).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8])
|
||||
})
|
||||
|
||||
it('should have GDPR references for all sections', () => {
|
||||
@@ -30,15 +30,16 @@ describe('DSFA_SECTIONS', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should mark first 4 sections as required', () => {
|
||||
it('should mark 7 sections as required', () => {
|
||||
const requiredSections = DSFA_SECTIONS.filter(s => s.required)
|
||||
expect(requiredSections.length).toBe(4)
|
||||
expect(requiredSections.map(s => s.number)).toEqual([1, 2, 3, 4])
|
||||
expect(requiredSections.length).toBe(7)
|
||||
expect(requiredSections.map(s => s.number)).toEqual([0, 1, 2, 3, 4, 6, 7])
|
||||
})
|
||||
|
||||
it('should mark section 5 as optional', () => {
|
||||
const section5 = DSFA_SECTIONS.find(s => s.number === 5)
|
||||
expect(section5?.required).toBe(false)
|
||||
it('should mark sections 5 and 8 as optional', () => {
|
||||
const optionalSections = DSFA_SECTIONS.filter(s => !s.required)
|
||||
expect(optionalSections.length).toBe(2)
|
||||
expect(optionalSections.map(s => s.number)).toEqual([5, 8])
|
||||
})
|
||||
|
||||
it('should have German titles for all sections', () => {
|
||||
@@ -197,7 +198,7 @@ describe('DSFAMitigation type', () => {
|
||||
})
|
||||
|
||||
describe('DSFASectionProgress type', () => {
|
||||
it('should track completion for all 5 sections', () => {
|
||||
it('should track completion for all 9 sections', () => {
|
||||
const progress: DSFASectionProgress = {
|
||||
section_0_complete: false,
|
||||
section_1_complete: true,
|
||||
@@ -207,6 +208,7 @@ describe('DSFASectionProgress type', () => {
|
||||
section_5_complete: false,
|
||||
section_6_complete: false,
|
||||
section_7_complete: false,
|
||||
section_8_complete: false,
|
||||
}
|
||||
|
||||
expect(progress.section_1_complete).toBe(true)
|
||||
@@ -238,11 +240,15 @@ describe('DSFA type', () => {
|
||||
authority_consulted: false,
|
||||
status: 'draft',
|
||||
section_progress: {
|
||||
section_0_complete: false,
|
||||
section_1_complete: true,
|
||||
section_2_complete: true,
|
||||
section_3_complete: false,
|
||||
section_4_complete: false,
|
||||
section_5_complete: false,
|
||||
section_6_complete: false,
|
||||
section_7_complete: false,
|
||||
section_8_complete: false,
|
||||
},
|
||||
conclusion: '',
|
||||
created_at: new Date().toISOString(),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Loeschfristen Baseline-Katalog
|
||||
*
|
||||
* 18 vordefinierte Aufbewahrungsfristen-Templates fuer gaengige
|
||||
* 25 vordefinierte Aufbewahrungsfristen-Templates fuer gaengige
|
||||
* Datenobjekte in deutschen Unternehmen. Basierend auf AO, HGB,
|
||||
* UStG, BGB, ArbZG, AGG, BDSG und BSIG.
|
||||
* UStG, BGB, ArbZG, AGG, BDSG, BSIG und ArbMedVV.
|
||||
*
|
||||
* Werden genutzt, um neue Loeschfrist-Policies schnell aus
|
||||
* bewaehrten Vorlagen zu erstellen.
|
||||
@@ -48,7 +48,7 @@ export interface BaselineTemplate {
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BASELINE TEMPLATES (18 Vorlagen)
|
||||
// BASELINE TEMPLATES (25 Vorlagen)
|
||||
// =============================================================================
|
||||
|
||||
export const BASELINE_TEMPLATES: BaselineTemplate[] = [
|
||||
@@ -519,6 +519,188 @@ export const BASELINE_TEMPLATES: BaselineTemplate[] = [
|
||||
reviewInterval: 'ANNUAL',
|
||||
tags: ['datenschutz', 'consent'],
|
||||
},
|
||||
|
||||
// ==================== 19. E-Mail-Archivierung ====================
|
||||
{
|
||||
templateId: 'email-archivierung',
|
||||
dataObjectName: 'E-Mail-Archivierung',
|
||||
description:
|
||||
'Archivierte geschaeftliche E-Mails inkl. Anhaenge, die als Handelsbriefe oder steuerrelevante Korrespondenz einzustufen sind.',
|
||||
affectedGroups: ['Mitarbeiter', 'Kunden', 'Lieferanten'],
|
||||
dataCategories: ['E-Mail-Korrespondenz', 'Anhaenge', 'Metadaten'],
|
||||
primaryPurpose:
|
||||
'Erfuellung der handelsrechtlichen Aufbewahrungspflicht fuer geschaeftliche Korrespondenz, die als Handelsbrief einzuordnen ist.',
|
||||
deletionTrigger: 'RETENTION_DRIVER',
|
||||
retentionDriver: 'HGB_257',
|
||||
retentionDriverDetail:
|
||||
'Aufbewahrungspflicht gemaess 257 HGB fuer empfangene und versandte Handelsbriefe (6 Jahre) bzw. buchhalterisch relevante E-Mails (10 Jahre).',
|
||||
retentionDuration: 6,
|
||||
retentionUnit: 'YEARS',
|
||||
retentionDescription: '6 Jahre nach Versand/Empfang der E-Mail',
|
||||
startEvent: 'Versand- bzw. Empfangsdatum der E-Mail',
|
||||
deletionMethod: 'AUTO_DELETE',
|
||||
deletionMethodDetail:
|
||||
'Automatische Loeschung durch das E-Mail-Archivierungssystem nach Ablauf der konfigurierten Aufbewahrungsfrist. Vor Loeschung wird geprueft, ob die E-Mail in laufenden Verfahren benoetigt wird.',
|
||||
responsibleRole: 'IT-Abteilung',
|
||||
reviewInterval: 'ANNUAL',
|
||||
tags: ['kommunikation', 'hgb'],
|
||||
},
|
||||
|
||||
// ==================== 20. Zutrittsprotokolle ====================
|
||||
{
|
||||
templateId: 'zutrittsprotokolle',
|
||||
dataObjectName: 'Zutrittsprotokolle',
|
||||
description:
|
||||
'Protokolle des Zutrittskontrollsystems inkl. Zeitstempel, Kartennummer, Zutrittsort und Zugangsentscheidung (gewaehrt/verweigert).',
|
||||
affectedGroups: ['Mitarbeiter', 'Besucher'],
|
||||
dataCategories: ['Zutrittsdaten', 'Zeitstempel', 'Kartennummern', 'Standortdaten'],
|
||||
primaryPurpose:
|
||||
'Sicherstellung der physischen Sicherheit, Nachvollziehbarkeit von Zutritten und Unterstuetzung bei der Aufklaerung von Sicherheitsvorfaellen.',
|
||||
deletionTrigger: 'RETENTION_DRIVER',
|
||||
retentionDriver: 'BSIG',
|
||||
retentionDriverDetail:
|
||||
'Aufbewahrung gemaess BSI-Grundschutz-Empfehlung fuer Zutrittsprotokolle zur Analyse von Sicherheitsvorfaellen (90 Tage).',
|
||||
retentionDuration: 90,
|
||||
retentionUnit: 'DAYS',
|
||||
retentionDescription: '90 Tage nach Zeitpunkt des Zutritts',
|
||||
startEvent: 'Zeitpunkt des protokollierten Zutrittsereignisses',
|
||||
deletionMethod: 'AUTO_DELETE',
|
||||
deletionMethodDetail:
|
||||
'Automatische Rotation und Loeschung der Zutrittsprotokolle durch das Zutrittskontrollsystem nach Ablauf der 90-Tage-Frist.',
|
||||
responsibleRole: 'Facility Management',
|
||||
reviewInterval: 'QUARTERLY',
|
||||
tags: ['sicherheit', 'zutritt'],
|
||||
},
|
||||
|
||||
// ==================== 21. Schulungsnachweise ====================
|
||||
{
|
||||
templateId: 'schulungsnachweise',
|
||||
dataObjectName: 'Schulungsnachweise',
|
||||
description:
|
||||
'Teilnahmebestaetigungen, Zertifikate und Protokolle von Mitarbeiterschulungen (Datenschutz, Arbeitssicherheit, Compliance).',
|
||||
affectedGroups: ['Mitarbeiter'],
|
||||
dataCategories: ['Schulungsdaten', 'Zertifikate', 'Teilnahmelisten'],
|
||||
primaryPurpose:
|
||||
'Nachweis der Durchfuehrung gesetzlich vorgeschriebener Schulungen und Dokumentation der Mitarbeiterqualifikation.',
|
||||
deletionTrigger: 'RETENTION_DRIVER',
|
||||
retentionDriver: 'CUSTOM',
|
||||
retentionDriverDetail:
|
||||
'Aufbewahrung fuer 3 Jahre nach Ende des Beschaeftigungsverhaeltnisses als Nachweis der ordnungsgemaessen Schulungsdurchfuehrung.',
|
||||
retentionDuration: 3,
|
||||
retentionUnit: 'YEARS',
|
||||
retentionDescription: '3 Jahre nach Ende des Beschaeftigungsverhaeltnisses',
|
||||
startEvent: 'Ende des Beschaeftigungsverhaeltnisses des geschulten Mitarbeiters',
|
||||
deletionMethod: 'MANUAL_REVIEW_DELETE',
|
||||
deletionMethodDetail:
|
||||
'Manuelle Pruefung durch die HR-Abteilung vor Loeschung, da Schulungsnachweise als Compliance-Nachweis in Audits relevant sein koennen.',
|
||||
responsibleRole: 'HR-Abteilung',
|
||||
reviewInterval: 'ANNUAL',
|
||||
tags: ['hr', 'schulung'],
|
||||
},
|
||||
|
||||
// ==================== 22. Betriebsarzt-Dokumentation ====================
|
||||
{
|
||||
templateId: 'betriebsarzt-doku',
|
||||
dataObjectName: 'Betriebsarzt-Dokumentation',
|
||||
description:
|
||||
'Ergebnisse arbeitsmedizinischer Vorsorgeuntersuchungen, Eignungsuntersuchungen und arbeitsmedizinische Empfehlungen.',
|
||||
affectedGroups: ['Mitarbeiter'],
|
||||
dataCategories: ['Gesundheitsdaten', 'Vorsorgeuntersuchungen', 'Eignungsbefunde'],
|
||||
primaryPurpose:
|
||||
'Erfuellung der Dokumentationspflicht fuer arbeitsmedizinische Vorsorge gemaess ArbMedVV und Nachweisfuehrung gegenueber Berufsgenossenschaften.',
|
||||
deletionTrigger: 'RETENTION_DRIVER',
|
||||
retentionDriver: 'CUSTOM',
|
||||
retentionDriverDetail:
|
||||
'Aufbewahrungspflicht gemaess ArbMedVV (Verordnung zur arbeitsmedizinischen Vorsorge) und Berufsgenossenschaftliche Grundsaetze: bis zu 40 Jahre bei Exposition gegenueber krebserzeugenden Gefahrstoffen.',
|
||||
retentionDuration: 40,
|
||||
retentionUnit: 'YEARS',
|
||||
retentionDescription: '40 Jahre nach letzter Exposition (bei Gefahrstoffen), sonst 10 Jahre nach Ende der Taetigkeit',
|
||||
startEvent: 'Ende der expositionsrelevanten Taetigkeit bzw. Ende des Beschaeftigungsverhaeltnisses',
|
||||
deletionMethod: 'PHYSICAL_DESTROY',
|
||||
deletionMethodDetail:
|
||||
'Physische Vernichtung der Papierunterlagen durch zertifizierten Aktenvernichtungsdienstleister (DIN 66399, Sicherheitsstufe P-5). Digitale Daten werden kryptographisch geloescht.',
|
||||
responsibleRole: 'Betriebsarzt / Arbeitsmedizinischer Dienst',
|
||||
reviewInterval: 'ANNUAL',
|
||||
tags: ['hr', 'gesundheit'],
|
||||
},
|
||||
|
||||
// ==================== 23. Kundenreklamationen ====================
|
||||
{
|
||||
templateId: 'kundenreklamationen',
|
||||
dataObjectName: 'Kundenreklamationen',
|
||||
description:
|
||||
'Reklamationsvorgaenge inkl. Beschwerdeinhalt, Kommunikationsverlauf, Massnahmen und Ergebnis der Reklamationsbearbeitung.',
|
||||
affectedGroups: ['Kunden'],
|
||||
dataCategories: ['Reklamationsdaten', 'Kommunikation', 'Massnahmenprotokolle'],
|
||||
primaryPurpose:
|
||||
'Dokumentation und Bearbeitung von Kundenreklamationen, Qualitaetssicherung und Absicherung gegen Gewaehrleistungsansprueche.',
|
||||
deletionTrigger: 'RETENTION_DRIVER',
|
||||
retentionDriver: 'BGB_195',
|
||||
retentionDriverDetail:
|
||||
'Aufbewahrung fuer die Dauer der regelmaessigen Verjaehrungsfrist gemaess 195 BGB (3 Jahre) zur Absicherung gegen Gewaehrleistungs- und Schadensersatzansprueche.',
|
||||
retentionDuration: 3,
|
||||
retentionUnit: 'YEARS',
|
||||
retentionDescription: '3 Jahre nach Abschluss des Reklamationsvorgangs',
|
||||
startEvent: 'Abschluss des Reklamationsvorgangs (letzte Massnahme)',
|
||||
deletionMethod: 'ANONYMIZATION',
|
||||
deletionMethodDetail:
|
||||
'Anonymisierung der personenbezogenen Daten nach Ablauf der Frist. Anonymisierte Reklamationsstatistiken bleiben fuer die Qualitaetssicherung erhalten.',
|
||||
responsibleRole: 'Qualitaetsmanagement',
|
||||
reviewInterval: 'ANNUAL',
|
||||
tags: ['kunden', 'qualitaet'],
|
||||
},
|
||||
|
||||
// ==================== 24. Lieferantenbewertungen ====================
|
||||
{
|
||||
templateId: 'lieferantenbewertungen',
|
||||
dataObjectName: 'Lieferantenbewertungen',
|
||||
description:
|
||||
'Bewertungen und Auditergebnisse von Lieferanten und Auftragsverarbeitern inkl. Qualitaets-, Compliance- und Datenschutz-Bewertungen.',
|
||||
affectedGroups: ['Lieferanten', 'Auftragsverarbeiter'],
|
||||
dataCategories: ['Bewertungsdaten', 'Auditberichte', 'Vertragsdaten'],
|
||||
primaryPurpose:
|
||||
'Dokumentation der Sorgfaltspflicht bei der Auswahl und Ueberwachung von Auftragsverarbeitern gemaess Art. 28 DSGVO und Qualitaetssicherung in der Lieferkette.',
|
||||
deletionTrigger: 'RETENTION_DRIVER',
|
||||
retentionDriver: 'HGB_257',
|
||||
retentionDriverDetail:
|
||||
'Aufbewahrung gemaess 257 HGB als handelsrelevante Unterlagen sowie zur Nachweisfuehrung der Sorgfaltspflicht bei der Auftragsverarbeitung.',
|
||||
retentionDuration: 6,
|
||||
retentionUnit: 'YEARS',
|
||||
retentionDescription: '6 Jahre nach Ende der Geschaeftsbeziehung',
|
||||
startEvent: 'Ende der Geschaeftsbeziehung mit dem Lieferanten/Auftragsverarbeiter',
|
||||
deletionMethod: 'MANUAL_REVIEW_DELETE',
|
||||
deletionMethodDetail:
|
||||
'Manuelle Pruefung durch den Einkauf/Compliance-Abteilung vor Loeschung, um sicherzustellen, dass keine Nachweispflichten aus laufenden Vertraegen oder Audits bestehen.',
|
||||
responsibleRole: 'Einkauf / Compliance',
|
||||
reviewInterval: 'ANNUAL',
|
||||
tags: ['lieferanten', 'einkauf'],
|
||||
},
|
||||
|
||||
// ==================== 25. Social-Media-Marketingdaten ====================
|
||||
{
|
||||
templateId: 'social-media-daten',
|
||||
dataObjectName: 'Social-Media-Marketingdaten',
|
||||
description:
|
||||
'Personenbezogene Daten aus Social-Media-Kampagnen inkl. Nutzerinteraktionen, Custom Audiences, Retargeting-Listen und Kampagnen-Analytics.',
|
||||
affectedGroups: ['Kunden', 'Interessenten', 'Website-Besucher'],
|
||||
dataCategories: ['Interaktionsdaten', 'Zielgruppendaten', 'Tracking-Daten', 'Profilmerkmale'],
|
||||
primaryPurpose:
|
||||
'Durchfuehrung zielgerichteter Marketing-Kampagnen auf Social-Media-Plattformen und Analyse der Kampagneneffektivitaet.',
|
||||
deletionTrigger: 'PURPOSE_END',
|
||||
retentionDriver: null,
|
||||
retentionDriverDetail:
|
||||
'Keine gesetzliche Aufbewahrungspflicht. Daten werden bis zum Widerruf der Einwilligung bzw. bis zum Ende der Kampagne gespeichert (Art. 6 Abs. 1 lit. a DSGVO).',
|
||||
retentionDuration: null,
|
||||
retentionUnit: null,
|
||||
retentionDescription: 'Bis zum Widerruf der Einwilligung oder Ende des Kampagnenzwecks',
|
||||
startEvent: 'Widerruf der Einwilligung oder Ende der Marketing-Kampagne',
|
||||
deletionMethod: 'AUTO_DELETE',
|
||||
deletionMethodDetail:
|
||||
'Automatische Loeschung der personenbezogenen Daten in den Social-Media-Werbekonten und internen Systemen nach Zweckwegfall. Custom Audiences werden bei Plattformanbietern geloescht.',
|
||||
responsibleRole: 'Marketing',
|
||||
reviewInterval: 'SEMI_ANNUAL',
|
||||
tags: ['marketing', 'social'],
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
import {
|
||||
LoeschfristPolicy,
|
||||
PolicyStatus,
|
||||
RetentionDriverType,
|
||||
isPolicyOverdue,
|
||||
getActiveLegalHolds,
|
||||
RETENTION_DRIVER_META,
|
||||
} from './loeschfristen-types'
|
||||
|
||||
// =============================================================================
|
||||
@@ -22,6 +24,10 @@ export type ComplianceIssueType =
|
||||
| 'LEGAL_HOLD_CONFLICT'
|
||||
| 'STALE_DRAFT'
|
||||
| 'UNCOVERED_VVT_CATEGORY'
|
||||
| 'MISSING_DELETION_METHOD'
|
||||
| 'MISSING_STORAGE_LOCATIONS'
|
||||
| 'EXCESSIVE_RETENTION'
|
||||
| 'MISSING_DATA_CATEGORIES'
|
||||
|
||||
export type ComplianceIssueSeverity = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
|
||||
|
||||
@@ -219,6 +225,108 @@ function checkStaleDraft(policy: LoeschfristPolicy): ComplianceIssue | null {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 8: MISSING_DELETION_METHOD (MEDIUM)
|
||||
* Active policy without a deletion method detail description.
|
||||
*/
|
||||
function checkMissingDeletionMethod(policy: LoeschfristPolicy): ComplianceIssue | null {
|
||||
if (policy.status === 'ACTIVE' && !policy.deletionMethodDetail.trim()) {
|
||||
return createIssue(
|
||||
policy,
|
||||
'MISSING_DELETION_METHOD',
|
||||
'MEDIUM',
|
||||
'Keine Loeschmethode beschrieben',
|
||||
`Die aktive Policy "${policy.dataObjectName}" hat keine detaillierte Beschreibung der Loeschmethode. Fuer ein auditfaehiges Loeschkonzept muss dokumentiert sein, wie die Loeschung technisch durchgefuehrt wird.`,
|
||||
'Ergaenzen Sie eine detaillierte Beschreibung der Loeschmethode (z.B. automatisches Loeschen durch Datenbank-Job, manuelle Pruefung durch Fachabteilung, kryptographische Loeschung).'
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 9: MISSING_STORAGE_LOCATIONS (MEDIUM)
|
||||
* Active policy without any documented storage locations.
|
||||
*/
|
||||
function checkMissingStorageLocations(policy: LoeschfristPolicy): ComplianceIssue | null {
|
||||
if (policy.status === 'ACTIVE' && policy.storageLocations.length === 0) {
|
||||
return createIssue(
|
||||
policy,
|
||||
'MISSING_STORAGE_LOCATIONS',
|
||||
'MEDIUM',
|
||||
'Keine Speicherorte dokumentiert',
|
||||
`Die aktive Policy "${policy.dataObjectName}" hat keine Speicherorte hinterlegt. Ohne Speicherort-Dokumentation ist unklar, wo die Daten gespeichert sind und wo die Loeschung durchgefuehrt werden muss.`,
|
||||
'Dokumentieren Sie mindestens einen Speicherort (z.B. Datenbank, Cloud-Speicher, E-Mail-System, Papierarchiv).'
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 10: EXCESSIVE_RETENTION (HIGH)
|
||||
* Retention duration exceeds 2x the legal default for the driver.
|
||||
*/
|
||||
function checkExcessiveRetention(policy: LoeschfristPolicy): ComplianceIssue | null {
|
||||
if (
|
||||
policy.retentionDriver &&
|
||||
policy.retentionDriver !== 'CUSTOM' &&
|
||||
policy.retentionDuration !== null &&
|
||||
policy.retentionUnit !== null
|
||||
) {
|
||||
const meta = RETENTION_DRIVER_META[policy.retentionDriver]
|
||||
if (meta.defaultDuration !== null && meta.defaultUnit !== null) {
|
||||
// Normalize both to days for comparison
|
||||
const policyDays = toDays(policy.retentionDuration, policy.retentionUnit)
|
||||
const legalDays = toDays(meta.defaultDuration, meta.defaultUnit)
|
||||
|
||||
if (legalDays > 0 && policyDays > legalDays * 2) {
|
||||
return createIssue(
|
||||
policy,
|
||||
'EXCESSIVE_RETENTION',
|
||||
'HIGH',
|
||||
'Ueberschreitung der gesetzlichen Aufbewahrungsfrist',
|
||||
`Die Policy "${policy.dataObjectName}" hat eine Aufbewahrungsdauer von ${policy.retentionDuration} ${policy.retentionUnit === 'YEARS' ? 'Jahren' : policy.retentionUnit === 'MONTHS' ? 'Monaten' : 'Tagen'}, die mehr als das Doppelte der gesetzlichen Frist (${meta.defaultDuration} ${meta.defaultUnit === 'YEARS' ? 'Jahre' : meta.defaultUnit === 'MONTHS' ? 'Monate' : 'Tage'} nach ${meta.statute}) betraegt. Ueberlange Speicherung widerspricht dem Grundsatz der Speicherbegrenzung (Art. 5 Abs. 1 lit. e DSGVO).`,
|
||||
'Pruefen Sie, ob die verlaengerte Aufbewahrungsdauer gerechtfertigt ist. Falls nicht, reduzieren Sie sie auf die gesetzliche Mindestfrist.'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 11: MISSING_DATA_CATEGORIES (LOW)
|
||||
* Non-draft policy without any data categories assigned.
|
||||
*/
|
||||
function checkMissingDataCategories(policy: LoeschfristPolicy): ComplianceIssue | null {
|
||||
if (policy.status !== 'DRAFT' && policy.dataCategories.length === 0) {
|
||||
return createIssue(
|
||||
policy,
|
||||
'MISSING_DATA_CATEGORIES',
|
||||
'LOW',
|
||||
'Keine Datenkategorien zugeordnet',
|
||||
`Die Policy "${policy.dataObjectName}" (Status: ${policy.status}) hat keine Datenkategorien zugeordnet. Ohne Datenkategorien ist unklar, welche personenbezogenen Daten von dieser Loeschregel betroffen sind.`,
|
||||
'Ordnen Sie mindestens eine Datenkategorie zu (z.B. Stammdaten, Kontaktdaten, Finanzdaten, Gesundheitsdaten).'
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: convert retention duration to days for comparison.
|
||||
*/
|
||||
function toDays(duration: number, unit: string): number {
|
||||
switch (unit) {
|
||||
case 'DAYS': return duration
|
||||
case 'MONTHS': return duration * 30
|
||||
case 'YEARS': return duration * 365
|
||||
default: return duration
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPLIANCE CHECK
|
||||
// =============================================================================
|
||||
@@ -248,6 +356,10 @@ export function runComplianceCheck(
|
||||
checkNoResponsible(policy),
|
||||
checkLegalHoldConflict(policy),
|
||||
checkStaleDraft(policy),
|
||||
checkMissingDeletionMethod(policy),
|
||||
checkMissingStorageLocations(policy),
|
||||
checkExcessiveRetention(policy),
|
||||
checkMissingDataCategories(policy),
|
||||
]
|
||||
|
||||
for (const issue of checks) {
|
||||
|
||||
879
admin-compliance/lib/sdk/loeschfristen-document.ts
Normal file
879
admin-compliance/lib/sdk/loeschfristen-document.ts
Normal file
@@ -0,0 +1,879 @@
|
||||
// =============================================================================
|
||||
// Loeschfristen Module - Loeschkonzept Document Generator
|
||||
// Generates a printable, audit-ready HTML document according to DSGVO Art. 5/17/30
|
||||
// =============================================================================
|
||||
|
||||
import type {
|
||||
LoeschfristPolicy,
|
||||
RetentionDriverType,
|
||||
} from './loeschfristen-types'
|
||||
|
||||
import {
|
||||
RETENTION_DRIVER_META,
|
||||
DELETION_METHOD_LABELS,
|
||||
STATUS_LABELS,
|
||||
TRIGGER_LABELS,
|
||||
REVIEW_INTERVAL_LABELS,
|
||||
formatRetentionDuration,
|
||||
getEffectiveDeletionTrigger,
|
||||
getActiveLegalHolds,
|
||||
} from './loeschfristen-types'
|
||||
|
||||
import type { ComplianceCheckResult, ComplianceIssueSeverity } from './loeschfristen-compliance'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface LoeschkonzeptOrgHeader {
|
||||
organizationName: string
|
||||
industry: string
|
||||
dpoName: string
|
||||
dpoContact: string
|
||||
responsiblePerson: string
|
||||
locations: string[]
|
||||
employeeCount: string
|
||||
loeschkonzeptVersion: string
|
||||
lastReviewDate: string
|
||||
nextReviewDate: string
|
||||
reviewInterval: string
|
||||
}
|
||||
|
||||
export interface LoeschkonzeptRevision {
|
||||
version: string
|
||||
date: string
|
||||
author: string
|
||||
changes: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DEFAULTS
|
||||
// =============================================================================
|
||||
|
||||
export function createDefaultLoeschkonzeptOrgHeader(): LoeschkonzeptOrgHeader {
|
||||
const now = new Date()
|
||||
const nextYear = new Date()
|
||||
nextYear.setFullYear(nextYear.getFullYear() + 1)
|
||||
|
||||
return {
|
||||
organizationName: '',
|
||||
industry: '',
|
||||
dpoName: '',
|
||||
dpoContact: '',
|
||||
responsiblePerson: '',
|
||||
locations: [],
|
||||
employeeCount: '',
|
||||
loeschkonzeptVersion: '1.0',
|
||||
lastReviewDate: now.toISOString().split('T')[0],
|
||||
nextReviewDate: nextYear.toISOString().split('T')[0],
|
||||
reviewInterval: 'Jaehrlich',
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SEVERITY LABELS (for Compliance Status section)
|
||||
// =============================================================================
|
||||
|
||||
const SEVERITY_LABELS_DE: Record<ComplianceIssueSeverity, string> = {
|
||||
CRITICAL: 'Kritisch',
|
||||
HIGH: 'Hoch',
|
||||
MEDIUM: 'Mittel',
|
||||
LOW: 'Niedrig',
|
||||
}
|
||||
|
||||
const SEVERITY_COLORS: Record<ComplianceIssueSeverity, string> = {
|
||||
CRITICAL: '#dc2626',
|
||||
HIGH: '#ea580c',
|
||||
MEDIUM: '#d97706',
|
||||
LOW: '#6b7280',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HTML DOCUMENT BUILDER
|
||||
// =============================================================================
|
||||
|
||||
export function buildLoeschkonzeptHtml(
|
||||
policies: LoeschfristPolicy[],
|
||||
orgHeader: LoeschkonzeptOrgHeader,
|
||||
vvtActivities: Array<{ id: string; vvt_id?: string; vvtId?: string; name?: string; activity_name?: string }>,
|
||||
complianceResult: ComplianceCheckResult | null,
|
||||
revisions: LoeschkonzeptRevision[]
|
||||
): string {
|
||||
const today = new Date().toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
const activePolicies = policies.filter(p => p.status !== 'ARCHIVED')
|
||||
const orgName = orgHeader.organizationName || 'Organisation'
|
||||
|
||||
// Collect unique storage locations across all policies
|
||||
const allStorageLocations = new Set<string>()
|
||||
for (const p of activePolicies) {
|
||||
for (const loc of p.storageLocations) {
|
||||
allStorageLocations.add(loc.name || loc.type)
|
||||
}
|
||||
}
|
||||
|
||||
// Collect unique responsible roles
|
||||
const roleMap = new Map<string, string[]>()
|
||||
for (const p of activePolicies) {
|
||||
const role = p.responsibleRole || p.responsiblePerson || 'Nicht zugewiesen'
|
||||
if (!roleMap.has(role)) roleMap.set(role, [])
|
||||
roleMap.get(role)!.push(p.dataObjectName || p.policyId)
|
||||
}
|
||||
|
||||
// Collect active legal holds
|
||||
const allActiveLegalHolds: Array<{ policy: string; hold: LoeschfristPolicy['legalHolds'][0] }> = []
|
||||
for (const p of activePolicies) {
|
||||
for (const h of getActiveLegalHolds(p)) {
|
||||
allActiveLegalHolds.push({ policy: p.dataObjectName || p.policyId, hold: h })
|
||||
}
|
||||
}
|
||||
|
||||
// Build VVT cross-reference data
|
||||
const vvtRefs: Array<{ policyName: string; policyId: string; vvtId: string; vvtName: string }> = []
|
||||
for (const p of activePolicies) {
|
||||
for (const linkedId of p.linkedVVTActivityIds) {
|
||||
const activity = vvtActivities.find(a => a.id === linkedId)
|
||||
if (activity) {
|
||||
vvtRefs.push({
|
||||
policyName: p.dataObjectName || p.policyId,
|
||||
policyId: p.policyId,
|
||||
vvtId: activity.vvt_id || activity.vvtId || linkedId.substring(0, 8),
|
||||
vvtName: activity.activity_name || activity.name || 'Unbenannte Verarbeitungstaetigkeit',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build vendor cross-reference data
|
||||
const vendorRefs: Array<{ policyName: string; policyId: string; vendorId: string; duration: string }> = []
|
||||
for (const p of activePolicies) {
|
||||
if (p.linkedVendorIds && p.linkedVendorIds.length > 0) {
|
||||
for (const vendorId of p.linkedVendorIds) {
|
||||
vendorRefs.push({
|
||||
policyName: p.dataObjectName || p.policyId,
|
||||
policyId: p.policyId,
|
||||
vendorId,
|
||||
duration: formatRetentionDuration(p.retentionDuration, p.retentionUnit),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HTML Template
|
||||
// =========================================================================
|
||||
|
||||
let html = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Loeschkonzept — ${escHtml(orgName)}</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 20mm 18mm 22mm 18mm; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 10pt;
|
||||
line-height: 1.5;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* Cover */
|
||||
.cover {
|
||||
min-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
page-break-after: always;
|
||||
}
|
||||
.cover h1 {
|
||||
font-size: 28pt;
|
||||
color: #5b21b6;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.cover .subtitle {
|
||||
font-size: 14pt;
|
||||
color: #7c3aed;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.cover .org-info {
|
||||
background: #f5f3ff;
|
||||
border: 1px solid #ddd6fe;
|
||||
border-radius: 8px;
|
||||
padding: 24px 40px;
|
||||
text-align: left;
|
||||
width: 400px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.cover .org-info div { margin-bottom: 6px; }
|
||||
.cover .org-info .label { font-weight: 600; color: #5b21b6; display: inline-block; min-width: 160px; }
|
||||
.cover .legal-ref {
|
||||
font-size: 9pt;
|
||||
color: #64748b;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* TOC */
|
||||
.toc {
|
||||
page-break-after: always;
|
||||
padding-top: 40px;
|
||||
}
|
||||
.toc h2 {
|
||||
font-size: 18pt;
|
||||
color: #5b21b6;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #5b21b6;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.toc-entry {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px dotted #cbd5e1;
|
||||
font-size: 10pt;
|
||||
}
|
||||
.toc-entry .toc-num { font-weight: 600; color: #5b21b6; min-width: 40px; }
|
||||
|
||||
/* Sections */
|
||||
.section {
|
||||
page-break-inside: avoid;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.section-header {
|
||||
font-size: 14pt;
|
||||
color: #5b21b6;
|
||||
font-weight: 700;
|
||||
margin: 30px 0 12px 0;
|
||||
border-bottom: 2px solid #ddd6fe;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.section-body { margin-bottom: 16px; }
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0 16px 0;
|
||||
font-size: 9pt;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
background: #f5f3ff;
|
||||
color: #5b21b6;
|
||||
font-weight: 600;
|
||||
font-size: 8.5pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
tr:nth-child(even) td { background: #faf5ff; }
|
||||
|
||||
/* Detail cards */
|
||||
.policy-detail {
|
||||
page-break-inside: avoid;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.policy-detail-header {
|
||||
background: #f5f3ff;
|
||||
padding: 8px 12px;
|
||||
font-weight: 700;
|
||||
color: #5b21b6;
|
||||
border-bottom: 1px solid #ddd6fe;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.policy-detail-body { padding: 0; }
|
||||
.policy-detail-body table { margin: 0; }
|
||||
.policy-detail-body th { width: 200px; }
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 8pt;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-active { background: #dcfce7; color: #166534; }
|
||||
.badge-draft { background: #f3f4f6; color: #374151; }
|
||||
.badge-review { background: #fef9c3; color: #854d0e; }
|
||||
.badge-critical { background: #fecaca; color: #991b1b; }
|
||||
.badge-high { background: #fed7aa; color: #9a3412; }
|
||||
.badge-medium { background: #fef3c7; color: #92400e; }
|
||||
.badge-low { background: #f3f4f6; color: #4b5563; }
|
||||
|
||||
/* Principles */
|
||||
.principle {
|
||||
margin-bottom: 10px;
|
||||
padding-left: 20px;
|
||||
position: relative;
|
||||
}
|
||||
.principle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #7c3aed;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.principle strong { color: #5b21b6; }
|
||||
|
||||
/* Score */
|
||||
.score-box {
|
||||
display: inline-block;
|
||||
padding: 4px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 18pt;
|
||||
font-weight: 700;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.score-excellent { background: #dcfce7; color: #166534; }
|
||||
.score-good { background: #dbeafe; color: #1e40af; }
|
||||
.score-needs-work { background: #fef3c7; color: #92400e; }
|
||||
.score-poor { background: #fecaca; color: #991b1b; }
|
||||
|
||||
/* Footer */
|
||||
.page-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 8px 18mm;
|
||||
font-size: 7.5pt;
|
||||
color: #94a3b8;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
/* Print */
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.no-print { display: none !important; }
|
||||
.page-break { page-break-after: always; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 0: Cover Page
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="cover">
|
||||
<h1>Loeschkonzept</h1>
|
||||
<div class="subtitle">gemaess Art. 5 Abs. 1 lit. e, Art. 17, Art. 30 DSGVO</div>
|
||||
<div class="org-info">
|
||||
<div><span class="label">Organisation:</span> ${escHtml(orgName)}</div>
|
||||
${orgHeader.industry ? `<div><span class="label">Branche:</span> ${escHtml(orgHeader.industry)}</div>` : ''}
|
||||
${orgHeader.dpoName ? `<div><span class="label">Datenschutzbeauftragter:</span> ${escHtml(orgHeader.dpoName)}</div>` : ''}
|
||||
${orgHeader.dpoContact ? `<div><span class="label">DSB-Kontakt:</span> ${escHtml(orgHeader.dpoContact)}</div>` : ''}
|
||||
${orgHeader.responsiblePerson ? `<div><span class="label">Verantwortlicher:</span> ${escHtml(orgHeader.responsiblePerson)}</div>` : ''}
|
||||
${orgHeader.employeeCount ? `<div><span class="label">Mitarbeiter:</span> ${escHtml(orgHeader.employeeCount)}</div>` : ''}
|
||||
${orgHeader.locations.length > 0 ? `<div><span class="label">Standorte:</span> ${escHtml(orgHeader.locations.join(', '))}</div>` : ''}
|
||||
</div>
|
||||
<div class="legal-ref">
|
||||
Version ${escHtml(orgHeader.loeschkonzeptVersion)} | Stand: ${today}<br/>
|
||||
Letzte Pruefung: ${formatDateDE(orgHeader.lastReviewDate)} | Naechste Pruefung: ${formatDateDE(orgHeader.nextReviewDate)}<br/>
|
||||
Pruefintervall: ${escHtml(orgHeader.reviewInterval)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Table of Contents
|
||||
// =========================================================================
|
||||
const sections = [
|
||||
'Ziel und Zweck',
|
||||
'Geltungsbereich',
|
||||
'Grundprinzipien der Datenspeicherung',
|
||||
'Loeschregeln-Uebersicht',
|
||||
'Detaillierte Loeschregeln',
|
||||
'VVT-Verknuepfung',
|
||||
'Auftragsverarbeiter mit Loeschpflichten',
|
||||
'Legal Hold Verfahren',
|
||||
'Verantwortlichkeiten',
|
||||
'Pruef- und Revisionszyklus',
|
||||
'Compliance-Status',
|
||||
'Aenderungshistorie',
|
||||
]
|
||||
|
||||
html += `
|
||||
<div class="toc">
|
||||
<h2>Inhaltsverzeichnis</h2>
|
||||
${sections.map((s, i) => `<div class="toc-entry"><span><span class="toc-num">${i + 1}.</span> ${escHtml(s)}</span></div>`).join('\n ')}
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 1: Ziel und Zweck
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">1. Ziel und Zweck</div>
|
||||
<div class="section-body">
|
||||
<p>Dieses Loeschkonzept definiert die systematischen Regeln und Verfahren fuer die Loeschung
|
||||
personenbezogener Daten bei <strong>${escHtml(orgName)}</strong>. Es dient der Umsetzung
|
||||
folgender DSGVO-Anforderungen:</p>
|
||||
<table>
|
||||
<tr><th>Rechtsgrundlage</th><th>Inhalt</th></tr>
|
||||
<tr><td><strong>Art. 5 Abs. 1 lit. e DSGVO</strong></td><td>Grundsatz der Speicherbegrenzung — personenbezogene Daten duerfen nur so lange gespeichert werden, wie es fuer die Zwecke der Verarbeitung erforderlich ist.</td></tr>
|
||||
<tr><td><strong>Art. 17 DSGVO</strong></td><td>Recht auf Loeschung („Recht auf Vergessenwerden“) — Betroffene haben das Recht, die Loeschung ihrer Daten zu verlangen.</td></tr>
|
||||
<tr><td><strong>Art. 30 DSGVO</strong></td><td>Verzeichnis von Verarbeitungstaetigkeiten — vorgesehene Fristen fuer die Loeschung der verschiedenen Datenkategorien muessen dokumentiert werden.</td></tr>
|
||||
</table>
|
||||
<p>Das Loeschkonzept ist fester Bestandteil des Datenschutz-Managementsystems und wird
|
||||
regelmaessig ueberprueft und aktualisiert.</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 2: Geltungsbereich
|
||||
// =========================================================================
|
||||
const storageListHtml = allStorageLocations.size > 0
|
||||
? Array.from(allStorageLocations).map(s => `<li>${escHtml(s)}</li>`).join('')
|
||||
: '<li><em>Keine Speicherorte dokumentiert</em></li>'
|
||||
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">2. Geltungsbereich</div>
|
||||
<div class="section-body">
|
||||
<p>Dieses Loeschkonzept gilt fuer alle personenbezogenen Daten, die von <strong>${escHtml(orgName)}</strong>
|
||||
verarbeitet werden. Es umfasst <strong>${activePolicies.length}</strong> Loeschregeln fuer folgende Systeme und Speicherorte:</p>
|
||||
<ul style="margin: 8px 0 8px 24px;">
|
||||
${storageListHtml}
|
||||
</ul>
|
||||
<p>Saemtliche Verarbeitungstaetigkeiten, die im Verzeichnis von Verarbeitungstaetigkeiten (VVT)
|
||||
erfasst sind, werden durch dieses Loeschkonzept abgedeckt.</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 3: Grundprinzipien
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">3. Grundprinzipien der Datenspeicherung</div>
|
||||
<div class="section-body">
|
||||
<div class="principle"><strong>Speicherbegrenzung:</strong> Personenbezogene Daten werden nur so lange gespeichert, wie es fuer den jeweiligen Verarbeitungszweck erforderlich ist (Art. 5 Abs. 1 lit. e DSGVO).</div>
|
||||
<div class="principle"><strong>3-Level-Loeschlogik:</strong> Die Loeschung folgt einer dreistufigen Priorisierung: (1) Zweckende, (2) gesetzliche Aufbewahrungspflichten, (3) Legal Hold — jeweils mit der laengsten Frist als massgeblich.</div>
|
||||
<div class="principle"><strong>Dokumentationspflicht:</strong> Jede Loeschregel ist dokumentiert mit Rechtsgrundlage, Frist, Loeschmethode und Verantwortlichkeit.</div>
|
||||
<div class="principle"><strong>Regelmaessige Ueberpruefung:</strong> Alle Loeschregeln werden im definierten Intervall ueberprueft und bei Bedarf angepasst.</div>
|
||||
<div class="principle"><strong>Datenschutz durch Technikgestaltung:</strong> Loeschmechanismen werden moeglichst automatisiert, um menschliche Fehler zu minimieren (Art. 25 DSGVO).</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 4: Loeschregeln-Uebersicht
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section page-break">
|
||||
<div class="section-header">4. Loeschregeln-Uebersicht</div>
|
||||
<div class="section-body">
|
||||
<p>Die folgende Tabelle zeigt eine Uebersicht aller ${activePolicies.length} aktiven Loeschregeln:</p>
|
||||
<table>
|
||||
<tr>
|
||||
<th>LF-Nr.</th>
|
||||
<th>Datenobjekt</th>
|
||||
<th>Loeschtrigger</th>
|
||||
<th>Aufbewahrungsfrist</th>
|
||||
<th>Loeschmethode</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
`
|
||||
for (const p of activePolicies) {
|
||||
const trigger = TRIGGER_LABELS[getEffectiveDeletionTrigger(p)]
|
||||
const duration = formatRetentionDuration(p.retentionDuration, p.retentionUnit)
|
||||
const method = DELETION_METHOD_LABELS[p.deletionMethod]
|
||||
const statusLabel = STATUS_LABELS[p.status]
|
||||
const statusClass = p.status === 'ACTIVE' ? 'badge-active' : p.status === 'REVIEW_NEEDED' ? 'badge-review' : 'badge-draft'
|
||||
|
||||
html += ` <tr>
|
||||
<td>${escHtml(p.policyId)}</td>
|
||||
<td>${escHtml(p.dataObjectName)}</td>
|
||||
<td>${escHtml(trigger)}</td>
|
||||
<td>${escHtml(duration)}</td>
|
||||
<td>${escHtml(method)}</td>
|
||||
<td><span class="badge ${statusClass}">${escHtml(statusLabel)}</span></td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 5: Detaillierte Loeschregeln
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">5. Detaillierte Loeschregeln</div>
|
||||
<div class="section-body">
|
||||
`
|
||||
for (const p of activePolicies) {
|
||||
const trigger = TRIGGER_LABELS[getEffectiveDeletionTrigger(p)]
|
||||
const duration = formatRetentionDuration(p.retentionDuration, p.retentionUnit)
|
||||
const method = DELETION_METHOD_LABELS[p.deletionMethod]
|
||||
const statusLabel = STATUS_LABELS[p.status]
|
||||
const driverLabel = p.retentionDriver ? RETENTION_DRIVER_META[p.retentionDriver]?.label || p.retentionDriver : '-'
|
||||
const driverStatute = p.retentionDriver ? RETENTION_DRIVER_META[p.retentionDriver]?.statute || '' : ''
|
||||
const locations = p.storageLocations.map(l => l.name || l.type).join(', ') || '-'
|
||||
const responsible = [p.responsiblePerson, p.responsibleRole].filter(s => s.trim()).join(' / ') || '-'
|
||||
const activeHolds = getActiveLegalHolds(p)
|
||||
|
||||
html += `
|
||||
<div class="policy-detail">
|
||||
<div class="policy-detail-header">
|
||||
<span>${escHtml(p.policyId)} — ${escHtml(p.dataObjectName)}</span>
|
||||
<span class="badge ${p.status === 'ACTIVE' ? 'badge-active' : 'badge-draft'}">${escHtml(statusLabel)}</span>
|
||||
</div>
|
||||
<div class="policy-detail-body">
|
||||
<table>
|
||||
<tr><th>Beschreibung</th><td>${escHtml(p.description || '-')}</td></tr>
|
||||
<tr><th>Betroffenengruppen</th><td>${escHtml(p.affectedGroups.join(', ') || '-')}</td></tr>
|
||||
<tr><th>Datenkategorien</th><td>${escHtml(p.dataCategories.join(', ') || '-')}</td></tr>
|
||||
<tr><th>Verarbeitungszweck</th><td>${escHtml(p.primaryPurpose || '-')}</td></tr>
|
||||
<tr><th>Loeschtrigger</th><td>${escHtml(trigger)}</td></tr>
|
||||
<tr><th>Aufbewahrungstreiber</th><td>${escHtml(driverLabel)}${driverStatute ? ` (${escHtml(driverStatute)})` : ''}</td></tr>
|
||||
<tr><th>Aufbewahrungsfrist</th><td>${escHtml(duration)}</td></tr>
|
||||
<tr><th>Startereignis</th><td>${escHtml(p.startEvent || '-')}</td></tr>
|
||||
<tr><th>Loeschmethode</th><td>${escHtml(method)}</td></tr>
|
||||
<tr><th>Loeschmethode (Detail)</th><td>${escHtml(p.deletionMethodDetail || '-')}</td></tr>
|
||||
<tr><th>Speicherorte</th><td>${escHtml(locations)}</td></tr>
|
||||
<tr><th>Verantwortlich</th><td>${escHtml(responsible)}</td></tr>
|
||||
<tr><th>Pruefintervall</th><td>${escHtml(REVIEW_INTERVAL_LABELS[p.reviewInterval] || p.reviewInterval)}</td></tr>
|
||||
${activeHolds.length > 0 ? `<tr><th>Aktive Legal Holds</th><td>${activeHolds.map(h => `${escHtml(h.reason)} (seit ${formatDateDE(h.startDate)})`).join('<br/>')}</td></tr>` : ''}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 6: VVT-Verknuepfung
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section page-break">
|
||||
<div class="section-header">6. VVT-Verknuepfung</div>
|
||||
<div class="section-body">
|
||||
<p>Die folgende Tabelle zeigt die Verknuepfung zwischen Loeschregeln und Verarbeitungstaetigkeiten
|
||||
im VVT (Art. 30 DSGVO):</p>
|
||||
`
|
||||
if (vvtRefs.length > 0) {
|
||||
html += ` <table>
|
||||
<tr><th>Loeschregel</th><th>LF-Nr.</th><th>VVT-Nr.</th><th>Verarbeitungstaetigkeit</th></tr>
|
||||
`
|
||||
for (const ref of vvtRefs) {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(ref.policyName)}</td>
|
||||
<td>${escHtml(ref.policyId)}</td>
|
||||
<td>${escHtml(ref.vvtId)}</td>
|
||||
<td>${escHtml(ref.vvtName)}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
} else {
|
||||
html += ` <p><em>Noch keine VVT-Verknuepfungen dokumentiert. Verknuepfen Sie Ihre Loeschregeln
|
||||
mit den entsprechenden Verarbeitungstaetigkeiten im Editor-Tab.</em></p>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 7: Auftragsverarbeiter mit Loeschpflichten
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">7. Auftragsverarbeiter mit Loeschpflichten</div>
|
||||
<div class="section-body">
|
||||
<p>Die folgende Tabelle zeigt Loeschregeln, die mit Auftragsverarbeitern verknuepft sind.
|
||||
Diese Verknuepfungen stellen sicher, dass auch bei extern verarbeiteten Daten die Loeschpflichten
|
||||
eingehalten werden (Art. 28 DSGVO).</p>
|
||||
`
|
||||
if (vendorRefs.length > 0) {
|
||||
html += ` <table>
|
||||
<tr><th>Loeschregel</th><th>LF-Nr.</th><th>Auftragsverarbeiter (ID)</th><th>Aufbewahrungsfrist</th></tr>
|
||||
`
|
||||
for (const ref of vendorRefs) {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(ref.policyName)}</td>
|
||||
<td>${escHtml(ref.policyId)}</td>
|
||||
<td>${escHtml(ref.vendorId)}</td>
|
||||
<td>${escHtml(ref.duration)}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
} else {
|
||||
html += ` <p><em>Noch keine Auftragsverarbeiter mit Loeschregeln verknuepft. Verknuepfen Sie Ihre
|
||||
Loeschregeln mit den entsprechenden Auftragsverarbeitern im Editor-Tab.</em></p>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 8: Legal Hold Verfahren
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">8. Legal Hold Verfahren</div>
|
||||
<div class="section-body">
|
||||
<p>Ein Legal Hold (Aufbewahrungspflicht aufgrund rechtlicher Verfahren) setzt die regulaere
|
||||
Loeschung aus. Betroffene Daten duerfen trotz abgelaufener Loeschfrist nicht geloescht werden,
|
||||
bis der Legal Hold aufgehoben wird.</p>
|
||||
<p><strong>Verfahrensschritte:</strong></p>
|
||||
<ol style="margin: 8px 0 8px 24px;">
|
||||
<li>Rechtsabteilung/DSB identifiziert betroffene Datenkategorien</li>
|
||||
<li>Legal Hold wird im System aktiviert (Status: Aktiv)</li>
|
||||
<li>Automatische Loeschung wird fuer betroffene Policies ausgesetzt</li>
|
||||
<li>Regelmaessige Pruefung, ob der Legal Hold noch erforderlich ist</li>
|
||||
<li>Nach Aufhebung: Regulaere Loeschfristen greifen wieder</li>
|
||||
</ol>
|
||||
`
|
||||
if (allActiveLegalHolds.length > 0) {
|
||||
html += ` <p><strong>Aktuell aktive Legal Holds (${allActiveLegalHolds.length}):</strong></p>
|
||||
<table>
|
||||
<tr><th>Datenobjekt</th><th>Grund</th><th>Rechtsgrundlage</th><th>Seit</th><th>Voraussichtlich bis</th></tr>
|
||||
`
|
||||
for (const { policy, hold } of allActiveLegalHolds) {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(policy)}</td>
|
||||
<td>${escHtml(hold.reason)}</td>
|
||||
<td>${escHtml(hold.legalBasis)}</td>
|
||||
<td>${formatDateDE(hold.startDate)}</td>
|
||||
<td>${hold.expectedEndDate ? formatDateDE(hold.expectedEndDate) : 'Unbefristet'}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
} else {
|
||||
html += ` <p><em>Derzeit sind keine aktiven Legal Holds vorhanden.</em></p>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 9: Verantwortlichkeiten
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">9. Verantwortlichkeiten</div>
|
||||
<div class="section-body">
|
||||
<p>Die folgende Rollenmatrix zeigt, welche Organisationseinheiten fuer welche Datenobjekte
|
||||
die Loeschverantwortung tragen:</p>
|
||||
<table>
|
||||
<tr><th>Rolle / Verantwortlich</th><th>Datenobjekte</th><th>Anzahl</th></tr>
|
||||
`
|
||||
for (const [role, objects] of roleMap.entries()) {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(role)}</td>
|
||||
<td>${objects.map(o => escHtml(o)).join(', ')}</td>
|
||||
<td>${objects.length}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 10: Pruef- und Revisionszyklus
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">10. Pruef- und Revisionszyklus</div>
|
||||
<div class="section-body">
|
||||
<table>
|
||||
<tr><th>Eigenschaft</th><th>Wert</th></tr>
|
||||
<tr><td>Aktuelles Pruefintervall</td><td>${escHtml(orgHeader.reviewInterval)}</td></tr>
|
||||
<tr><td>Letzte Pruefung</td><td>${formatDateDE(orgHeader.lastReviewDate)}</td></tr>
|
||||
<tr><td>Naechste Pruefung</td><td>${formatDateDE(orgHeader.nextReviewDate)}</td></tr>
|
||||
<tr><td>Aktuelle Version</td><td>${escHtml(orgHeader.loeschkonzeptVersion)}</td></tr>
|
||||
</table>
|
||||
<p style="margin-top: 8px;">Bei jeder Pruefung wird das Loeschkonzept auf folgende Punkte ueberprueft:</p>
|
||||
<ul style="margin: 8px 0 8px 24px;">
|
||||
<li>Vollstaendigkeit aller Loeschregeln (neue Verarbeitungen erfasst?)</li>
|
||||
<li>Aktualitaet der gesetzlichen Aufbewahrungsfristen</li>
|
||||
<li>Wirksamkeit der technischen Loeschmechanismen</li>
|
||||
<li>Einhaltung der definierten Loeschfristen</li>
|
||||
<li>Angemessenheit der Verantwortlichkeiten</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 11: Compliance-Status
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section page-break">
|
||||
<div class="section-header">11. Compliance-Status</div>
|
||||
<div class="section-body">
|
||||
`
|
||||
if (complianceResult) {
|
||||
const scoreClass = complianceResult.score >= 90 ? 'score-excellent'
|
||||
: complianceResult.score >= 75 ? 'score-good'
|
||||
: complianceResult.score >= 50 ? 'score-needs-work'
|
||||
: 'score-poor'
|
||||
const scoreLabel = complianceResult.score >= 90 ? 'Ausgezeichnet'
|
||||
: complianceResult.score >= 75 ? 'Gut'
|
||||
: complianceResult.score >= 50 ? 'Verbesserungswuerdig'
|
||||
: 'Mangelhaft'
|
||||
|
||||
html += ` <p><span class="score-box ${scoreClass}">${complianceResult.score}/100</span> ${escHtml(scoreLabel)}</p>
|
||||
<table style="margin-top: 12px;">
|
||||
<tr><th>Kennzahl</th><th>Wert</th></tr>
|
||||
<tr><td>Gepruefte Policies</td><td>${complianceResult.stats.total}</td></tr>
|
||||
<tr><td>Bestanden</td><td>${complianceResult.stats.passed}</td></tr>
|
||||
<tr><td>Beanstandungen</td><td>${complianceResult.stats.failed}</td></tr>
|
||||
</table>
|
||||
`
|
||||
if (complianceResult.issues.length > 0) {
|
||||
html += ` <p style="margin-top: 12px;"><strong>Befunde nach Schweregrad:</strong></p>
|
||||
<table>
|
||||
<tr><th>Schweregrad</th><th>Anzahl</th><th>Befunde</th></tr>
|
||||
`
|
||||
const severityOrder: ComplianceIssueSeverity[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
|
||||
for (const sev of severityOrder) {
|
||||
const count = complianceResult.stats.bySeverity[sev]
|
||||
if (count === 0) continue
|
||||
const issuesForSev = complianceResult.issues.filter(i => i.severity === sev)
|
||||
html += ` <tr>
|
||||
<td><span class="badge badge-${sev.toLowerCase()}" style="color: ${SEVERITY_COLORS[sev]}">${SEVERITY_LABELS_DE[sev]}</span></td>
|
||||
<td>${count}</td>
|
||||
<td>${issuesForSev.map(i => escHtml(i.title)).join('; ')}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
} else {
|
||||
html += ` <p style="margin-top: 8px;"><em>Keine Beanstandungen. Alle Policies sind konform.</em></p>
|
||||
`
|
||||
}
|
||||
} else {
|
||||
html += ` <p><em>Compliance-Check wurde noch nicht ausgefuehrt. Fuehren Sie den Check im
|
||||
Export-Tab durch, um den Status in das Dokument aufzunehmen.</em></p>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 12: Aenderungshistorie
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">12. Aenderungshistorie</div>
|
||||
<div class="section-body">
|
||||
<table>
|
||||
<tr><th>Version</th><th>Datum</th><th>Autor</th><th>Aenderungen</th></tr>
|
||||
`
|
||||
if (revisions.length > 0) {
|
||||
for (const rev of revisions) {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(rev.version)}</td>
|
||||
<td>${formatDateDE(rev.date)}</td>
|
||||
<td>${escHtml(rev.author)}</td>
|
||||
<td>${escHtml(rev.changes)}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
} else {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(orgHeader.loeschkonzeptVersion)}</td>
|
||||
<td>${today}</td>
|
||||
<td>${escHtml(orgHeader.dpoName || orgHeader.responsiblePerson || '-')}</td>
|
||||
<td>Erstversion des Loeschkonzepts</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Footer
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="page-footer">
|
||||
<span>Loeschkonzept — ${escHtml(orgName)}</span>
|
||||
<span>Stand: ${today} | Version ${escHtml(orgHeader.loeschkonzeptVersion)}</span>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INTERNAL HELPERS
|
||||
// =============================================================================
|
||||
|
||||
function escHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function formatDateDE(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '-'
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
if (isNaN(date.getTime())) return '-'
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
} catch {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// =============================================================================
|
||||
// Loeschfristen Module - Profiling Wizard
|
||||
// 4-Step Profiling (15 Fragen) zur Generierung von Baseline-Loeschrichtlinien
|
||||
// 4-Step Profiling (16 Fragen) zur Generierung von Baseline-Loeschrichtlinien
|
||||
// =============================================================================
|
||||
|
||||
import type { LoeschfristPolicy, StorageLocation } from './loeschfristen-types'
|
||||
@@ -42,7 +42,7 @@ export interface ProfilingResult {
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROFILING STEPS (4 Steps, 15 Questions)
|
||||
// PROFILING STEPS (4 Steps, 16 Questions)
|
||||
// =============================================================================
|
||||
|
||||
export const PROFILING_STEPS: ProfilingStep[] = [
|
||||
@@ -163,7 +163,7 @@ export const PROFILING_STEPS: ProfilingStep[] = [
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// Step 3: Systeme (3 Fragen)
|
||||
// Step 3: Systeme (4 Fragen)
|
||||
// =========================================================================
|
||||
{
|
||||
id: 'systems',
|
||||
@@ -194,6 +194,14 @@ export const PROFILING_STEPS: ProfilingStep[] = [
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'sys-zutritt',
|
||||
step: 'systems',
|
||||
question: 'Nutzen Sie ein Zutrittskontrollsystem?',
|
||||
helpText: 'Zutrittskontrollsysteme erzeugen Protokolle, die personenbezogene Daten enthalten und einer Loeschfrist unterliegen.',
|
||||
type: 'boolean',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -340,6 +348,7 @@ export function generatePoliciesFromProfile(answers: ProfilingAnswer[]): Profili
|
||||
matchedTemplateIds.add('zeiterfassung')
|
||||
matchedTemplateIds.add('bewerbungsunterlagen')
|
||||
matchedTemplateIds.add('krankmeldungen')
|
||||
matchedTemplateIds.add('schulungsnachweise')
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -358,6 +367,8 @@ export function generatePoliciesFromProfile(answers: ProfilingAnswer[]): Profili
|
||||
matchedTemplateIds.add('vertraege')
|
||||
matchedTemplateIds.add('geschaeftsbriefe')
|
||||
matchedTemplateIds.add('kundenstammdaten')
|
||||
matchedTemplateIds.add('kundenreklamationen')
|
||||
matchedTemplateIds.add('lieferantenbewertungen')
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -367,6 +378,7 @@ export function generatePoliciesFromProfile(answers: ProfilingAnswer[]): Profili
|
||||
matchedTemplateIds.add('newsletter-einwilligungen')
|
||||
matchedTemplateIds.add('crm-kontakthistorie')
|
||||
matchedTemplateIds.add('cookie-consent-logs')
|
||||
matchedTemplateIds.add('social-media-daten')
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -384,6 +396,20 @@ export function generatePoliciesFromProfile(answers: ProfilingAnswer[]): Profili
|
||||
matchedTemplateIds.add('cookie-consent-logs')
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cloud (sys-cloud = true) → E-Mail-Archivierung
|
||||
// -------------------------------------------------------------------------
|
||||
if (getBool('sys-cloud')) {
|
||||
matchedTemplateIds.add('email-archivierung')
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Zutritt (sys-zutritt = true)
|
||||
// -------------------------------------------------------------------------
|
||||
if (getBool('sys-zutritt')) {
|
||||
matchedTemplateIds.add('zutrittsprotokolle')
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ERP/CRM (sys-erp = true)
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -405,6 +431,7 @@ export function generatePoliciesFromProfile(answers: ProfilingAnswer[]): Profili
|
||||
if (getBool('special-gesundheit')) {
|
||||
// Ensure krankmeldungen is included even without full HR data
|
||||
matchedTemplateIds.add('krankmeldungen')
|
||||
matchedTemplateIds.add('betriebsarzt-doku')
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -91,6 +91,7 @@ export interface LoeschfristPolicy {
|
||||
responsiblePerson: string
|
||||
releaseProcess: string
|
||||
linkedVVTActivityIds: string[]
|
||||
linkedVendorIds: string[]
|
||||
// Status & Review
|
||||
status: PolicyStatus
|
||||
lastReviewDate: string
|
||||
@@ -272,6 +273,7 @@ export function createEmptyPolicy(): LoeschfristPolicy {
|
||||
responsiblePerson: '',
|
||||
releaseProcess: '',
|
||||
linkedVVTActivityIds: [],
|
||||
linkedVendorIds: [],
|
||||
status: 'DRAFT',
|
||||
lastReviewDate: now,
|
||||
nextReviewDate: nextYear.toISOString(),
|
||||
|
||||
395
admin-compliance/lib/sdk/obligations-compliance.ts
Normal file
395
admin-compliance/lib/sdk/obligations-compliance.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
// =============================================================================
|
||||
// Obligations Module - Compliance Check Engine
|
||||
// Prueft Pflichten auf Vollstaendigkeit, Konsistenz und Auditfaehigkeit
|
||||
// =============================================================================
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface Obligation {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
source: string
|
||||
source_article: string
|
||||
deadline: string | null
|
||||
status: 'pending' | 'in-progress' | 'completed' | 'overdue'
|
||||
priority: 'critical' | 'high' | 'medium' | 'low'
|
||||
responsible: string
|
||||
linked_systems: string[]
|
||||
linked_vendor_ids?: string[]
|
||||
assessment_id?: string
|
||||
rule_code?: string
|
||||
notes?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
evidence?: string[]
|
||||
review_date?: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
export type ObligationComplianceIssueType =
|
||||
| 'MISSING_RESPONSIBLE'
|
||||
| 'OVERDUE_DEADLINE'
|
||||
| 'MISSING_EVIDENCE'
|
||||
| 'MISSING_DESCRIPTION'
|
||||
| 'NO_LEGAL_REFERENCE'
|
||||
| 'INCOMPLETE_REGULATION'
|
||||
| 'HIGH_PRIORITY_NOT_STARTED'
|
||||
| 'STALE_PENDING'
|
||||
| 'MISSING_LINKED_SYSTEMS'
|
||||
| 'NO_REVIEW_PROCESS'
|
||||
| 'CRITICAL_WITHOUT_EVIDENCE'
|
||||
| 'MISSING_VENDOR_LINK'
|
||||
|
||||
export type ObligationComplianceIssueSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'
|
||||
|
||||
export interface ObligationComplianceIssue {
|
||||
type: ObligationComplianceIssueType
|
||||
severity: ObligationComplianceIssueSeverity
|
||||
message: string
|
||||
affectedObligations: string[]
|
||||
recommendation: string
|
||||
}
|
||||
|
||||
export interface ObligationComplianceCheckResult {
|
||||
score: number
|
||||
issues: ObligationComplianceIssue[]
|
||||
summary: { total: number; critical: number; high: number; medium: number; low: number }
|
||||
checkedAt: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
export const OBLIGATION_SEVERITY_LABELS_DE: Record<ObligationComplianceIssueSeverity, string> = {
|
||||
CRITICAL: 'Kritisch',
|
||||
HIGH: 'Hoch',
|
||||
MEDIUM: 'Mittel',
|
||||
LOW: 'Niedrig',
|
||||
}
|
||||
|
||||
export const OBLIGATION_SEVERITY_COLORS: Record<ObligationComplianceIssueSeverity, string> = {
|
||||
CRITICAL: '#dc2626',
|
||||
HIGH: '#ea580c',
|
||||
MEDIUM: '#d97706',
|
||||
LOW: '#6b7280',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPERS
|
||||
// =============================================================================
|
||||
|
||||
function daysBetween(date: Date, now: Date): number {
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PER-OBLIGATION CHECKS (1-5, 9, 11)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check 1: MISSING_RESPONSIBLE (MEDIUM)
|
||||
* Pflicht ohne verantwortliche Person/Abteilung.
|
||||
*/
|
||||
function checkMissingResponsible(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const affected = obligations.filter(o => !o.responsible || o.responsible.trim() === '')
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'MISSING_RESPONSIBLE',
|
||||
severity: 'MEDIUM',
|
||||
message: `${affected.length} Pflicht(en) ohne verantwortliche Person oder Abteilung. Ohne klare Zustaendigkeit koennen Pflichten nicht zuverlaessig umgesetzt werden.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Weisen Sie jeder Pflicht eine verantwortliche Person oder Abteilung zu.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 2: OVERDUE_DEADLINE (HIGH)
|
||||
* Pflicht mit Deadline in der Vergangenheit + Status != completed.
|
||||
*/
|
||||
function checkOverdueDeadline(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const now = new Date()
|
||||
const affected = obligations.filter(o => {
|
||||
if (!o.deadline || o.status === 'completed') return false
|
||||
return new Date(o.deadline) < now
|
||||
})
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'OVERDUE_DEADLINE',
|
||||
severity: 'HIGH',
|
||||
message: `${affected.length} Pflicht(en) mit ueberschrittener Frist. Ueberfaellige Pflichten stellen ein Compliance-Risiko dar und koennen zu Bussgeldern fuehren.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Bearbeiten Sie ueberfaellige Pflichten umgehend oder passen Sie die Fristen an.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 3: MISSING_EVIDENCE (HIGH)
|
||||
* Completed-Pflicht ohne Evidence.
|
||||
*/
|
||||
function checkMissingEvidence(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const affected = obligations.filter(o =>
|
||||
o.status === 'completed' && (!o.evidence || o.evidence.length === 0)
|
||||
)
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'MISSING_EVIDENCE',
|
||||
severity: 'HIGH',
|
||||
message: `${affected.length} abgeschlossene Pflicht(en) ohne Nachweis. Ohne Nachweise ist die Erfuellung im Audit nicht belegbar.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Hinterlegen Sie Nachweisdokumente fuer alle abgeschlossenen Pflichten.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 4: MISSING_DESCRIPTION (MEDIUM)
|
||||
* Pflicht ohne Beschreibung.
|
||||
*/
|
||||
function checkMissingDescription(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const affected = obligations.filter(o => !o.description || o.description.trim() === '')
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'MISSING_DESCRIPTION',
|
||||
severity: 'MEDIUM',
|
||||
message: `${affected.length} Pflicht(en) ohne Beschreibung. Eine fehlende Beschreibung erschwert die Nachvollziehbarkeit und Umsetzung.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Ergaenzen Sie eine Beschreibung fuer jede Pflicht, die den Inhalt und die Anforderungen erlaeutert.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 5: NO_LEGAL_REFERENCE (HIGH)
|
||||
* Pflicht ohne source_article (kein Artikel-Bezug).
|
||||
*/
|
||||
function checkNoLegalReference(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const affected = obligations.filter(o => !o.source_article || o.source_article.trim() === '')
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'NO_LEGAL_REFERENCE',
|
||||
severity: 'HIGH',
|
||||
message: `${affected.length} Pflicht(en) ohne Artikel-/Paragraphen-Referenz. Ohne Rechtsbezug ist die Pflicht im Audit nicht nachvollziehbar.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Ergaenzen Sie die Rechtsgrundlage (z.B. Art. 32 DSGVO) fuer jede Pflicht.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 9: MISSING_LINKED_SYSTEMS (MEDIUM)
|
||||
* Pflicht ohne verknuepfte Systeme/Verarbeitungen.
|
||||
*/
|
||||
function checkMissingLinkedSystems(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const affected = obligations.filter(o => !o.linked_systems || o.linked_systems.length === 0)
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'MISSING_LINKED_SYSTEMS',
|
||||
severity: 'MEDIUM',
|
||||
message: `${affected.length} Pflicht(en) ohne verknuepfte Systeme oder Verarbeitungstaetigkeiten. Ohne Systemzuordnung fehlt der operative Bezug.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Ordnen Sie jeder Pflicht die betroffenen IT-Systeme oder Verarbeitungstaetigkeiten zu.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 11: CRITICAL_WITHOUT_EVIDENCE (CRITICAL)
|
||||
* Critical-Pflicht ohne Evidence.
|
||||
*/
|
||||
function checkCriticalWithoutEvidence(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const affected = obligations.filter(o =>
|
||||
o.priority === 'critical' && (!o.evidence || o.evidence.length === 0)
|
||||
)
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'CRITICAL_WITHOUT_EVIDENCE',
|
||||
severity: 'CRITICAL',
|
||||
message: `${affected.length} kritische Pflicht(en) ohne Nachweis. Kritische Pflichten erfordern zwingend eine Dokumentation der Erfuellung.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Hinterlegen Sie umgehend Nachweise fuer alle kritischen Pflichten.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 12: MISSING_VENDOR_LINK (MEDIUM)
|
||||
* Art.-28-Pflicht ohne verknuepften Auftragsverarbeiter.
|
||||
*/
|
||||
function checkMissingVendorLink(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const affected = obligations.filter(o =>
|
||||
o.source_article?.includes('Art. 28') &&
|
||||
(!o.linked_vendor_ids || o.linked_vendor_ids.length === 0)
|
||||
)
|
||||
if (affected.length === 0) return null
|
||||
return {
|
||||
type: 'MISSING_VENDOR_LINK',
|
||||
severity: 'MEDIUM',
|
||||
message: `${affected.length} Art.-28-Pflicht(en) ohne verknuepften Auftragsverarbeiter.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Verknuepfen Sie Art.-28-Pflichten mit den betroffenen Auftragsverarbeitern im Vendor Register.',
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AGGREGATE CHECKS (6-8, 10)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check 6: INCOMPLETE_REGULATION (HIGH)
|
||||
* Regulierung, bei der alle Pflichten pending/overdue sind.
|
||||
*/
|
||||
function checkIncompleteRegulation(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const bySource = new Map<string, Obligation[]>()
|
||||
for (const o of obligations) {
|
||||
const src = o.source || 'Unbekannt'
|
||||
if (!bySource.has(src)) bySource.set(src, [])
|
||||
bySource.get(src)!.push(o)
|
||||
}
|
||||
|
||||
const incompleteRegs: string[] = []
|
||||
const affectedIds: string[] = []
|
||||
|
||||
for (const [source, obls] of bySource.entries()) {
|
||||
if (obls.length < 2) continue // Skip single-obligation regulations
|
||||
const allStalled = obls.every(o => o.status === 'pending' || o.status === 'overdue')
|
||||
if (allStalled) {
|
||||
incompleteRegs.push(source)
|
||||
affectedIds.push(...obls.map(o => o.id))
|
||||
}
|
||||
}
|
||||
|
||||
if (incompleteRegs.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'INCOMPLETE_REGULATION',
|
||||
severity: 'HIGH',
|
||||
message: `${incompleteRegs.length} Regulierung(en) vollstaendig ohne Umsetzung: ${incompleteRegs.join(', ')}. Alle Pflichten sind ausstehend oder ueberfaellig.`,
|
||||
affectedObligations: affectedIds,
|
||||
recommendation: 'Beginnen Sie mit der Umsetzung der wichtigsten Pflichten in den betroffenen Regulierungen.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 7: HIGH_PRIORITY_NOT_STARTED (CRITICAL)
|
||||
* Critical/High-Pflicht seit > 30 Tagen pending.
|
||||
*/
|
||||
function checkHighPriorityNotStarted(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const now = new Date()
|
||||
const affected = obligations.filter(o => {
|
||||
if (o.status !== 'pending') return false
|
||||
if (o.priority !== 'critical' && o.priority !== 'high') return false
|
||||
if (!o.created_at) return false
|
||||
return daysBetween(new Date(o.created_at), now) > 30
|
||||
})
|
||||
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'HIGH_PRIORITY_NOT_STARTED',
|
||||
severity: 'CRITICAL',
|
||||
message: `${affected.length} hochprioritaere Pflicht(en) seit ueber 30 Tagen nicht begonnen. Dies deutet auf organisatorische Blockaden oder fehlende Priorisierung hin.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Starten Sie umgehend mit der Bearbeitung dieser kritischen/hohen Pflichten und erstellen Sie einen Umsetzungsplan.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 8: STALE_PENDING (LOW)
|
||||
* Pflicht seit > 90 Tagen pending.
|
||||
*/
|
||||
function checkStalePending(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
const now = new Date()
|
||||
const affected = obligations.filter(o => {
|
||||
if (o.status !== 'pending') return false
|
||||
if (!o.created_at) return false
|
||||
return daysBetween(new Date(o.created_at), now) > 90
|
||||
})
|
||||
|
||||
if (affected.length === 0) return null
|
||||
|
||||
return {
|
||||
type: 'STALE_PENDING',
|
||||
severity: 'LOW',
|
||||
message: `${affected.length} Pflicht(en) seit ueber 90 Tagen ausstehend. Langfristig unbearbeitete Pflichten sollten priorisiert oder als nicht relevant markiert werden.`,
|
||||
affectedObligations: affected.map(o => o.id),
|
||||
recommendation: 'Pruefen Sie, ob die Pflichten weiterhin relevant sind, und setzen Sie Prioritaeten fuer die Umsetzung.',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 10: NO_REVIEW_PROCESS (MEDIUM)
|
||||
* Keine einzige Pflicht hat review_date.
|
||||
*/
|
||||
function checkNoReviewProcess(obligations: Obligation[]): ObligationComplianceIssue | null {
|
||||
if (obligations.length === 0) return null
|
||||
const hasAnyReview = obligations.some(o => o.review_date)
|
||||
if (hasAnyReview) return null
|
||||
|
||||
return {
|
||||
type: 'NO_REVIEW_PROCESS',
|
||||
severity: 'MEDIUM',
|
||||
message: 'Keine Pflicht hat ein Pruefungsdatum (review_date). Ohne regelmaessige Ueberpruefung ist die Aktualitaet des Pflichtenregisters nicht gewaehrleistet.',
|
||||
affectedObligations: [],
|
||||
recommendation: 'Fuehren Sie ein Pruefintervall ein und setzen Sie review_date fuer alle Pflichten.',
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPLIANCE CHECK
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Fuehrt einen vollstaendigen Compliance-Check ueber alle Pflichten durch.
|
||||
*/
|
||||
export function runObligationComplianceCheck(obligations: Obligation[]): ObligationComplianceCheckResult {
|
||||
const issues: ObligationComplianceIssue[] = []
|
||||
|
||||
const checks = [
|
||||
checkMissingResponsible(obligations),
|
||||
checkOverdueDeadline(obligations),
|
||||
checkMissingEvidence(obligations),
|
||||
checkMissingDescription(obligations),
|
||||
checkNoLegalReference(obligations),
|
||||
checkIncompleteRegulation(obligations),
|
||||
checkHighPriorityNotStarted(obligations),
|
||||
checkStalePending(obligations),
|
||||
checkMissingLinkedSystems(obligations),
|
||||
checkNoReviewProcess(obligations),
|
||||
checkCriticalWithoutEvidence(obligations),
|
||||
checkMissingVendorLink(obligations),
|
||||
]
|
||||
|
||||
for (const issue of checks) {
|
||||
if (issue !== null) {
|
||||
issues.push(issue)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate score
|
||||
const summary = { total: issues.length, critical: 0, high: 0, medium: 0, low: 0 }
|
||||
for (const issue of issues) {
|
||||
switch (issue.severity) {
|
||||
case 'CRITICAL': summary.critical++; break
|
||||
case 'HIGH': summary.high++; break
|
||||
case 'MEDIUM': summary.medium++; break
|
||||
case 'LOW': summary.low++; break
|
||||
}
|
||||
}
|
||||
|
||||
const rawScore = 100 - (summary.critical * 15 + summary.high * 10 + summary.medium * 5 + summary.low * 2)
|
||||
const score = Math.max(0, rawScore)
|
||||
|
||||
return {
|
||||
score,
|
||||
issues,
|
||||
summary,
|
||||
checkedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
915
admin-compliance/lib/sdk/obligations-document.ts
Normal file
915
admin-compliance/lib/sdk/obligations-document.ts
Normal file
@@ -0,0 +1,915 @@
|
||||
// =============================================================================
|
||||
// Obligations Module - Pflichtenregister Document Generator
|
||||
// Generates a printable, audit-ready HTML document for the obligation register
|
||||
// =============================================================================
|
||||
|
||||
import type { Obligation, ObligationComplianceCheckResult, ObligationComplianceIssueSeverity } from './obligations-compliance'
|
||||
import { OBLIGATION_SEVERITY_LABELS_DE, OBLIGATION_SEVERITY_COLORS } from './obligations-compliance'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface ObligationDocumentOrgHeader {
|
||||
organizationName: string
|
||||
industry: string
|
||||
dpoName: string
|
||||
dpoContact: string
|
||||
responsiblePerson: string
|
||||
legalDepartment: string
|
||||
documentVersion: string
|
||||
lastReviewDate: string
|
||||
nextReviewDate: string
|
||||
reviewInterval: string
|
||||
}
|
||||
|
||||
export interface ObligationDocumentRevision {
|
||||
version: string
|
||||
date: string
|
||||
author: string
|
||||
changes: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DEFAULTS
|
||||
// =============================================================================
|
||||
|
||||
export function createDefaultObligationDocumentOrgHeader(): ObligationDocumentOrgHeader {
|
||||
const now = new Date()
|
||||
const nextYear = new Date()
|
||||
nextYear.setFullYear(nextYear.getFullYear() + 1)
|
||||
|
||||
return {
|
||||
organizationName: '',
|
||||
industry: '',
|
||||
dpoName: '',
|
||||
dpoContact: '',
|
||||
responsiblePerson: '',
|
||||
legalDepartment: '',
|
||||
documentVersion: '1.0',
|
||||
lastReviewDate: now.toISOString().split('T')[0],
|
||||
nextReviewDate: nextYear.toISOString().split('T')[0],
|
||||
reviewInterval: 'Jaehrlich',
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STATUS & PRIORITY LABELS
|
||||
// =============================================================================
|
||||
|
||||
const STATUS_LABELS_DE: Record<string, string> = {
|
||||
'pending': 'Ausstehend',
|
||||
'in-progress': 'In Bearbeitung',
|
||||
'completed': 'Abgeschlossen',
|
||||
'overdue': 'Ueberfaellig',
|
||||
}
|
||||
|
||||
const STATUS_BADGE_CLASSES: Record<string, string> = {
|
||||
'pending': 'badge-draft',
|
||||
'in-progress': 'badge-review',
|
||||
'completed': 'badge-active',
|
||||
'overdue': 'badge-critical',
|
||||
}
|
||||
|
||||
const PRIORITY_LABELS_DE: Record<string, string> = {
|
||||
critical: 'Kritisch',
|
||||
high: 'Hoch',
|
||||
medium: 'Mittel',
|
||||
low: 'Niedrig',
|
||||
}
|
||||
|
||||
const PRIORITY_BADGE_CLASSES: Record<string, string> = {
|
||||
critical: 'badge-critical',
|
||||
high: 'badge-high',
|
||||
medium: 'badge-medium',
|
||||
low: 'badge-low',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HTML DOCUMENT BUILDER
|
||||
// =============================================================================
|
||||
|
||||
export function buildObligationDocumentHtml(
|
||||
obligations: Obligation[],
|
||||
orgHeader: ObligationDocumentOrgHeader,
|
||||
complianceResult: ObligationComplianceCheckResult | null,
|
||||
revisions: ObligationDocumentRevision[]
|
||||
): string {
|
||||
const today = new Date().toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
const orgName = orgHeader.organizationName || 'Organisation'
|
||||
|
||||
// Group obligations by source (regulation)
|
||||
const bySource = new Map<string, Obligation[]>()
|
||||
for (const o of obligations) {
|
||||
const src = o.source || 'Sonstig'
|
||||
if (!bySource.has(src)) bySource.set(src, [])
|
||||
bySource.get(src)!.push(o)
|
||||
}
|
||||
|
||||
// Build role map
|
||||
const roleMap = new Map<string, Obligation[]>()
|
||||
for (const o of obligations) {
|
||||
const role = o.responsible || 'Nicht zugewiesen'
|
||||
if (!roleMap.has(role)) roleMap.set(role, [])
|
||||
roleMap.get(role)!.push(o)
|
||||
}
|
||||
|
||||
// Distinct sources
|
||||
const distinctSources = Array.from(bySource.keys()).sort()
|
||||
|
||||
// =========================================================================
|
||||
// HTML Template
|
||||
// =========================================================================
|
||||
|
||||
let html = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Pflichtenregister — ${escHtml(orgName)}</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 20mm 18mm 22mm 18mm; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 10pt;
|
||||
line-height: 1.5;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* Cover */
|
||||
.cover {
|
||||
min-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
page-break-after: always;
|
||||
}
|
||||
.cover h1 {
|
||||
font-size: 28pt;
|
||||
color: #5b21b6;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.cover .subtitle {
|
||||
font-size: 14pt;
|
||||
color: #7c3aed;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.cover .org-info {
|
||||
background: #f5f3ff;
|
||||
border: 1px solid #ddd6fe;
|
||||
border-radius: 8px;
|
||||
padding: 24px 40px;
|
||||
text-align: left;
|
||||
width: 400px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.cover .org-info div { margin-bottom: 6px; }
|
||||
.cover .org-info .label { font-weight: 600; color: #5b21b6; display: inline-block; min-width: 160px; }
|
||||
.cover .legal-ref {
|
||||
font-size: 9pt;
|
||||
color: #64748b;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* TOC */
|
||||
.toc {
|
||||
page-break-after: always;
|
||||
padding-top: 40px;
|
||||
}
|
||||
.toc h2 {
|
||||
font-size: 18pt;
|
||||
color: #5b21b6;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #5b21b6;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.toc-entry {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px dotted #cbd5e1;
|
||||
font-size: 10pt;
|
||||
}
|
||||
.toc-entry .toc-num { font-weight: 600; color: #5b21b6; min-width: 40px; }
|
||||
|
||||
/* Sections */
|
||||
.section {
|
||||
page-break-inside: avoid;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.section-header {
|
||||
font-size: 14pt;
|
||||
color: #5b21b6;
|
||||
font-weight: 700;
|
||||
margin: 30px 0 12px 0;
|
||||
border-bottom: 2px solid #ddd6fe;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.section-body { margin-bottom: 16px; }
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0 16px 0;
|
||||
font-size: 9pt;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
background: #f5f3ff;
|
||||
color: #5b21b6;
|
||||
font-weight: 600;
|
||||
font-size: 8.5pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
tr:nth-child(even) td { background: #faf5ff; }
|
||||
|
||||
/* Detail cards */
|
||||
.policy-detail {
|
||||
page-break-inside: avoid;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.policy-detail-header {
|
||||
background: #f5f3ff;
|
||||
padding: 8px 12px;
|
||||
font-weight: 700;
|
||||
color: #5b21b6;
|
||||
border-bottom: 1px solid #ddd6fe;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.policy-detail-body { padding: 0; }
|
||||
.policy-detail-body table { margin: 0; }
|
||||
.policy-detail-body th { width: 200px; }
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 8pt;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-active { background: #dcfce7; color: #166534; }
|
||||
.badge-draft { background: #f3f4f6; color: #374151; }
|
||||
.badge-review { background: #fef9c3; color: #854d0e; }
|
||||
.badge-critical { background: #fecaca; color: #991b1b; }
|
||||
.badge-high { background: #fed7aa; color: #9a3412; }
|
||||
.badge-medium { background: #fef3c7; color: #92400e; }
|
||||
.badge-low { background: #f3f4f6; color: #4b5563; }
|
||||
|
||||
/* Principles */
|
||||
.principle {
|
||||
margin-bottom: 10px;
|
||||
padding-left: 20px;
|
||||
position: relative;
|
||||
}
|
||||
.principle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #7c3aed;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.principle strong { color: #5b21b6; }
|
||||
|
||||
/* Score */
|
||||
.score-box {
|
||||
display: inline-block;
|
||||
padding: 4px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 18pt;
|
||||
font-weight: 700;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.score-excellent { background: #dcfce7; color: #166534; }
|
||||
.score-good { background: #dbeafe; color: #1e40af; }
|
||||
.score-needs-work { background: #fef3c7; color: #92400e; }
|
||||
.score-poor { background: #fecaca; color: #991b1b; }
|
||||
|
||||
/* Footer */
|
||||
.page-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 8px 18mm;
|
||||
font-size: 7.5pt;
|
||||
color: #94a3b8;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
/* Print */
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.no-print { display: none !important; }
|
||||
.page-break { page-break-after: always; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 0: Cover Page
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="cover">
|
||||
<h1>Pflichtenregister</h1>
|
||||
<div class="subtitle">Regulatorische Pflichten — DSGVO, AI Act, NIS2 und weitere</div>
|
||||
<div class="org-info">
|
||||
<div><span class="label">Organisation:</span> ${escHtml(orgName)}</div>
|
||||
${orgHeader.industry ? `<div><span class="label">Branche:</span> ${escHtml(orgHeader.industry)}</div>` : ''}
|
||||
${orgHeader.dpoName ? `<div><span class="label">DSB:</span> ${escHtml(orgHeader.dpoName)}</div>` : ''}
|
||||
${orgHeader.dpoContact ? `<div><span class="label">DSB-Kontakt:</span> ${escHtml(orgHeader.dpoContact)}</div>` : ''}
|
||||
${orgHeader.responsiblePerson ? `<div><span class="label">Verantwortlicher:</span> ${escHtml(orgHeader.responsiblePerson)}</div>` : ''}
|
||||
${orgHeader.legalDepartment ? `<div><span class="label">Rechtsabteilung:</span> ${escHtml(orgHeader.legalDepartment)}</div>` : ''}
|
||||
</div>
|
||||
<div class="legal-ref">
|
||||
Version ${escHtml(orgHeader.documentVersion)} | Stand: ${today}<br/>
|
||||
Letzte Pruefung: ${formatDateDE(orgHeader.lastReviewDate)} | Naechste Pruefung: ${formatDateDE(orgHeader.nextReviewDate)}<br/>
|
||||
Pruefintervall: ${escHtml(orgHeader.reviewInterval)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Table of Contents
|
||||
// =========================================================================
|
||||
const sections = [
|
||||
'Ziel und Zweck',
|
||||
'Geltungsbereich',
|
||||
'Methodik',
|
||||
'Regulatorische Grundlagen',
|
||||
'Pflichtenuebersicht',
|
||||
'Detaillierte Pflichten',
|
||||
'Verantwortlichkeiten',
|
||||
'Fristen und Termine',
|
||||
'Nachweisverzeichnis',
|
||||
'Compliance-Status',
|
||||
'Aenderungshistorie',
|
||||
]
|
||||
|
||||
html += `
|
||||
<div class="toc">
|
||||
<h2>Inhaltsverzeichnis</h2>
|
||||
${sections.map((s, i) => `<div class="toc-entry"><span><span class="toc-num">${i + 1}.</span> ${escHtml(s)}</span></div>`).join('\n ')}
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 1: Ziel und Zweck
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">1. Ziel und Zweck</div>
|
||||
<div class="section-body">
|
||||
<p>Dieses Pflichtenregister dokumentiert alle regulatorischen Pflichten, denen
|
||||
<strong>${escHtml(orgName)}</strong> unterliegt. Es dient der systematischen Erfassung,
|
||||
Ueberwachung und Nachverfolgung aller Compliance-Anforderungen aus den anwendbaren
|
||||
Regulierungen.</p>
|
||||
<p style="margin-top: 8px;">Das Register erfuellt folgende Zwecke:</p>
|
||||
<ul style="margin: 8px 0 8px 24px;">
|
||||
<li>Vollstaendige Erfassung aller anwendbaren regulatorischen Pflichten</li>
|
||||
<li>Zuordnung von Verantwortlichkeiten und Fristen</li>
|
||||
<li>Nachverfolgung des Umsetzungsstatus</li>
|
||||
<li>Dokumentation von Nachweisen fuer Audits</li>
|
||||
<li>Identifikation von Compliance-Luecken und Handlungsbedarf</li>
|
||||
</ul>
|
||||
<table>
|
||||
<tr><th>Rechtsrahmen</th><th>Relevanz</th></tr>
|
||||
<tr><td><strong>DSGVO (EU) 2016/679</strong></td><td>Datenschutz-Grundverordnung — Kernregulierung fuer personenbezogene Daten</td></tr>
|
||||
<tr><td><strong>AI Act (EU) 2024/1689</strong></td><td>KI-Verordnung — Anforderungen an KI-Systeme nach Risikoklasse</td></tr>
|
||||
<tr><td><strong>NIS2 (EU) 2022/2555</strong></td><td>Netzwerk- und Informationssicherheit — Cybersicherheitspflichten</td></tr>
|
||||
<tr><td><strong>BDSG</strong></td><td>Bundesdatenschutzgesetz — Nationale Ergaenzung zur DSGVO</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 2: Geltungsbereich
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">2. Geltungsbereich</div>
|
||||
<div class="section-body">
|
||||
<p>Dieses Pflichtenregister gilt fuer alle Geschaeftsprozesse und IT-Systeme von
|
||||
<strong>${escHtml(orgName)}</strong>${orgHeader.industry ? ` (Branche: ${escHtml(orgHeader.industry)})` : ''}.</p>
|
||||
<p style="margin-top: 8px;">Anwendbare Regulierungen:</p>
|
||||
<table>
|
||||
<tr><th>Regulierung</th><th>Anzahl Pflichten</th><th>Status</th></tr>
|
||||
`
|
||||
for (const [source, obls] of bySource.entries()) {
|
||||
const completed = obls.filter(o => o.status === 'completed').length
|
||||
const pct = obls.length > 0 ? Math.round((completed / obls.length) * 100) : 0
|
||||
html += ` <tr>
|
||||
<td>${escHtml(source)}</td>
|
||||
<td>${obls.length}</td>
|
||||
<td>${completed}/${obls.length} abgeschlossen (${pct}%)</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
<p>Insgesamt umfasst dieses Register <strong>${obligations.length}</strong> Pflichten aus
|
||||
<strong>${distinctSources.length}</strong> Regulierungen.</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 3: Methodik
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">3. Methodik</div>
|
||||
<div class="section-body">
|
||||
<p>Die Identifikation und Bewertung der Pflichten erfolgt in drei Schritten:</p>
|
||||
<div class="principle"><strong>Pflicht-Identifikation:</strong> Systematische Analyse aller anwendbaren Regulierungen und Extraktion der einzelnen Pflichten mit Artikel-Referenz, Beschreibung und Zielgruppe.</div>
|
||||
<div class="principle"><strong>Bewertung und Priorisierung:</strong> Jede Pflicht wird nach Prioritaet (kritisch, hoch, mittel, niedrig) und Dringlichkeit (Frist) bewertet. Die Bewertung basiert auf dem Risikopotenzial bei Nichterfuellung.</div>
|
||||
<div class="principle"><strong>Ueberwachung und Nachverfolgung:</strong> Regelmaessige Pruefung des Umsetzungsstatus, Aktualisierung der Fristen und Dokumentation von Nachweisen.</div>
|
||||
<p style="margin-top: 12px;">Die Pflichten werden ueber einen automatisierten Compliance-Check geprueft, der
|
||||
11 Kriterien umfasst (siehe Abschnitt 10: Compliance-Status).</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 4: Regulatorische Grundlagen
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section page-break">
|
||||
<div class="section-header">4. Regulatorische Grundlagen</div>
|
||||
<div class="section-body">
|
||||
<p>Die folgende Tabelle zeigt die regulatorischen Grundlagen mit Artikelzahl und Umsetzungsstatus:</p>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Regulierung</th>
|
||||
<th>Pflichten</th>
|
||||
<th>Kritisch</th>
|
||||
<th>Hoch</th>
|
||||
<th>Mittel</th>
|
||||
<th>Niedrig</th>
|
||||
<th>Abgeschlossen</th>
|
||||
</tr>
|
||||
`
|
||||
for (const [source, obls] of bySource.entries()) {
|
||||
const critical = obls.filter(o => o.priority === 'critical').length
|
||||
const high = obls.filter(o => o.priority === 'high').length
|
||||
const medium = obls.filter(o => o.priority === 'medium').length
|
||||
const low = obls.filter(o => o.priority === 'low').length
|
||||
const completed = obls.filter(o => o.status === 'completed').length
|
||||
|
||||
html += ` <tr>
|
||||
<td><strong>${escHtml(source)}</strong></td>
|
||||
<td>${obls.length}</td>
|
||||
<td>${critical}</td>
|
||||
<td>${high}</td>
|
||||
<td>${medium}</td>
|
||||
<td>${low}</td>
|
||||
<td>${completed}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
// Totals row
|
||||
const totalCritical = obligations.filter(o => o.priority === 'critical').length
|
||||
const totalHigh = obligations.filter(o => o.priority === 'high').length
|
||||
const totalMedium = obligations.filter(o => o.priority === 'medium').length
|
||||
const totalLow = obligations.filter(o => o.priority === 'low').length
|
||||
const totalCompleted = obligations.filter(o => o.status === 'completed').length
|
||||
|
||||
html += ` <tr style="font-weight: 700; background: #f5f3ff;">
|
||||
<td>Gesamt</td>
|
||||
<td>${obligations.length}</td>
|
||||
<td>${totalCritical}</td>
|
||||
<td>${totalHigh}</td>
|
||||
<td>${totalMedium}</td>
|
||||
<td>${totalLow}</td>
|
||||
<td>${totalCompleted}</td>
|
||||
</tr>
|
||||
`
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 5: Pflichtenuebersicht
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">5. Pflichtenuebersicht</div>
|
||||
<div class="section-body">
|
||||
<p>Uebersicht aller ${obligations.length} Pflichten nach Regulierung und Status:</p>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Regulierung</th>
|
||||
<th>Gesamt</th>
|
||||
<th>Ausstehend</th>
|
||||
<th>In Bearbeitung</th>
|
||||
<th>Abgeschlossen</th>
|
||||
<th>Ueberfaellig</th>
|
||||
</tr>
|
||||
`
|
||||
for (const [source, obls] of bySource.entries()) {
|
||||
const pending = obls.filter(o => o.status === 'pending').length
|
||||
const inProgress = obls.filter(o => o.status === 'in-progress').length
|
||||
const completed = obls.filter(o => o.status === 'completed').length
|
||||
const overdue = obls.filter(o => o.status === 'overdue').length
|
||||
|
||||
html += ` <tr>
|
||||
<td>${escHtml(source)}</td>
|
||||
<td>${obls.length}</td>
|
||||
<td>${pending}</td>
|
||||
<td>${inProgress}</td>
|
||||
<td>${completed}</td>
|
||||
<td>${overdue > 0 ? `<span class="badge badge-critical">${overdue}</span>` : '0'}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 6: Detaillierte Pflichten
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">6. Detaillierte Pflichten</div>
|
||||
<div class="section-body">
|
||||
`
|
||||
|
||||
for (const [source, obls] of bySource.entries()) {
|
||||
// Sort by priority (critical first) then by title
|
||||
const priorityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 }
|
||||
const sorted = [...obls].sort((a, b) => {
|
||||
const pa = priorityOrder[a.priority] ?? 2
|
||||
const pb = priorityOrder[b.priority] ?? 2
|
||||
if (pa !== pb) return pa - pb
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
|
||||
html += ` <h3 style="color: #5b21b6; margin: 20px 0 10px 0; font-size: 11pt;">${escHtml(source)} <span style="font-weight: 400; font-size: 9pt; color: #64748b;">(${sorted.length} Pflichten)</span></h3>
|
||||
`
|
||||
|
||||
for (const o of sorted) {
|
||||
const statusLabel = STATUS_LABELS_DE[o.status] || o.status
|
||||
const statusBadge = STATUS_BADGE_CLASSES[o.status] || 'badge-draft'
|
||||
const priorityLabel = PRIORITY_LABELS_DE[o.priority] || o.priority
|
||||
const priorityBadge = PRIORITY_BADGE_CLASSES[o.priority] || 'badge-draft'
|
||||
const deadlineStr = o.deadline ? formatDateDE(o.deadline) : '—'
|
||||
const evidenceStr = o.evidence && o.evidence.length > 0
|
||||
? o.evidence.map(e => escHtml(e)).join(', ')
|
||||
: '<em style="color: #d97706;">Kein Nachweis</em>'
|
||||
const systemsStr = o.linked_systems && o.linked_systems.length > 0
|
||||
? o.linked_systems.map(s => escHtml(s)).join(', ')
|
||||
: '—'
|
||||
|
||||
html += `
|
||||
<div class="policy-detail">
|
||||
<div class="policy-detail-header">
|
||||
<span>${escHtml(o.title)}</span>
|
||||
<span class="badge ${statusBadge}">${escHtml(statusLabel)}</span>
|
||||
</div>
|
||||
<div class="policy-detail-body">
|
||||
<table>
|
||||
<tr><th>Rechtsquelle</th><td>${escHtml(o.source)} ${escHtml(o.source_article || '')}</td></tr>
|
||||
<tr><th>Beschreibung</th><td>${escHtml(o.description || '—')}</td></tr>
|
||||
<tr><th>Prioritaet</th><td><span class="badge ${priorityBadge}">${escHtml(priorityLabel)}</span></td></tr>
|
||||
<tr><th>Status</th><td><span class="badge ${statusBadge}">${escHtml(statusLabel)}</span></td></tr>
|
||||
<tr><th>Verantwortlich</th><td>${escHtml(o.responsible || '—')}</td></tr>
|
||||
<tr><th>Frist</th><td>${deadlineStr}</td></tr>
|
||||
<tr><th>Nachweise</th><td>${evidenceStr}</td></tr>
|
||||
<tr><th>Betroffene Systeme</th><td>${systemsStr}</td></tr>
|
||||
${o.linked_vendor_ids && o.linked_vendor_ids.length > 0 ? `<tr><th>Auftragsverarbeiter</th><td>${o.linked_vendor_ids.map(id => escHtml(id)).join(', ')}</td></tr>` : ''}
|
||||
${o.notes ? `<tr><th>Notizen</th><td>${escHtml(o.notes)}</td></tr>` : ''}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 7: Verantwortlichkeiten
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section page-break">
|
||||
<div class="section-header">7. Verantwortlichkeiten</div>
|
||||
<div class="section-body">
|
||||
<p>Die folgende Rollenmatrix zeigt, welche Personen oder Abteilungen fuer welche Pflichten
|
||||
die Umsetzungsverantwortung tragen:</p>
|
||||
<table>
|
||||
<tr><th>Verantwortlich</th><th>Pflichten</th><th>Anzahl</th><th>Davon offen</th></tr>
|
||||
`
|
||||
for (const [role, obls] of roleMap.entries()) {
|
||||
const openCount = obls.filter(o => o.status !== 'completed').length
|
||||
const titles = obls.slice(0, 5).map(o => escHtml(o.title))
|
||||
const suffix = obls.length > 5 ? `, ... (+${obls.length - 5})` : ''
|
||||
html += ` <tr>
|
||||
<td>${escHtml(role)}</td>
|
||||
<td>${titles.join('; ')}${suffix}</td>
|
||||
<td>${obls.length}</td>
|
||||
<td>${openCount}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 8: Fristen und Termine
|
||||
// =========================================================================
|
||||
const now = new Date()
|
||||
const withDeadline = obligations
|
||||
.filter(o => o.deadline && o.status !== 'completed')
|
||||
.sort((a, b) => new Date(a.deadline!).getTime() - new Date(b.deadline!).getTime())
|
||||
|
||||
const overdue = withDeadline.filter(o => new Date(o.deadline!) < now)
|
||||
const upcoming = withDeadline.filter(o => new Date(o.deadline!) >= now)
|
||||
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">8. Fristen und Termine</div>
|
||||
<div class="section-body">
|
||||
`
|
||||
if (overdue.length > 0) {
|
||||
html += ` <h4 style="color: #dc2626; margin-bottom: 8px;">Ueberfaellige Pflichten (${overdue.length})</h4>
|
||||
<table>
|
||||
<tr><th>Pflicht</th><th>Regulierung</th><th>Frist</th><th>Tage ueberfaellig</th><th>Prioritaet</th></tr>
|
||||
`
|
||||
for (const o of overdue) {
|
||||
const days = daysBetween(new Date(o.deadline!), now)
|
||||
html += ` <tr>
|
||||
<td>${escHtml(o.title)}</td>
|
||||
<td>${escHtml(o.source)}</td>
|
||||
<td>${formatDateDE(o.deadline)}</td>
|
||||
<td><span class="badge badge-critical">${days} Tage</span></td>
|
||||
<td><span class="badge ${PRIORITY_BADGE_CLASSES[o.priority] || 'badge-draft'}">${escHtml(PRIORITY_LABELS_DE[o.priority] || o.priority)}</span></td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
}
|
||||
|
||||
if (upcoming.length > 0) {
|
||||
html += ` <h4 style="color: #5b21b6; margin: 16px 0 8px 0;">Anstehende Fristen (${upcoming.length})</h4>
|
||||
<table>
|
||||
<tr><th>Pflicht</th><th>Regulierung</th><th>Frist</th><th>Verbleibend</th><th>Verantwortlich</th></tr>
|
||||
`
|
||||
for (const o of upcoming.slice(0, 20)) {
|
||||
const days = daysBetween(now, new Date(o.deadline!))
|
||||
html += ` <tr>
|
||||
<td>${escHtml(o.title)}</td>
|
||||
<td>${escHtml(o.source)}</td>
|
||||
<td>${formatDateDE(o.deadline)}</td>
|
||||
<td>${days} Tage</td>
|
||||
<td>${escHtml(o.responsible || '—')}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
if (upcoming.length > 20) {
|
||||
html += ` <tr><td colspan="5" style="text-align: center; color: #64748b;">... und ${upcoming.length - 20} weitere</td></tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
}
|
||||
|
||||
if (withDeadline.length === 0) {
|
||||
html += ` <p><em>Keine offenen Pflichten mit Fristen vorhanden.</em></p>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 9: Nachweisverzeichnis
|
||||
// =========================================================================
|
||||
const withEvidence = obligations.filter(o => o.evidence && o.evidence.length > 0)
|
||||
const withoutEvidence = obligations.filter(o => !o.evidence || o.evidence.length === 0)
|
||||
|
||||
html += `
|
||||
<div class="section page-break">
|
||||
<div class="section-header">9. Nachweisverzeichnis</div>
|
||||
<div class="section-body">
|
||||
<p>${withEvidence.length} von ${obligations.length} Pflichten haben Nachweise hinterlegt.</p>
|
||||
`
|
||||
if (withEvidence.length > 0) {
|
||||
html += ` <table>
|
||||
<tr><th>Pflicht</th><th>Regulierung</th><th>Nachweise</th><th>Status</th></tr>
|
||||
`
|
||||
for (const o of withEvidence) {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(o.title)}</td>
|
||||
<td>${escHtml(o.source)}</td>
|
||||
<td>${o.evidence!.map(e => escHtml(e)).join(', ')}</td>
|
||||
<td><span class="badge ${STATUS_BADGE_CLASSES[o.status] || 'badge-draft'}">${escHtml(STATUS_LABELS_DE[o.status] || o.status)}</span></td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
}
|
||||
|
||||
if (withoutEvidence.length > 0) {
|
||||
html += ` <p style="margin-top: 12px;"><strong>Pflichten ohne Nachweise (${withoutEvidence.length}):</strong></p>
|
||||
<ul style="margin: 4px 0 8px 24px; font-size: 9pt; color: #d97706;">
|
||||
`
|
||||
for (const o of withoutEvidence.slice(0, 15)) {
|
||||
html += ` <li>${escHtml(o.title)} (${escHtml(o.source)})</li>
|
||||
`
|
||||
}
|
||||
if (withoutEvidence.length > 15) {
|
||||
html += ` <li>... und ${withoutEvidence.length - 15} weitere</li>
|
||||
`
|
||||
}
|
||||
html += ` </ul>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 10: Compliance-Status
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">10. Compliance-Status</div>
|
||||
<div class="section-body">
|
||||
`
|
||||
if (complianceResult) {
|
||||
const scoreClass = complianceResult.score >= 90 ? 'score-excellent'
|
||||
: complianceResult.score >= 75 ? 'score-good'
|
||||
: complianceResult.score >= 50 ? 'score-needs-work'
|
||||
: 'score-poor'
|
||||
const scoreLabel = complianceResult.score >= 90 ? 'Ausgezeichnet'
|
||||
: complianceResult.score >= 75 ? 'Gut'
|
||||
: complianceResult.score >= 50 ? 'Verbesserungswuerdig'
|
||||
: 'Mangelhaft'
|
||||
|
||||
html += ` <p><span class="score-box ${scoreClass}">${complianceResult.score}/100</span> ${escHtml(scoreLabel)}</p>
|
||||
<table style="margin-top: 12px;">
|
||||
<tr><th>Kennzahl</th><th>Wert</th></tr>
|
||||
<tr><td>Geprueft am</td><td>${formatDateDE(complianceResult.checkedAt)}</td></tr>
|
||||
<tr><td>Befunde gesamt</td><td>${complianceResult.summary.total}</td></tr>
|
||||
<tr><td>Kritisch</td><td>${complianceResult.summary.critical}</td></tr>
|
||||
<tr><td>Hoch</td><td>${complianceResult.summary.high}</td></tr>
|
||||
<tr><td>Mittel</td><td>${complianceResult.summary.medium}</td></tr>
|
||||
<tr><td>Niedrig</td><td>${complianceResult.summary.low}</td></tr>
|
||||
</table>
|
||||
`
|
||||
if (complianceResult.issues.length > 0) {
|
||||
html += ` <p style="margin-top: 12px;"><strong>Befunde nach Schweregrad:</strong></p>
|
||||
<table>
|
||||
<tr><th>Schweregrad</th><th>Befund</th><th>Betroffene Pflichten</th><th>Empfehlung</th></tr>
|
||||
`
|
||||
const severityOrder: ObligationComplianceIssueSeverity[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
|
||||
for (const sev of severityOrder) {
|
||||
const issuesForSev = complianceResult.issues.filter(i => i.severity === sev)
|
||||
for (const issue of issuesForSev) {
|
||||
html += ` <tr>
|
||||
<td><span class="badge badge-${sev.toLowerCase()}" style="color: ${OBLIGATION_SEVERITY_COLORS[sev]}">${OBLIGATION_SEVERITY_LABELS_DE[sev]}</span></td>
|
||||
<td>${escHtml(issue.message)}</td>
|
||||
<td>${issue.affectedObligations.length > 0 ? issue.affectedObligations.length + ' Pflicht(en)' : '—'}</td>
|
||||
<td>${escHtml(issue.recommendation)}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
} else {
|
||||
html += ` <p style="margin-top: 8px;"><em>Keine Beanstandungen. Alle Pflichten sind konform.</em></p>
|
||||
`
|
||||
}
|
||||
} else {
|
||||
html += ` <p><em>Compliance-Check wurde noch nicht ausgefuehrt. Fuehren Sie den Check im
|
||||
Pflichtenregister-Tab durch, um den Status in das Dokument aufzunehmen.</em></p>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 11: Aenderungshistorie
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">11. Aenderungshistorie</div>
|
||||
<div class="section-body">
|
||||
<table>
|
||||
<tr><th>Version</th><th>Datum</th><th>Autor</th><th>Aenderungen</th></tr>
|
||||
`
|
||||
if (revisions.length > 0) {
|
||||
for (const rev of revisions) {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(rev.version)}</td>
|
||||
<td>${formatDateDE(rev.date)}</td>
|
||||
<td>${escHtml(rev.author)}</td>
|
||||
<td>${escHtml(rev.changes)}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
} else {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(orgHeader.documentVersion)}</td>
|
||||
<td>${today}</td>
|
||||
<td>${escHtml(orgHeader.dpoName || orgHeader.responsiblePerson || '—')}</td>
|
||||
<td>Erstversion des Pflichtenregisters</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Footer
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="page-footer">
|
||||
<span>Pflichtenregister — ${escHtml(orgName)}</span>
|
||||
<span>Stand: ${today} | Version ${escHtml(orgHeader.documentVersion)}</span>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INTERNAL HELPERS
|
||||
// =============================================================================
|
||||
|
||||
function escHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function formatDateDE(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '—'
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
if (isNaN(date.getTime())) return '—'
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
|
||||
function daysBetween(earlier: Date, later: Date): number {
|
||||
const diffMs = later.getTime() - earlier.getTime()
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
553
admin-compliance/lib/sdk/tom-compliance.ts
Normal file
553
admin-compliance/lib/sdk/tom-compliance.ts
Normal file
@@ -0,0 +1,553 @@
|
||||
// =============================================================================
|
||||
// TOM Module - Compliance Check Engine
|
||||
// Prueft Technische und Organisatorische Massnahmen auf Vollstaendigkeit,
|
||||
// Konsistenz und DSGVO-Konformitaet (Art. 32 DSGVO)
|
||||
// =============================================================================
|
||||
|
||||
import type {
|
||||
TOMGeneratorState,
|
||||
DerivedTOM,
|
||||
RiskProfile,
|
||||
DataProfile,
|
||||
ControlCategory,
|
||||
ImplementationStatus,
|
||||
} from './tom-generator/types'
|
||||
|
||||
import { getControlById, getControlsByCategory, getAllCategories } from './tom-generator/controls/loader'
|
||||
import { SDM_CATEGORY_MAPPING } from './tom-generator/types'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export type TOMComplianceIssueSeverity = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'
|
||||
|
||||
export type TOMComplianceIssueType =
|
||||
| 'MISSING_RESPONSIBLE'
|
||||
| 'OVERDUE_REVIEW'
|
||||
| 'MISSING_EVIDENCE'
|
||||
| 'INCOMPLETE_CATEGORY'
|
||||
| 'NO_ENCRYPTION_MEASURES'
|
||||
| 'NO_PSEUDONYMIZATION'
|
||||
| 'MISSING_AVAILABILITY'
|
||||
| 'NO_REVIEW_PROCESS'
|
||||
| 'UNCOVERED_SDM_GOAL'
|
||||
| 'HIGH_RISK_WITHOUT_MEASURES'
|
||||
| 'STALE_NOT_IMPLEMENTED'
|
||||
|
||||
export interface TOMComplianceIssue {
|
||||
id: string
|
||||
controlId: string
|
||||
controlName: string
|
||||
type: TOMComplianceIssueType
|
||||
severity: TOMComplianceIssueSeverity
|
||||
title: string
|
||||
description: string
|
||||
recommendation: string
|
||||
}
|
||||
|
||||
export interface TOMComplianceCheckResult {
|
||||
issues: TOMComplianceIssue[]
|
||||
score: number // 0-100
|
||||
stats: {
|
||||
total: number
|
||||
passed: number
|
||||
failed: number
|
||||
bySeverity: Record<TOMComplianceIssueSeverity, number>
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
export const TOM_SEVERITY_LABELS_DE: Record<TOMComplianceIssueSeverity, string> = {
|
||||
CRITICAL: 'Kritisch',
|
||||
HIGH: 'Hoch',
|
||||
MEDIUM: 'Mittel',
|
||||
LOW: 'Niedrig',
|
||||
}
|
||||
|
||||
export const TOM_SEVERITY_COLORS: Record<TOMComplianceIssueSeverity, string> = {
|
||||
CRITICAL: '#dc2626',
|
||||
HIGH: '#ea580c',
|
||||
MEDIUM: '#d97706',
|
||||
LOW: '#6b7280',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPERS
|
||||
// =============================================================================
|
||||
|
||||
let issueCounter = 0
|
||||
|
||||
function createIssueId(): string {
|
||||
issueCounter++
|
||||
return `TCI-${Date.now()}-${String(issueCounter).padStart(4, '0')}`
|
||||
}
|
||||
|
||||
function createIssue(
|
||||
controlId: string,
|
||||
controlName: string,
|
||||
type: TOMComplianceIssueType,
|
||||
severity: TOMComplianceIssueSeverity,
|
||||
title: string,
|
||||
description: string,
|
||||
recommendation: string
|
||||
): TOMComplianceIssue {
|
||||
return { id: createIssueId(), controlId, controlName, type, severity, title, description, recommendation }
|
||||
}
|
||||
|
||||
function daysBetween(date: Date, now: Date): number {
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
return Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PER-TOM CHECKS (1-3, 11)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check 1: MISSING_RESPONSIBLE (MEDIUM)
|
||||
* REQUIRED TOM without responsiblePerson AND responsibleDepartment.
|
||||
*/
|
||||
function checkMissingResponsible(tom: DerivedTOM): TOMComplianceIssue | null {
|
||||
if (tom.applicability !== 'REQUIRED') return null
|
||||
|
||||
if (!tom.responsiblePerson && !tom.responsibleDepartment) {
|
||||
return createIssue(
|
||||
tom.controlId,
|
||||
tom.name,
|
||||
'MISSING_RESPONSIBLE',
|
||||
'MEDIUM',
|
||||
'Keine verantwortliche Person/Abteilung',
|
||||
`Die TOM "${tom.name}" ist als REQUIRED eingestuft, hat aber weder eine verantwortliche Person noch eine verantwortliche Abteilung zugewiesen. Ohne klare Verantwortlichkeit kann die Massnahme nicht zuverlaessig umgesetzt und gepflegt werden.`,
|
||||
'Weisen Sie eine verantwortliche Person oder Abteilung zu, die fuer die Umsetzung und regelmaessige Pruefung dieser Massnahme zustaendig ist.'
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 2: OVERDUE_REVIEW (MEDIUM)
|
||||
* TOM with reviewDate in the past.
|
||||
*/
|
||||
function checkOverdueReview(tom: DerivedTOM): TOMComplianceIssue | null {
|
||||
if (!tom.reviewDate) return null
|
||||
|
||||
const reviewDate = new Date(tom.reviewDate)
|
||||
const now = new Date()
|
||||
|
||||
if (reviewDate < now) {
|
||||
const overdueDays = daysBetween(reviewDate, now)
|
||||
return createIssue(
|
||||
tom.controlId,
|
||||
tom.name,
|
||||
'OVERDUE_REVIEW',
|
||||
'MEDIUM',
|
||||
'Ueberfaellige Pruefung',
|
||||
`Die TOM "${tom.name}" haette am ${reviewDate.toLocaleDateString('de-DE')} geprueft werden muessen. Die Pruefung ist ${overdueDays} Tag(e) ueberfaellig. Gemaess Art. 32 Abs. 1 lit. d DSGVO ist eine regelmaessige Ueberpruefung der Wirksamkeit von TOMs erforderlich.`,
|
||||
'Fuehren Sie umgehend eine Wirksamkeitspruefung dieser Massnahme durch und aktualisieren Sie das naechste Pruefungsdatum.'
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 3: MISSING_EVIDENCE (HIGH)
|
||||
* IMPLEMENTED TOM where linkedEvidence is empty but the control has evidenceRequirements.
|
||||
*/
|
||||
function checkMissingEvidence(tom: DerivedTOM): TOMComplianceIssue | null {
|
||||
if (tom.implementationStatus !== 'IMPLEMENTED') return null
|
||||
if (tom.linkedEvidence.length > 0) return null
|
||||
|
||||
const control = getControlById(tom.controlId)
|
||||
if (!control || control.evidenceRequirements.length === 0) return null
|
||||
|
||||
return createIssue(
|
||||
tom.controlId,
|
||||
tom.name,
|
||||
'MISSING_EVIDENCE',
|
||||
'HIGH',
|
||||
'Kein Nachweis hinterlegt',
|
||||
`Die TOM "${tom.name}" ist als IMPLEMENTED markiert, hat aber keine verknuepften Nachweisdokumente. Der Control erfordert ${control.evidenceRequirements.length} Nachweis(e): ${control.evidenceRequirements.join(', ')}. Ohne Nachweise ist die Umsetzung nicht auditfaehig.`,
|
||||
'Laden Sie die erforderlichen Nachweisdokumente hoch und verknuepfen Sie sie mit dieser Massnahme.'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 11: STALE_NOT_IMPLEMENTED (LOW)
|
||||
* REQUIRED TOM that has been NOT_IMPLEMENTED for >90 days.
|
||||
* Uses implementationDate === null and state.createdAt / state.updatedAt as reference.
|
||||
*/
|
||||
function checkStaleNotImplemented(tom: DerivedTOM, state: TOMGeneratorState): TOMComplianceIssue | null {
|
||||
if (tom.applicability !== 'REQUIRED') return null
|
||||
if (tom.implementationStatus !== 'NOT_IMPLEMENTED') return null
|
||||
if (tom.implementationDate !== null) return null
|
||||
|
||||
const referenceDate = state.createdAt ? new Date(state.createdAt) : (state.updatedAt ? new Date(state.updatedAt) : null)
|
||||
if (!referenceDate) return null
|
||||
|
||||
const ageInDays = daysBetween(referenceDate, new Date())
|
||||
if (ageInDays <= 90) return null
|
||||
|
||||
return createIssue(
|
||||
tom.controlId,
|
||||
tom.name,
|
||||
'STALE_NOT_IMPLEMENTED',
|
||||
'LOW',
|
||||
'Langfristig nicht umgesetzte Pflichtmassnahme',
|
||||
`Die TOM "${tom.name}" ist als REQUIRED eingestuft, aber seit ${ageInDays} Tagen nicht umgesetzt. Pflichtmassnahmen, die laenger als 90 Tage nicht implementiert werden, deuten auf organisatorische Blockaden oder unzureichende Priorisierung hin.`,
|
||||
'Pruefen Sie, ob die Massnahme weiterhin erforderlich ist, und erstellen Sie einen konkreten Umsetzungsplan mit Verantwortlichkeiten und Fristen.'
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// AGGREGATE CHECKS (4-10)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Check 4: INCOMPLETE_CATEGORY (HIGH)
|
||||
* Category where ALL applicable (REQUIRED) controls are NOT_IMPLEMENTED.
|
||||
*/
|
||||
function checkIncompleteCategory(toms: DerivedTOM[]): TOMComplianceIssue[] {
|
||||
const issues: TOMComplianceIssue[] = []
|
||||
|
||||
// Group applicable TOMs by category
|
||||
const categoryMap = new Map<ControlCategory, DerivedTOM[]>()
|
||||
|
||||
for (const tom of toms) {
|
||||
const control = getControlById(tom.controlId)
|
||||
if (!control) continue
|
||||
|
||||
const category = control.category
|
||||
if (!categoryMap.has(category)) {
|
||||
categoryMap.set(category, [])
|
||||
}
|
||||
categoryMap.get(category)!.push(tom)
|
||||
}
|
||||
|
||||
for (const [category, categoryToms] of Array.from(categoryMap.entries())) {
|
||||
// Only check categories that have at least one REQUIRED control
|
||||
const requiredToms = categoryToms.filter((t: DerivedTOM) => t.applicability === 'REQUIRED')
|
||||
if (requiredToms.length === 0) continue
|
||||
|
||||
const allNotImplemented = requiredToms.every((t: DerivedTOM) => t.implementationStatus === 'NOT_IMPLEMENTED')
|
||||
if (allNotImplemented) {
|
||||
issues.push(
|
||||
createIssue(
|
||||
category,
|
||||
category,
|
||||
'INCOMPLETE_CATEGORY',
|
||||
'HIGH',
|
||||
`Kategorie "${category}" vollstaendig ohne Umsetzung`,
|
||||
`Alle ${requiredToms.length} Pflichtmassnahme(n) in der Kategorie "${category}" sind nicht umgesetzt. Eine vollstaendig unabgedeckte Kategorie stellt eine erhebliche Luecke im TOM-Konzept dar.`,
|
||||
`Setzen Sie mindestens die wichtigsten Massnahmen in der Kategorie "${category}" um, um eine Grundabdeckung sicherzustellen.`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 5: NO_ENCRYPTION_MEASURES (CRITICAL)
|
||||
* No ENCRYPTION control with status IMPLEMENTED.
|
||||
*/
|
||||
function checkNoEncryption(toms: DerivedTOM[]): TOMComplianceIssue | null {
|
||||
const hasImplementedEncryption = toms.some((tom) => {
|
||||
const control = getControlById(tom.controlId)
|
||||
return control?.category === 'ENCRYPTION' && tom.implementationStatus === 'IMPLEMENTED'
|
||||
})
|
||||
|
||||
if (!hasImplementedEncryption) {
|
||||
return createIssue(
|
||||
'ENCRYPTION',
|
||||
'Verschluesselung',
|
||||
'NO_ENCRYPTION_MEASURES',
|
||||
'CRITICAL',
|
||||
'Keine Verschluesselungsmassnahmen umgesetzt',
|
||||
'Es ist keine einzige Verschluesselungsmassnahme als IMPLEMENTED markiert. Art. 32 Abs. 1 lit. a DSGVO nennt Verschluesselung explizit als geeignete technische Massnahme. Ohne Verschluesselung sind personenbezogene Daten bei Zugriff oder Verlust ungeschuetzt.',
|
||||
'Implementieren Sie umgehend Verschluesselungsmassnahmen fuer Daten im Ruhezustand (Encryption at Rest) und waehrend der Uebertragung (Encryption in Transit).'
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 6: NO_PSEUDONYMIZATION (MEDIUM)
|
||||
* DataProfile has special categories (Art. 9) but no PSEUDONYMIZATION control implemented.
|
||||
*/
|
||||
function checkNoPseudonymization(toms: DerivedTOM[], dataProfile: DataProfile | null): TOMComplianceIssue | null {
|
||||
if (!dataProfile || !dataProfile.hasSpecialCategories) return null
|
||||
|
||||
const hasImplementedPseudonymization = toms.some((tom) => {
|
||||
const control = getControlById(tom.controlId)
|
||||
return control?.category === 'PSEUDONYMIZATION' && tom.implementationStatus === 'IMPLEMENTED'
|
||||
})
|
||||
|
||||
if (!hasImplementedPseudonymization) {
|
||||
return createIssue(
|
||||
'PSEUDONYMIZATION',
|
||||
'Pseudonymisierung',
|
||||
'NO_PSEUDONYMIZATION',
|
||||
'MEDIUM',
|
||||
'Keine Pseudonymisierung bei besonderen Datenkategorien',
|
||||
'Das Datenprofil enthaelt besondere Kategorien personenbezogener Daten (Art. 9 DSGVO), aber keine Pseudonymisierungsmassnahme ist umgesetzt. Art. 32 Abs. 1 lit. a DSGVO empfiehlt Pseudonymisierung ausdruecklich als Schutzmassnahme.',
|
||||
'Implementieren Sie Pseudonymisierungsmassnahmen fuer die Verarbeitung besonderer Datenkategorien, um das Risiko fuer betroffene Personen zu minimieren.'
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 7: MISSING_AVAILABILITY (HIGH)
|
||||
* No AVAILABILITY or RECOVERY control implemented AND no DR plan in securityProfile.
|
||||
*/
|
||||
function checkMissingAvailability(toms: DerivedTOM[], state: TOMGeneratorState): TOMComplianceIssue | null {
|
||||
const hasAvailabilityOrRecovery = toms.some((tom) => {
|
||||
const control = getControlById(tom.controlId)
|
||||
return (
|
||||
(control?.category === 'AVAILABILITY' || control?.category === 'RECOVERY') &&
|
||||
tom.implementationStatus === 'IMPLEMENTED'
|
||||
)
|
||||
})
|
||||
|
||||
const hasDRPlan = state.securityProfile?.hasDRPlan ?? false
|
||||
|
||||
if (!hasAvailabilityOrRecovery && !hasDRPlan) {
|
||||
return createIssue(
|
||||
'AVAILABILITY',
|
||||
'Verfuegbarkeit / Wiederherstellbarkeit',
|
||||
'MISSING_AVAILABILITY',
|
||||
'HIGH',
|
||||
'Keine Verfuegbarkeits- oder Wiederherstellungsmassnahmen',
|
||||
'Weder Verfuegbarkeits- noch Wiederherstellungsmassnahmen sind umgesetzt, und es existiert kein Disaster-Recovery-Plan im Security-Profil. Art. 32 Abs. 1 lit. b und c DSGVO verlangen die Faehigkeit zur raschen Wiederherstellung der Verfuegbarkeit personenbezogener Daten.',
|
||||
'Implementieren Sie Backup-Konzepte, Redundanzloesungen und einen Disaster-Recovery-Plan, um die Verfuegbarkeit und Wiederherstellbarkeit sicherzustellen.'
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 8: NO_REVIEW_PROCESS (MEDIUM)
|
||||
* No REVIEW control implemented (Art. 32 Abs. 1 lit. d requires periodic review).
|
||||
*/
|
||||
function checkNoReviewProcess(toms: DerivedTOM[]): TOMComplianceIssue | null {
|
||||
const hasImplementedReview = toms.some((tom) => {
|
||||
const control = getControlById(tom.controlId)
|
||||
return control?.category === 'REVIEW' && tom.implementationStatus === 'IMPLEMENTED'
|
||||
})
|
||||
|
||||
if (!hasImplementedReview) {
|
||||
return createIssue(
|
||||
'REVIEW',
|
||||
'Ueberpruefung & Bewertung',
|
||||
'NO_REVIEW_PROCESS',
|
||||
'MEDIUM',
|
||||
'Kein Verfahren zur regelmaessigen Ueberpruefung',
|
||||
'Es ist keine Ueberpruefungsmassnahme als IMPLEMENTED markiert. Art. 32 Abs. 1 lit. d DSGVO verlangt ein Verfahren zur regelmaessigen Ueberpruefung, Bewertung und Evaluierung der Wirksamkeit der technischen und organisatorischen Massnahmen.',
|
||||
'Implementieren Sie einen regelmaessigen Review-Prozess (z.B. quartalsweise TOM-Audits, jaehrliche Wirksamkeitspruefung) und dokumentieren Sie die Ergebnisse.'
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 9: UNCOVERED_SDM_GOAL (HIGH)
|
||||
* SDM goal with 0% coverage — no implemented control maps to it via SDM_CATEGORY_MAPPING.
|
||||
*/
|
||||
function checkUncoveredSDMGoal(toms: DerivedTOM[]): TOMComplianceIssue[] {
|
||||
const issues: TOMComplianceIssue[] = []
|
||||
|
||||
// Build reverse mapping: SDM goal -> ControlCategories that cover it
|
||||
const sdmGoals = [
|
||||
'Verfuegbarkeit',
|
||||
'Integritaet',
|
||||
'Vertraulichkeit',
|
||||
'Nichtverkettung',
|
||||
'Intervenierbarkeit',
|
||||
'Transparenz',
|
||||
'Datenminimierung',
|
||||
] as const
|
||||
|
||||
const goalToCategoriesMap = new Map<string, ControlCategory[]>()
|
||||
for (const goal of sdmGoals) {
|
||||
goalToCategoriesMap.set(goal, [])
|
||||
}
|
||||
|
||||
// Build reverse lookup from SDM_CATEGORY_MAPPING
|
||||
for (const [category, goals] of Object.entries(SDM_CATEGORY_MAPPING)) {
|
||||
for (const goal of goals) {
|
||||
const existing = goalToCategoriesMap.get(goal)
|
||||
if (existing) {
|
||||
existing.push(category as ControlCategory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect implemented categories
|
||||
const implementedCategories = new Set<ControlCategory>()
|
||||
for (const tom of toms) {
|
||||
if (tom.implementationStatus !== 'IMPLEMENTED') continue
|
||||
const control = getControlById(tom.controlId)
|
||||
if (control) {
|
||||
implementedCategories.add(control.category)
|
||||
}
|
||||
}
|
||||
|
||||
// Check each SDM goal
|
||||
for (const goal of sdmGoals) {
|
||||
const coveringCategories = goalToCategoriesMap.get(goal) ?? []
|
||||
const hasCoverage = coveringCategories.some((cat) => implementedCategories.has(cat))
|
||||
|
||||
if (!hasCoverage) {
|
||||
issues.push(
|
||||
createIssue(
|
||||
`SDM-${goal}`,
|
||||
goal,
|
||||
'UNCOVERED_SDM_GOAL',
|
||||
'HIGH',
|
||||
`SDM-Gewaehrleistungsziel "${goal}" nicht abgedeckt`,
|
||||
`Das Gewaehrleistungsziel "${goal}" des Standard-Datenschutzmodells (SDM) ist durch keine umgesetzte Massnahme abgedeckt. Zugehoerige Kategorien (${coveringCategories.join(', ')}) haben keine IMPLEMENTED Controls. Das SDM ist die anerkannte Methodik zur Umsetzung der DSGVO-Anforderungen.`,
|
||||
`Setzen Sie mindestens eine Massnahme aus den Kategorien ${coveringCategories.join(', ')} um, um das SDM-Ziel "${goal}" abzudecken.`
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
/**
|
||||
* Check 10: HIGH_RISK_WITHOUT_MEASURES (CRITICAL)
|
||||
* Protection level VERY_HIGH but < 50% of REQUIRED controls implemented.
|
||||
*/
|
||||
function checkHighRiskWithoutMeasures(toms: DerivedTOM[], riskProfile: RiskProfile | null): TOMComplianceIssue | null {
|
||||
if (!riskProfile || riskProfile.protectionLevel !== 'VERY_HIGH') return null
|
||||
|
||||
const requiredToms = toms.filter((t) => t.applicability === 'REQUIRED')
|
||||
if (requiredToms.length === 0) return null
|
||||
|
||||
const implementedCount = requiredToms.filter((t) => t.implementationStatus === 'IMPLEMENTED').length
|
||||
const implementationRate = implementedCount / requiredToms.length
|
||||
|
||||
if (implementationRate < 0.5) {
|
||||
const percentage = Math.round(implementationRate * 100)
|
||||
return createIssue(
|
||||
'RISK-PROFILE',
|
||||
'Risikoprofil VERY_HIGH',
|
||||
'HIGH_RISK_WITHOUT_MEASURES',
|
||||
'CRITICAL',
|
||||
'Sehr hoher Schutzbedarf bei niedriger Umsetzungsrate',
|
||||
`Der Schutzbedarf ist als VERY_HIGH eingestuft, aber nur ${implementedCount} von ${requiredToms.length} Pflichtmassnahmen (${percentage}%) sind umgesetzt. Bei sehr hohem Schutzbedarf muessen mindestens 50% der Pflichtmassnahmen implementiert sein, um ein angemessenes Schutzniveau gemaess Art. 32 DSGVO zu gewaehrleisten.`,
|
||||
'Priorisieren Sie die Umsetzung der verbleibenden Pflichtmassnahmen. Beginnen Sie mit CRITICAL- und HIGH-Priority Controls. Erwaeegen Sie einen Umsetzungsplan mit klaren Meilensteinen.'
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPLIANCE CHECK
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Fuehrt einen vollstaendigen Compliance-Check ueber alle TOMs durch.
|
||||
*
|
||||
* @param state - Der vollstaendige TOMGeneratorState
|
||||
* @returns TOMComplianceCheckResult mit Issues, Score und Statistiken
|
||||
*/
|
||||
export function runTOMComplianceCheck(state: TOMGeneratorState): TOMComplianceCheckResult {
|
||||
// Reset counter for deterministic IDs within a single check run
|
||||
issueCounter = 0
|
||||
|
||||
const issues: TOMComplianceIssue[] = []
|
||||
|
||||
// Filter to applicable TOMs only (REQUIRED or RECOMMENDED, exclude NOT_APPLICABLE)
|
||||
const applicableTOMs = state.derivedTOMs.filter(
|
||||
(tom) => tom.applicability === 'REQUIRED' || tom.applicability === 'RECOMMENDED'
|
||||
)
|
||||
|
||||
// Run per-TOM checks (1-3, 11) on each applicable TOM
|
||||
for (const tom of applicableTOMs) {
|
||||
const perTomChecks = [
|
||||
checkMissingResponsible(tom),
|
||||
checkOverdueReview(tom),
|
||||
checkMissingEvidence(tom),
|
||||
checkStaleNotImplemented(tom, state),
|
||||
]
|
||||
|
||||
for (const issue of perTomChecks) {
|
||||
if (issue !== null) {
|
||||
issues.push(issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run aggregate checks (4-10)
|
||||
issues.push(...checkIncompleteCategory(applicableTOMs))
|
||||
|
||||
const aggregateChecks = [
|
||||
checkNoEncryption(applicableTOMs),
|
||||
checkNoPseudonymization(applicableTOMs, state.dataProfile),
|
||||
checkMissingAvailability(applicableTOMs, state),
|
||||
checkNoReviewProcess(applicableTOMs),
|
||||
checkHighRiskWithoutMeasures(applicableTOMs, state.riskProfile),
|
||||
]
|
||||
|
||||
for (const issue of aggregateChecks) {
|
||||
if (issue !== null) {
|
||||
issues.push(issue)
|
||||
}
|
||||
}
|
||||
|
||||
issues.push(...checkUncoveredSDMGoal(applicableTOMs))
|
||||
|
||||
// Calculate score
|
||||
const bySeverity: Record<TOMComplianceIssueSeverity, number> = {
|
||||
LOW: 0,
|
||||
MEDIUM: 0,
|
||||
HIGH: 0,
|
||||
CRITICAL: 0,
|
||||
}
|
||||
|
||||
for (const issue of issues) {
|
||||
bySeverity[issue.severity]++
|
||||
}
|
||||
|
||||
const rawScore =
|
||||
100 -
|
||||
(bySeverity.CRITICAL * 15 +
|
||||
bySeverity.HIGH * 10 +
|
||||
bySeverity.MEDIUM * 5 +
|
||||
bySeverity.LOW * 2)
|
||||
|
||||
const score = Math.max(0, rawScore)
|
||||
|
||||
// Calculate pass/fail per TOM
|
||||
const failedControlIds = new Set(
|
||||
issues.filter((i) => !i.controlId.startsWith('SDM-') && i.controlId !== 'RISK-PROFILE').map((i) => i.controlId)
|
||||
)
|
||||
const totalTOMs = applicableTOMs.length
|
||||
const failedCount = failedControlIds.size
|
||||
const passedCount = Math.max(0, totalTOMs - failedCount)
|
||||
|
||||
return {
|
||||
issues,
|
||||
score,
|
||||
stats: {
|
||||
total: totalTOMs,
|
||||
passed: passedCount,
|
||||
failed: failedCount,
|
||||
bySeverity,
|
||||
},
|
||||
}
|
||||
}
|
||||
906
admin-compliance/lib/sdk/tom-document.ts
Normal file
906
admin-compliance/lib/sdk/tom-document.ts
Normal file
@@ -0,0 +1,906 @@
|
||||
// =============================================================================
|
||||
// TOM Module - TOM-Dokumentation Document Generator
|
||||
// Generates a printable, audit-ready HTML document according to DSGVO Art. 32
|
||||
// =============================================================================
|
||||
|
||||
import type {
|
||||
TOMGeneratorState,
|
||||
DerivedTOM,
|
||||
CompanyProfile,
|
||||
RiskProfile,
|
||||
ControlCategory,
|
||||
} from './tom-generator/types'
|
||||
|
||||
import { SDM_CATEGORY_MAPPING } from './tom-generator/types'
|
||||
|
||||
import {
|
||||
getControlById,
|
||||
getControlsByCategory,
|
||||
getAllCategories,
|
||||
getCategoryMetadata,
|
||||
} from './tom-generator/controls/loader'
|
||||
|
||||
import type { TOMComplianceCheckResult, TOMComplianceIssueSeverity } from './tom-compliance'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface TOMDocumentOrgHeader {
|
||||
organizationName: string
|
||||
industry: string
|
||||
dpoName: string
|
||||
dpoContact: string
|
||||
responsiblePerson: string
|
||||
itSecurityContact: string
|
||||
locations: string[]
|
||||
employeeCount: string
|
||||
documentVersion: string
|
||||
lastReviewDate: string
|
||||
nextReviewDate: string
|
||||
reviewInterval: string
|
||||
}
|
||||
|
||||
export interface TOMDocumentRevision {
|
||||
version: string
|
||||
date: string
|
||||
author: string
|
||||
changes: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DEFAULTS
|
||||
// =============================================================================
|
||||
|
||||
export function createDefaultTOMDocumentOrgHeader(): TOMDocumentOrgHeader {
|
||||
const now = new Date()
|
||||
const nextYear = new Date()
|
||||
nextYear.setFullYear(nextYear.getFullYear() + 1)
|
||||
|
||||
return {
|
||||
organizationName: '',
|
||||
industry: '',
|
||||
dpoName: '',
|
||||
dpoContact: '',
|
||||
responsiblePerson: '',
|
||||
itSecurityContact: '',
|
||||
locations: [],
|
||||
employeeCount: '',
|
||||
documentVersion: '1.0',
|
||||
lastReviewDate: now.toISOString().split('T')[0],
|
||||
nextReviewDate: nextYear.toISOString().split('T')[0],
|
||||
reviewInterval: 'Jaehrlich',
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SEVERITY LABELS (for Compliance Status section)
|
||||
// =============================================================================
|
||||
|
||||
const SEVERITY_LABELS_DE: Record<TOMComplianceIssueSeverity, string> = {
|
||||
CRITICAL: 'Kritisch',
|
||||
HIGH: 'Hoch',
|
||||
MEDIUM: 'Mittel',
|
||||
LOW: 'Niedrig',
|
||||
}
|
||||
|
||||
const SEVERITY_COLORS: Record<TOMComplianceIssueSeverity, string> = {
|
||||
CRITICAL: '#dc2626',
|
||||
HIGH: '#ea580c',
|
||||
MEDIUM: '#d97706',
|
||||
LOW: '#6b7280',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CATEGORY LABELS (German)
|
||||
// =============================================================================
|
||||
|
||||
const CATEGORY_LABELS_DE: Record<ControlCategory, string> = {
|
||||
ACCESS_CONTROL: 'Zutrittskontrolle',
|
||||
ADMISSION_CONTROL: 'Zugangskontrolle',
|
||||
ACCESS_AUTHORIZATION: 'Zugriffskontrolle',
|
||||
TRANSFER_CONTROL: 'Weitergabekontrolle',
|
||||
INPUT_CONTROL: 'Eingabekontrolle',
|
||||
ORDER_CONTROL: 'Auftragskontrolle',
|
||||
AVAILABILITY: 'Verfuegbarkeit',
|
||||
SEPARATION: 'Trennbarkeit',
|
||||
ENCRYPTION: 'Verschluesselung',
|
||||
PSEUDONYMIZATION: 'Pseudonymisierung',
|
||||
RESILIENCE: 'Belastbarkeit',
|
||||
RECOVERY: 'Wiederherstellbarkeit',
|
||||
REVIEW: 'Ueberpruefung & Bewertung',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STATUS & APPLICABILITY LABELS
|
||||
// =============================================================================
|
||||
|
||||
const STATUS_LABELS_DE: Record<string, string> = {
|
||||
IMPLEMENTED: 'Umgesetzt',
|
||||
PARTIAL: 'Teilweise umgesetzt',
|
||||
NOT_IMPLEMENTED: 'Nicht umgesetzt',
|
||||
}
|
||||
|
||||
const STATUS_BADGE_CLASSES: Record<string, string> = {
|
||||
IMPLEMENTED: 'badge-active',
|
||||
PARTIAL: 'badge-review',
|
||||
NOT_IMPLEMENTED: 'badge-critical',
|
||||
}
|
||||
|
||||
const APPLICABILITY_LABELS_DE: Record<string, string> = {
|
||||
REQUIRED: 'Erforderlich',
|
||||
RECOMMENDED: 'Empfohlen',
|
||||
OPTIONAL: 'Optional',
|
||||
NOT_APPLICABLE: 'Nicht anwendbar',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HTML DOCUMENT BUILDER
|
||||
// =============================================================================
|
||||
|
||||
export function buildTOMDocumentHtml(
|
||||
derivedTOMs: DerivedTOM[],
|
||||
orgHeader: TOMDocumentOrgHeader,
|
||||
companyProfile: CompanyProfile | null,
|
||||
riskProfile: RiskProfile | null,
|
||||
complianceResult: TOMComplianceCheckResult | null,
|
||||
revisions: TOMDocumentRevision[]
|
||||
): string {
|
||||
const today = new Date().toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
|
||||
const orgName = orgHeader.organizationName || 'Organisation'
|
||||
|
||||
// Filter out NOT_APPLICABLE TOMs for display
|
||||
const applicableTOMs = derivedTOMs.filter(t => t.applicability !== 'NOT_APPLICABLE')
|
||||
|
||||
// Group TOMs by category via control library lookup
|
||||
const tomsByCategory = new Map<ControlCategory, DerivedTOM[]>()
|
||||
for (const tom of applicableTOMs) {
|
||||
const control = getControlById(tom.controlId)
|
||||
const cat = control?.category || 'REVIEW'
|
||||
if (!tomsByCategory.has(cat)) tomsByCategory.set(cat, [])
|
||||
tomsByCategory.get(cat)!.push(tom)
|
||||
}
|
||||
|
||||
// Build role map: role/department → list of control codes
|
||||
const roleMap = new Map<string, string[]>()
|
||||
for (const tom of applicableTOMs) {
|
||||
const role = tom.responsiblePerson || tom.responsibleDepartment || 'Nicht zugewiesen'
|
||||
if (!roleMap.has(role)) roleMap.set(role, [])
|
||||
const control = getControlById(tom.controlId)
|
||||
roleMap.get(role)!.push(control?.code || tom.controlId)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HTML Template
|
||||
// =========================================================================
|
||||
|
||||
let html = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>TOM-Dokumentation — ${escHtml(orgName)}</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 20mm 18mm 22mm 18mm; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
font-size: 10pt;
|
||||
line-height: 1.5;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* Cover */
|
||||
.cover {
|
||||
min-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
page-break-after: always;
|
||||
}
|
||||
.cover h1 {
|
||||
font-size: 28pt;
|
||||
color: #5b21b6;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.cover .subtitle {
|
||||
font-size: 14pt;
|
||||
color: #7c3aed;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.cover .org-info {
|
||||
background: #f5f3ff;
|
||||
border: 1px solid #ddd6fe;
|
||||
border-radius: 8px;
|
||||
padding: 24px 40px;
|
||||
text-align: left;
|
||||
width: 400px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.cover .org-info div { margin-bottom: 6px; }
|
||||
.cover .org-info .label { font-weight: 600; color: #5b21b6; display: inline-block; min-width: 160px; }
|
||||
.cover .legal-ref {
|
||||
font-size: 9pt;
|
||||
color: #64748b;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* TOC */
|
||||
.toc {
|
||||
page-break-after: always;
|
||||
padding-top: 40px;
|
||||
}
|
||||
.toc h2 {
|
||||
font-size: 18pt;
|
||||
color: #5b21b6;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #5b21b6;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.toc-entry {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px dotted #cbd5e1;
|
||||
font-size: 10pt;
|
||||
}
|
||||
.toc-entry .toc-num { font-weight: 600; color: #5b21b6; min-width: 40px; }
|
||||
|
||||
/* Sections */
|
||||
.section {
|
||||
page-break-inside: avoid;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.section-header {
|
||||
font-size: 14pt;
|
||||
color: #5b21b6;
|
||||
font-weight: 700;
|
||||
margin: 30px 0 12px 0;
|
||||
border-bottom: 2px solid #ddd6fe;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.section-body { margin-bottom: 16px; }
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0 16px 0;
|
||||
font-size: 9pt;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
background: #f5f3ff;
|
||||
color: #5b21b6;
|
||||
font-weight: 600;
|
||||
font-size: 8.5pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
tr:nth-child(even) td { background: #faf5ff; }
|
||||
|
||||
/* Detail cards */
|
||||
.policy-detail {
|
||||
page-break-inside: avoid;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.policy-detail-header {
|
||||
background: #f5f3ff;
|
||||
padding: 8px 12px;
|
||||
font-weight: 700;
|
||||
color: #5b21b6;
|
||||
border-bottom: 1px solid #ddd6fe;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.policy-detail-body { padding: 0; }
|
||||
.policy-detail-body table { margin: 0; }
|
||||
.policy-detail-body th { width: 200px; }
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 8pt;
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-active { background: #dcfce7; color: #166534; }
|
||||
.badge-draft { background: #f3f4f6; color: #374151; }
|
||||
.badge-review { background: #fef9c3; color: #854d0e; }
|
||||
.badge-critical { background: #fecaca; color: #991b1b; }
|
||||
.badge-high { background: #fed7aa; color: #9a3412; }
|
||||
.badge-medium { background: #fef3c7; color: #92400e; }
|
||||
.badge-low { background: #f3f4f6; color: #4b5563; }
|
||||
|
||||
/* Principles */
|
||||
.principle {
|
||||
margin-bottom: 10px;
|
||||
padding-left: 20px;
|
||||
position: relative;
|
||||
}
|
||||
.principle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #7c3aed;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.principle strong { color: #5b21b6; }
|
||||
|
||||
/* Score */
|
||||
.score-box {
|
||||
display: inline-block;
|
||||
padding: 4px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 18pt;
|
||||
font-weight: 700;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.score-excellent { background: #dcfce7; color: #166534; }
|
||||
.score-good { background: #dbeafe; color: #1e40af; }
|
||||
.score-needs-work { background: #fef3c7; color: #92400e; }
|
||||
.score-poor { background: #fecaca; color: #991b1b; }
|
||||
|
||||
/* Footer */
|
||||
.page-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 8px 18mm;
|
||||
font-size: 7.5pt;
|
||||
color: #94a3b8;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
/* Print */
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.no-print { display: none !important; }
|
||||
.page-break { page-break-after: always; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 0: Cover Page
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="cover">
|
||||
<h1>TOM-Dokumentation</h1>
|
||||
<div class="subtitle">Technische und Organisatorische Massnahmen gemaess Art. 32 DSGVO</div>
|
||||
<div class="org-info">
|
||||
<div><span class="label">Organisation:</span> ${escHtml(orgName)}</div>
|
||||
${orgHeader.industry ? `<div><span class="label">Branche:</span> ${escHtml(orgHeader.industry)}</div>` : ''}
|
||||
${orgHeader.dpoName ? `<div><span class="label">DSB:</span> ${escHtml(orgHeader.dpoName)}</div>` : ''}
|
||||
${orgHeader.dpoContact ? `<div><span class="label">DSB-Kontakt:</span> ${escHtml(orgHeader.dpoContact)}</div>` : ''}
|
||||
${orgHeader.responsiblePerson ? `<div><span class="label">Verantwortlicher:</span> ${escHtml(orgHeader.responsiblePerson)}</div>` : ''}
|
||||
${orgHeader.itSecurityContact ? `<div><span class="label">IT-Sicherheit:</span> ${escHtml(orgHeader.itSecurityContact)}</div>` : ''}
|
||||
${orgHeader.employeeCount ? `<div><span class="label">Mitarbeiter:</span> ${escHtml(orgHeader.employeeCount)}</div>` : ''}
|
||||
${orgHeader.locations.length > 0 ? `<div><span class="label">Standorte:</span> ${escHtml(orgHeader.locations.join(', '))}</div>` : ''}
|
||||
</div>
|
||||
<div class="legal-ref">
|
||||
Version ${escHtml(orgHeader.documentVersion)} | Stand: ${today}<br/>
|
||||
Letzte Pruefung: ${formatDateDE(orgHeader.lastReviewDate)} | Naechste Pruefung: ${formatDateDE(orgHeader.nextReviewDate)}<br/>
|
||||
Pruefintervall: ${escHtml(orgHeader.reviewInterval)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Table of Contents
|
||||
// =========================================================================
|
||||
const sections = [
|
||||
'Ziel und Zweck',
|
||||
'Geltungsbereich',
|
||||
'Grundprinzipien Art. 32',
|
||||
'Schutzbedarf und Risikoanalyse',
|
||||
'Massnahmen-Uebersicht',
|
||||
'Detaillierte Massnahmen',
|
||||
'SDM Gewaehrleistungsziele',
|
||||
'Verantwortlichkeiten',
|
||||
'Pruef- und Revisionszyklus',
|
||||
'Compliance-Status',
|
||||
'Aenderungshistorie',
|
||||
]
|
||||
|
||||
html += `
|
||||
<div class="toc">
|
||||
<h2>Inhaltsverzeichnis</h2>
|
||||
${sections.map((s, i) => `<div class="toc-entry"><span><span class="toc-num">${i + 1}.</span> ${escHtml(s)}</span></div>`).join('\n ')}
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 1: Ziel und Zweck
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">1. Ziel und Zweck</div>
|
||||
<div class="section-body">
|
||||
<p>Diese TOM-Dokumentation beschreibt die technischen und organisatorischen Massnahmen
|
||||
zum Schutz personenbezogener Daten bei <strong>${escHtml(orgName)}</strong>. Sie dient
|
||||
der Umsetzung folgender DSGVO-Anforderungen:</p>
|
||||
<table>
|
||||
<tr><th>Rechtsgrundlage</th><th>Inhalt</th></tr>
|
||||
<tr><td><strong>Art. 32 Abs. 1 lit. a DSGVO</strong></td><td>Pseudonymisierung und Verschluesselung personenbezogener Daten</td></tr>
|
||||
<tr><td><strong>Art. 32 Abs. 1 lit. b DSGVO</strong></td><td>Faehigkeit, die Vertraulichkeit, Integritaet, Verfuegbarkeit und Belastbarkeit der Systeme und Dienste im Zusammenhang mit der Verarbeitung auf Dauer sicherzustellen</td></tr>
|
||||
<tr><td><strong>Art. 32 Abs. 1 lit. c DSGVO</strong></td><td>Faehigkeit, die Verfuegbarkeit der personenbezogenen Daten und den Zugang zu ihnen bei einem physischen oder technischen Zwischenfall rasch wiederherzustellen</td></tr>
|
||||
<tr><td><strong>Art. 32 Abs. 1 lit. d DSGVO</strong></td><td>Verfahren zur regelmaessigen Ueberpruefung, Bewertung und Evaluierung der Wirksamkeit der technischen und organisatorischen Massnahmen</td></tr>
|
||||
</table>
|
||||
<p>Die TOM-Dokumentation ist fester Bestandteil des Datenschutz-Managementsystems und wird
|
||||
regelmaessig ueberprueft und aktualisiert.</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 2: Geltungsbereich
|
||||
// =========================================================================
|
||||
const industryInfo = companyProfile?.industry || orgHeader.industry || ''
|
||||
const hostingInfo = companyProfile ? `Unternehmen: ${escHtml(companyProfile.name || orgName)}, Groesse: ${escHtml(companyProfile.size || '-')}` : ''
|
||||
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">2. Geltungsbereich</div>
|
||||
<div class="section-body">
|
||||
<p>Diese TOM-Dokumentation gilt fuer alle IT-Systeme, Anwendungen und Verarbeitungsprozesse
|
||||
von <strong>${escHtml(orgName)}</strong>${industryInfo ? ` (Branche: ${escHtml(industryInfo)})` : ''}.</p>
|
||||
${hostingInfo ? `<p>${hostingInfo}</p>` : ''}
|
||||
${orgHeader.locations.length > 0 ? `<p>Standorte: ${escHtml(orgHeader.locations.join(', '))}</p>` : ''}
|
||||
<p>Die dokumentierten Massnahmen stammen aus zwei Quellen:</p>
|
||||
<ul style="margin: 8px 0 8px 24px;">
|
||||
<li><strong>Embedded Library (TOM-xxx):</strong> Integrierte Kontrollbibliothek mit spezifischen Massnahmen fuer Art. 32 DSGVO</li>
|
||||
<li><strong>Canonical Control Library (CP-CLIB):</strong> Uebergreifende Kontrollbibliothek mit framework-uebergreifenden Massnahmen</li>
|
||||
</ul>
|
||||
<p>Insgesamt umfasst dieses Dokument <strong>${applicableTOMs.length}</strong> anwendbare Massnahmen
|
||||
in <strong>${tomsByCategory.size}</strong> Kategorien.</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 3: Grundprinzipien Art. 32
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">3. Grundprinzipien Art. 32</div>
|
||||
<div class="section-body">
|
||||
<div class="principle"><strong>Vertraulichkeit:</strong> Schutz personenbezogener Daten vor unbefugter Kenntnisnahme durch Zutrittskontrolle, Zugangskontrolle, Zugriffskontrolle und Verschluesselung (Art. 32 Abs. 1 lit. b DSGVO).</div>
|
||||
<div class="principle"><strong>Integritaet:</strong> Sicherstellung, dass personenbezogene Daten nicht unbefugt oder unbeabsichtigt veraendert werden koennen, durch Eingabekontrolle, Weitergabekontrolle und Protokollierung (Art. 32 Abs. 1 lit. b DSGVO).</div>
|
||||
<div class="principle"><strong>Verfuegbarkeit und Belastbarkeit:</strong> Gewaehrleistung, dass Systeme und Dienste bei Lastspitzen und Stoerungen zuverlaessig funktionieren, durch Backup, Redundanz und Disaster Recovery (Art. 32 Abs. 1 lit. b DSGVO).</div>
|
||||
<div class="principle"><strong>Rasche Wiederherstellbarkeit:</strong> Faehigkeit, nach einem physischen oder technischen Zwischenfall Daten und Systeme schnell wiederherzustellen, durch getestete Recovery-Prozesse (Art. 32 Abs. 1 lit. c DSGVO).</div>
|
||||
<div class="principle"><strong>Regelmaessige Wirksamkeitspruefung:</strong> Verfahren zur regelmaessigen Ueberpruefung, Bewertung und Evaluierung der Wirksamkeit aller technischen und organisatorischen Massnahmen (Art. 32 Abs. 1 lit. d DSGVO).</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 4: Schutzbedarf und Risikoanalyse
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">4. Schutzbedarf und Risikoanalyse</div>
|
||||
<div class="section-body">
|
||||
`
|
||||
if (riskProfile) {
|
||||
html += ` <p>Die folgende Schutzbedarfsanalyse bildet die Grundlage fuer die Auswahl und Priorisierung
|
||||
der technischen und organisatorischen Massnahmen:</p>
|
||||
<table>
|
||||
<tr><th>Kriterium</th><th>Bewertung</th></tr>
|
||||
<tr><td>Vertraulichkeit</td><td>${riskProfile.ciaAssessment.confidentiality}/5</td></tr>
|
||||
<tr><td>Integritaet</td><td>${riskProfile.ciaAssessment.integrity}/5</td></tr>
|
||||
<tr><td>Verfuegbarkeit</td><td>${riskProfile.ciaAssessment.availability}/5</td></tr>
|
||||
<tr><td>Schutzniveau</td><td><strong>${escHtml(riskProfile.protectionLevel)}</strong></td></tr>
|
||||
<tr><td>DSFA-Pflicht</td><td>${riskProfile.dsfaRequired ? 'Ja' : 'Nein'}</td></tr>
|
||||
${riskProfile.specialRisks.length > 0 ? `<tr><td>Spezialrisiken</td><td>${escHtml(riskProfile.specialRisks.join(', '))}</td></tr>` : ''}
|
||||
${riskProfile.regulatoryRequirements.length > 0 ? `<tr><td>Regulatorische Anforderungen</td><td>${escHtml(riskProfile.regulatoryRequirements.join(', '))}</td></tr>` : ''}
|
||||
</table>
|
||||
`
|
||||
} else {
|
||||
html += ` <p><em>Die Schutzbedarfsanalyse wurde noch nicht durchgefuehrt. Fuehren Sie den
|
||||
Risiko-Wizard im TOM-Generator durch, um den Schutzbedarf zu ermitteln.</em></p>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 5: Massnahmen-Uebersicht
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section page-break">
|
||||
<div class="section-header">5. Massnahmen-Uebersicht</div>
|
||||
<div class="section-body">
|
||||
<p>Die folgende Tabelle zeigt eine Uebersicht aller ${applicableTOMs.length} anwendbaren Massnahmen
|
||||
nach Kategorie:</p>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Kategorie</th>
|
||||
<th>Gesamt</th>
|
||||
<th>Umgesetzt</th>
|
||||
<th>Teilweise</th>
|
||||
<th>Offen</th>
|
||||
</tr>
|
||||
`
|
||||
const allCategories = getAllCategories()
|
||||
for (const cat of allCategories) {
|
||||
const tomsInCat = tomsByCategory.get(cat)
|
||||
if (!tomsInCat || tomsInCat.length === 0) continue
|
||||
|
||||
const implemented = tomsInCat.filter(t => t.implementationStatus === 'IMPLEMENTED').length
|
||||
const partial = tomsInCat.filter(t => t.implementationStatus === 'PARTIAL').length
|
||||
const notImpl = tomsInCat.filter(t => t.implementationStatus === 'NOT_IMPLEMENTED').length
|
||||
const catLabel = CATEGORY_LABELS_DE[cat] || cat
|
||||
|
||||
html += ` <tr>
|
||||
<td>${escHtml(catLabel)}</td>
|
||||
<td>${tomsInCat.length}</td>
|
||||
<td>${implemented}</td>
|
||||
<td>${partial}</td>
|
||||
<td>${notImpl}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 6: Detaillierte Massnahmen
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">6. Detaillierte Massnahmen</div>
|
||||
<div class="section-body">
|
||||
`
|
||||
|
||||
for (const cat of allCategories) {
|
||||
const tomsInCat = tomsByCategory.get(cat)
|
||||
if (!tomsInCat || tomsInCat.length === 0) continue
|
||||
|
||||
const catLabel = CATEGORY_LABELS_DE[cat] || cat
|
||||
const catMeta = getCategoryMetadata(cat)
|
||||
const gdprRef = catMeta?.gdprReference || ''
|
||||
|
||||
html += ` <h3 style="color: #5b21b6; margin: 20px 0 10px 0; font-size: 11pt;">${escHtml(catLabel)}${gdprRef ? ` <span style="font-weight: 400; font-size: 9pt; color: #64748b;">(${escHtml(gdprRef)})</span>` : ''}</h3>
|
||||
`
|
||||
|
||||
// Sort TOMs by control code
|
||||
const sortedTOMs = [...tomsInCat].sort((a, b) => {
|
||||
const codeA = getControlById(a.controlId)?.code || a.controlId
|
||||
const codeB = getControlById(b.controlId)?.code || b.controlId
|
||||
return codeA.localeCompare(codeB)
|
||||
})
|
||||
|
||||
for (const tom of sortedTOMs) {
|
||||
const control = getControlById(tom.controlId)
|
||||
const code = control?.code || tom.controlId
|
||||
const nameDE = control?.name?.de || tom.name
|
||||
const descDE = control?.description?.de || tom.description
|
||||
const typeLabel = control?.type === 'TECHNICAL' ? 'Technisch' : control?.type === 'ORGANIZATIONAL' ? 'Organisatorisch' : '-'
|
||||
const statusLabel = STATUS_LABELS_DE[tom.implementationStatus] || tom.implementationStatus
|
||||
const statusBadge = STATUS_BADGE_CLASSES[tom.implementationStatus] || 'badge-draft'
|
||||
const applicabilityLabel = APPLICABILITY_LABELS_DE[tom.applicability] || tom.applicability
|
||||
const responsible = [tom.responsiblePerson, tom.responsibleDepartment].filter(s => s && s.trim()).join(' / ') || '-'
|
||||
const implDate = tom.implementationDate ? formatDateDE(typeof tom.implementationDate === 'string' ? tom.implementationDate : tom.implementationDate.toISOString()) : '-'
|
||||
const reviewDate = tom.reviewDate ? formatDateDE(typeof tom.reviewDate === 'string' ? tom.reviewDate : tom.reviewDate.toISOString()) : '-'
|
||||
|
||||
// Evidence
|
||||
const evidenceInfo = tom.linkedEvidence.length > 0
|
||||
? tom.linkedEvidence.join(', ')
|
||||
: tom.evidenceGaps.length > 0
|
||||
? `<em style="color: #d97706;">Fehlend: ${escHtml(tom.evidenceGaps.join(', '))}</em>`
|
||||
: '-'
|
||||
|
||||
// Framework mappings
|
||||
let mappingsHtml = '-'
|
||||
if (control?.mappings && control.mappings.length > 0) {
|
||||
mappingsHtml = control.mappings.map(m => `${escHtml(m.framework)}: ${escHtml(m.reference)}`).join('<br/>')
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="policy-detail">
|
||||
<div class="policy-detail-header">
|
||||
<span>${escHtml(code)} — ${escHtml(nameDE)}</span>
|
||||
<span class="badge ${statusBadge}">${escHtml(statusLabel)}</span>
|
||||
</div>
|
||||
<div class="policy-detail-body">
|
||||
<table>
|
||||
<tr><th>Beschreibung</th><td>${escHtml(descDE)}</td></tr>
|
||||
<tr><th>Massnahmentyp</th><td>${escHtml(typeLabel)}</td></tr>
|
||||
<tr><th>Anwendbarkeit</th><td>${escHtml(applicabilityLabel)}${tom.applicabilityReason ? ` — ${escHtml(tom.applicabilityReason)}` : ''}</td></tr>
|
||||
<tr><th>Umsetzungsstatus</th><td><span class="badge ${statusBadge}">${escHtml(statusLabel)}</span></td></tr>
|
||||
<tr><th>Verantwortlich</th><td>${escHtml(responsible)}</td></tr>
|
||||
<tr><th>Umsetzungsdatum</th><td>${implDate}</td></tr>
|
||||
<tr><th>Naechste Pruefung</th><td>${reviewDate}</td></tr>
|
||||
<tr><th>Evidence</th><td>${evidenceInfo}</td></tr>
|
||||
<tr><th>Framework-Mappings</th><td>${mappingsHtml}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 7: SDM Gewaehrleistungsziele
|
||||
// =========================================================================
|
||||
const sdmGoals: Array<{ goal: string; categories: ControlCategory[] }> = []
|
||||
const allSDMGoals = [
|
||||
'Verfuegbarkeit',
|
||||
'Integritaet',
|
||||
'Vertraulichkeit',
|
||||
'Nichtverkettung',
|
||||
'Intervenierbarkeit',
|
||||
'Transparenz',
|
||||
'Datenminimierung',
|
||||
] as const
|
||||
|
||||
for (const goal of allSDMGoals) {
|
||||
const cats: ControlCategory[] = []
|
||||
for (const [cat, goals] of Object.entries(SDM_CATEGORY_MAPPING)) {
|
||||
if (goals.includes(goal)) {
|
||||
cats.push(cat as ControlCategory)
|
||||
}
|
||||
}
|
||||
sdmGoals.push({ goal, categories: cats })
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="section page-break">
|
||||
<div class="section-header">7. SDM Gewaehrleistungsziele</div>
|
||||
<div class="section-body">
|
||||
<p>Die folgende Tabelle zeigt die Abdeckung der sieben Gewaehrleistungsziele des
|
||||
Standard-Datenschutzmodells (SDM) durch die implementierten Massnahmen:</p>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Gewaehrleistungsziel</th>
|
||||
<th>Abgedeckt</th>
|
||||
<th>Gesamt</th>
|
||||
<th>Abdeckung (%)</th>
|
||||
</tr>
|
||||
`
|
||||
for (const { goal, categories } of sdmGoals) {
|
||||
let totalInGoal = 0
|
||||
let implementedInGoal = 0
|
||||
for (const cat of categories) {
|
||||
const tomsInCat = tomsByCategory.get(cat) || []
|
||||
totalInGoal += tomsInCat.length
|
||||
implementedInGoal += tomsInCat.filter(t => t.implementationStatus === 'IMPLEMENTED').length
|
||||
}
|
||||
const percentage = totalInGoal > 0 ? Math.round((implementedInGoal / totalInGoal) * 100) : 0
|
||||
|
||||
html += ` <tr>
|
||||
<td>${escHtml(goal)}</td>
|
||||
<td>${implementedInGoal}</td>
|
||||
<td>${totalInGoal}</td>
|
||||
<td>${percentage}%</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 8: Verantwortlichkeiten
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">8. Verantwortlichkeiten</div>
|
||||
<div class="section-body">
|
||||
<p>Die folgende Rollenmatrix zeigt, welche Personen oder Abteilungen fuer welche Massnahmen
|
||||
die Umsetzungsverantwortung tragen:</p>
|
||||
<table>
|
||||
<tr><th>Rolle / Verantwortlich</th><th>Massnahmen</th><th>Anzahl</th></tr>
|
||||
`
|
||||
for (const [role, controls] of roleMap.entries()) {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(role)}</td>
|
||||
<td>${controls.map(c => escHtml(c)).join(', ')}</td>
|
||||
<td>${controls.length}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 9: Pruef- und Revisionszyklus
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">9. Pruef- und Revisionszyklus</div>
|
||||
<div class="section-body">
|
||||
<table>
|
||||
<tr><th>Eigenschaft</th><th>Wert</th></tr>
|
||||
<tr><td>Aktuelles Pruefintervall</td><td>${escHtml(orgHeader.reviewInterval)}</td></tr>
|
||||
<tr><td>Letzte Pruefung</td><td>${formatDateDE(orgHeader.lastReviewDate)}</td></tr>
|
||||
<tr><td>Naechste Pruefung</td><td>${formatDateDE(orgHeader.nextReviewDate)}</td></tr>
|
||||
<tr><td>Aktuelle Version</td><td>${escHtml(orgHeader.documentVersion)}</td></tr>
|
||||
</table>
|
||||
<p style="margin-top: 8px;">Bei jeder Pruefung wird die TOM-Dokumentation auf folgende Punkte ueberprueft:</p>
|
||||
<ul style="margin: 8px 0 8px 24px;">
|
||||
<li>Vollstaendigkeit aller Massnahmen (neue Systeme oder Verarbeitungen erfasst?)</li>
|
||||
<li>Aktualitaet des Umsetzungsstatus (Aenderungen seit letzter Pruefung?)</li>
|
||||
<li>Wirksamkeit der technischen Massnahmen (Penetration-Tests, Audit-Ergebnisse)</li>
|
||||
<li>Angemessenheit der organisatorischen Massnahmen (Schulungen, Richtlinien aktuell?)</li>
|
||||
<li>Abdeckung aller SDM-Gewaehrleistungsziele</li>
|
||||
<li>Zuordnung von Verantwortlichkeiten zu allen Massnahmen</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 10: Compliance-Status
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section page-break">
|
||||
<div class="section-header">10. Compliance-Status</div>
|
||||
<div class="section-body">
|
||||
`
|
||||
if (complianceResult) {
|
||||
const scoreClass = complianceResult.score >= 90 ? 'score-excellent'
|
||||
: complianceResult.score >= 75 ? 'score-good'
|
||||
: complianceResult.score >= 50 ? 'score-needs-work'
|
||||
: 'score-poor'
|
||||
const scoreLabel = complianceResult.score >= 90 ? 'Ausgezeichnet'
|
||||
: complianceResult.score >= 75 ? 'Gut'
|
||||
: complianceResult.score >= 50 ? 'Verbesserungswuerdig'
|
||||
: 'Mangelhaft'
|
||||
|
||||
html += ` <p><span class="score-box ${scoreClass}">${complianceResult.score}/100</span> ${escHtml(scoreLabel)}</p>
|
||||
<table style="margin-top: 12px;">
|
||||
<tr><th>Kennzahl</th><th>Wert</th></tr>
|
||||
<tr><td>Gepruefte Massnahmen</td><td>${complianceResult.stats.total}</td></tr>
|
||||
<tr><td>Bestanden</td><td>${complianceResult.stats.passed}</td></tr>
|
||||
<tr><td>Beanstandungen</td><td>${complianceResult.stats.failed}</td></tr>
|
||||
</table>
|
||||
`
|
||||
if (complianceResult.issues.length > 0) {
|
||||
html += ` <p style="margin-top: 12px;"><strong>Befunde nach Schweregrad:</strong></p>
|
||||
<table>
|
||||
<tr><th>Schweregrad</th><th>Anzahl</th><th>Befunde</th></tr>
|
||||
`
|
||||
const severityOrder: TOMComplianceIssueSeverity[] = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
|
||||
for (const sev of severityOrder) {
|
||||
const count = complianceResult.stats.bySeverity[sev]
|
||||
if (count === 0) continue
|
||||
const issuesForSev = complianceResult.issues.filter(i => i.severity === sev)
|
||||
html += ` <tr>
|
||||
<td><span class="badge badge-${sev.toLowerCase()}" style="color: ${SEVERITY_COLORS[sev]}">${SEVERITY_LABELS_DE[sev]}</span></td>
|
||||
<td>${count}</td>
|
||||
<td>${issuesForSev.map(i => escHtml(i.title)).join('; ')}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
html += ` </table>
|
||||
`
|
||||
} else {
|
||||
html += ` <p style="margin-top: 8px;"><em>Keine Beanstandungen. Alle Massnahmen sind konform.</em></p>
|
||||
`
|
||||
}
|
||||
} else {
|
||||
html += ` <p><em>Compliance-Check wurde noch nicht ausgefuehrt. Fuehren Sie den Check im
|
||||
Export-Tab durch, um den Status in das Dokument aufzunehmen.</em></p>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Section 11: Aenderungshistorie
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="section">
|
||||
<div class="section-header">11. Aenderungshistorie</div>
|
||||
<div class="section-body">
|
||||
<table>
|
||||
<tr><th>Version</th><th>Datum</th><th>Autor</th><th>Aenderungen</th></tr>
|
||||
`
|
||||
if (revisions.length > 0) {
|
||||
for (const rev of revisions) {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(rev.version)}</td>
|
||||
<td>${formatDateDE(rev.date)}</td>
|
||||
<td>${escHtml(rev.author)}</td>
|
||||
<td>${escHtml(rev.changes)}</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
} else {
|
||||
html += ` <tr>
|
||||
<td>${escHtml(orgHeader.documentVersion)}</td>
|
||||
<td>${today}</td>
|
||||
<td>${escHtml(orgHeader.dpoName || orgHeader.responsiblePerson || '-')}</td>
|
||||
<td>Erstversion der TOM-Dokumentation</td>
|
||||
</tr>
|
||||
`
|
||||
}
|
||||
|
||||
html += ` </table>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// =========================================================================
|
||||
// Footer
|
||||
// =========================================================================
|
||||
html += `
|
||||
<div class="page-footer">
|
||||
<span>TOM-Dokumentation — ${escHtml(orgName)}</span>
|
||||
<span>Stand: ${today} | Version ${escHtml(orgHeader.documentVersion)}</span>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INTERNAL HELPERS
|
||||
// =============================================================================
|
||||
|
||||
function escHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function formatDateDE(dateStr: string | null | undefined): string {
|
||||
if (!dateStr) return '-'
|
||||
try {
|
||||
const date = new Date(dateStr)
|
||||
if (isNaN(date.getTime())) return '-'
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
} catch {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,7 @@ KONTROLLE:
|
||||
- Typ: ${control.type}
|
||||
|
||||
UNTERNEHMENSPROFIL:
|
||||
- Branche: ${companyProfile.industry}
|
||||
- Branche: ${Array.isArray(companyProfile.industry) ? companyProfile.industry.join(', ') : companyProfile.industry}
|
||||
- Größe: ${companyProfile.size}
|
||||
- Rolle: ${companyProfile.role}
|
||||
- Produkte/Services: ${companyProfile.products.join(', ')}
|
||||
@@ -177,7 +177,7 @@ CONTROL:
|
||||
- Type: ${control.type}
|
||||
|
||||
COMPANY PROFILE:
|
||||
- Industry: ${companyProfile.industry}
|
||||
- Industry: ${Array.isArray(companyProfile.industry) ? companyProfile.industry.join(', ') : companyProfile.industry}
|
||||
- Size: ${companyProfile.size}
|
||||
- Role: ${companyProfile.role}
|
||||
- Products/Services: ${companyProfile.products.join(', ')}
|
||||
@@ -240,7 +240,7 @@ export function getGapRecommendationsPrompt(
|
||||
return `Du bist ein Experte für Datenschutz-Compliance und erstellst Handlungsempfehlungen für TOM-Lücken.
|
||||
|
||||
UNTERNEHMEN:
|
||||
- Branche: ${companyProfile.industry}
|
||||
- Branche: ${Array.isArray(companyProfile.industry) ? companyProfile.industry.join(', ') : companyProfile.industry}
|
||||
- Größe: ${companyProfile.size}
|
||||
- Rolle: ${companyProfile.role}
|
||||
|
||||
@@ -279,7 +279,7 @@ Antworte im JSON-Format:
|
||||
return `You are a data protection compliance expert creating recommendations for TOM gaps.
|
||||
|
||||
COMPANY:
|
||||
- Industry: ${companyProfile.industry}
|
||||
- Industry: ${Array.isArray(companyProfile.industry) ? companyProfile.industry.join(', ') : companyProfile.industry}
|
||||
- Size: ${companyProfile.size}
|
||||
- Role: ${companyProfile.role}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user