Compare commits
145 Commits
feature/be
...
feat/dead-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3702f70754 | ||
|
|
78e47c96bd | ||
|
|
f96536ebbe | ||
|
|
c05a71163b | ||
|
|
71a4a3d7f3 | ||
|
|
5e7d5d0a18 | ||
|
|
391aab83e0 | ||
|
|
8ec8af4c2d | ||
|
|
8266c37911 | ||
|
|
baf2d8a550 | ||
|
|
04d78d5fcd | ||
|
|
c41607595e | ||
|
|
58f108b578 | ||
|
|
f7a5f9e1ed | ||
|
|
3f1444541f | ||
|
|
13f57c4519 | ||
|
|
3f2aff2389 | ||
|
|
3fb5b94905 | ||
|
|
c293d76e6b | ||
|
|
e0b3c54212 | ||
|
|
a83056b5e7 | ||
|
|
9f96061631 | ||
|
|
3f306fb6f0 | ||
|
|
9ec72ed681 | ||
|
|
a7fe32fb82 | ||
|
|
9ecd3b2d84 | ||
|
|
19d6437161 | ||
|
|
7d8e5667c9 | ||
|
|
feedeb052f | ||
|
|
92a47bf6f9 | ||
|
|
788172e869 | ||
|
|
91063f09b8 | ||
|
|
b00fe6cb73 | ||
|
|
d32ad81094 | ||
|
|
e3a1822883 | ||
|
|
083792dfd7 | ||
|
|
aabfd0aecd | ||
|
|
11f13b3f74 | ||
|
|
20fbfc197e | ||
|
|
b5d20a4c1d | ||
|
|
54add75eb0 | ||
|
|
ce27636b67 | ||
|
|
e58af8aa30 | ||
|
|
ada50f0466 | ||
|
|
c34f8528a7 | ||
|
|
ad6e6019e9 | ||
|
|
535d3d8c20 | ||
|
|
90d14eb546 | ||
|
|
0125199c76 | ||
|
|
cfd4fc347f | ||
|
|
2adbacf267 | ||
|
|
3180457f20 | ||
|
|
9d96330a54 | ||
|
|
c50e57fd85 | ||
|
|
af08f089df | ||
|
|
1fcd8244b1 | ||
|
|
e0c1d21879 | ||
|
|
2ade65431a | ||
|
|
c43d9da6d0 | ||
|
|
e04816cfe5 | ||
|
|
519ffdc8dc | ||
|
|
653fa07f57 | ||
|
|
87f2ce9692 | ||
|
|
8044ddb776 | ||
|
|
7907b3f25b | ||
|
|
9096aad693 | ||
|
|
9f2224efc4 | ||
|
|
ffae41237e | ||
|
|
1c1af4e38d | ||
|
|
92a730626d | ||
|
|
cc3a9a37dc | ||
|
|
e6ff76d0e1 | ||
|
|
554320770a | ||
|
|
eeb9931d87 | ||
|
|
76962a2831 | ||
|
|
4921d1c052 | ||
|
|
6571b580dc | ||
|
|
d5287f4bdd | ||
|
|
82a5a388b8 | ||
|
|
637eb012f5 | ||
|
|
2b818c6fb3 | ||
|
|
3a22a2fa52 | ||
|
|
375b34a0d8 | ||
|
|
ff9f5e849c | ||
|
|
2fb6b98bc5 | ||
|
|
1f45d6cca8 | ||
|
|
dca0c96f2a | ||
|
|
74927c6f66 | ||
|
|
ddcd89f26d | ||
|
|
5cb91e88d2 | ||
|
|
4ed39d2616 | ||
|
|
ef8284dff5 | ||
|
|
6c883fb12e | ||
|
|
f7b77fd504 | ||
|
|
ff775517a2 | ||
|
|
98a773c7cd | ||
|
|
528abc86ab | ||
|
|
be4d58009a | ||
|
|
e07e1de6c9 | ||
|
|
58e95d5e8e | ||
|
|
786bb409e4 | ||
|
|
3c4f7d900d | ||
|
|
aae07b7a9b | ||
|
|
911d872178 | ||
|
|
fc6a3306d4 | ||
|
|
ab6ba63108 | ||
|
|
769e8c12d5 | ||
|
|
7344e5806e | ||
|
|
32e121f2a3 | ||
|
|
07d470edee | ||
|
|
a84dccb339 | ||
|
|
1a2ae896fb | ||
|
|
d35b0bc78c | ||
|
|
ae008d7d25 | ||
|
|
6658776610 | ||
|
|
d2c94619d8 | ||
|
|
cc1c61947d | ||
|
|
0c2e03f294 | ||
|
|
a638d0e527 | ||
|
|
e613af1a7d | ||
|
|
7107a31496 | ||
|
|
b850368ec9 | ||
|
|
4fa0dd6f6d | ||
|
|
f39c7ca40c | ||
|
|
d571412657 | ||
|
|
10073f3ef0 | ||
|
|
883ef702ac | ||
|
|
4a91814bfc | ||
|
|
482e8574ad | ||
|
|
d9dcfb97ef | ||
|
|
3320ef94fc | ||
|
|
1dfea51919 | ||
|
|
559d7960a2 | ||
|
|
a101426dba | ||
|
|
f6b22820ce | ||
|
|
86588aff09 | ||
|
|
033fa52e5b | ||
|
|
005fb9d219 | ||
|
|
0c01f1c96c | ||
|
|
ffd256d420 | ||
|
|
d542dbbacd | ||
|
|
a3d0024d39 | ||
|
|
998d427c3c | ||
|
|
99f3180ffc | ||
|
|
2ec340c64b |
@@ -1,37 +1,76 @@
|
||||
# BreakPilot Compliance - DSGVO/AI-Act SDK Platform
|
||||
|
||||
> **NON-NEGOTIABLE STRUCTURE RULES** (enforced by `.claude/settings.json` hook, git pre-commit, and CI):
|
||||
> 1. **File-size budget:** soft target **300** lines, **hard cap 500** lines for any non-test, non-generated source file. Anything larger → split it. Exceptions are listed in `.claude/rules/loc-exceptions.txt` and require a written rationale.
|
||||
> 2. **Clean architecture per service.** Routers/handlers stay thin (≤30 lines per handler) and delegate to services; services use repositories; repositories own DB I/O. See `AGENTS.python.md` / `AGENTS.go.md` / `AGENTS.typescript.md`.
|
||||
> 3. **Do not touch the database schema.** No new Alembic migrations, no `ALTER TABLE`, no model field renames without an explicit migration plan reviewed by the DB owner. SQLAlchemy `__tablename__` and column names are frozen.
|
||||
> 4. **Public endpoints are a contract.** Any change to a path, method, status code, request schema, or response schema in `backend-compliance/`, `ai-compliance-sdk/`, `dsms-gateway/`, `document-crawler/`, or `compliance-tts-service/` must be accompanied by a matching update in **every** consumer (`admin-compliance/`, `developer-portal/`, `breakpilot-compliance-sdk/`, `consent-sdk/`). Use the OpenAPI snapshot tests in `tests/contracts/` as the gate.
|
||||
> 5. **Tests are not optional.** New code without tests fails CI. Refactors must preserve coverage and add a characterization test before splitting an oversized file.
|
||||
> 6. **Do not bypass the guardrails.** Do not edit `.claude/settings.json`, `scripts/check-loc.sh`, or the loc-exceptions list to silence violations. If a rule is wrong, raise it in a PR description.
|
||||
>
|
||||
> These rules apply to **every** Claude Code session opened inside this repository, regardless of who launched it. They are loaded automatically via this `CLAUDE.md`.
|
||||
|
||||
|
||||
|
||||
## First-Time Setup & Claude Code Onboarding
|
||||
|
||||
**For humans:** Read this CLAUDE.md top to bottom before your first commit. Then read `AGENTS.<lang>.md` for the service you are working on (`AGENTS.python.md`, `AGENTS.go.md`, or `AGENTS.typescript.md`).
|
||||
|
||||
**For Claude Code sessions — things that cause first-commit failures:**
|
||||
|
||||
1. **Wrong branch.** Never commit directly to `main`. Create a feature branch first: `git checkout -b feat/my-change`.
|
||||
|
||||
2. **PreToolUse hook blocks your write.** The `PreToolUse` hooks in `.claude/settings.json` will reject Write/Edit operations on any file that would push its line count past 500. This is intentional — split the file into smaller modules instead of trying to bypass the hook.
|
||||
|
||||
3. **Missing `[guardrail-change]` marker.** The `guardrail-integrity` CI job fails if you modify a guardrail file without the marker in the commit message body. See the table below.
|
||||
|
||||
4. **Never `git add -A` or `git add .`.** Stage files individually by path. `git add -A` risks committing `.env`, `node_modules/`, `.next/`, compiled binaries, and other artifacts that must never enter the repo.
|
||||
|
||||
5. **LOC check before push.** After any session, run `bash scripts/check-loc.sh`. It must exit 0 before you push. The git pre-commit hook runs this automatically, but run it manually first to catch issues early.
|
||||
|
||||
### Commit message quick reference
|
||||
|
||||
| Marker | Required when touching |
|
||||
|--------|----------------------|
|
||||
| `[guardrail-change]` | `.claude/settings.json`, `scripts/check-loc.sh`, `scripts/githooks/pre-commit`, `.claude/rules/loc-exceptions.txt`, any `AGENTS.*.md` |
|
||||
| `[migration-approved]` | Anything under `migrations/` or `alembic/versions/` |
|
||||
|
||||
Add the marker anywhere in the commit message body or footer — the CI job does a plain-text grep for it.
|
||||
|
||||
---
|
||||
|
||||
## Entwicklungsumgebung (WICHTIG - IMMER ZUERST LESEN)
|
||||
|
||||
### Zwei-Rechner-Setup + Coolify
|
||||
### Zwei-Rechner-Setup + Orca
|
||||
|
||||
| Geraet | Rolle | Aufgaben |
|
||||
|--------|-------|----------|
|
||||
| **MacBook** | Entwicklung | Claude Terminal, Code-Entwicklung, Browser (Frontend-Tests) |
|
||||
| **Mac Mini** | Lokaler Server | Docker fuer lokale Dev/Tests (NICHT fuer Production!) |
|
||||
| **Coolify** | Production | Automatisches Build + Deploy bei Push auf gitea |
|
||||
| **Orca** | Production | Automatisches Build + Deploy bei Push auf origin |
|
||||
|
||||
**WICHTIG:** Code wird auf dem MacBook bearbeitet. Production-Deployment laeuft automatisch ueber Coolify.
|
||||
**WICHTIG:** Code wird auf dem MacBook bearbeitet. Production-Deployment laeuft automatisch ueber Orca.
|
||||
|
||||
### Entwicklungsworkflow (CI/CD — Coolify)
|
||||
### Entwicklungsworkflow (CI/CD — Orca)
|
||||
|
||||
```bash
|
||||
# 1. Code auf MacBook bearbeiten (dieses Verzeichnis)
|
||||
# 2. Committen und zu BEIDEN Remotes pushen:
|
||||
git push origin main && git push gitea main
|
||||
# 2. Committen und pushen:
|
||||
git push origin main
|
||||
|
||||
# 3. FERTIG! Push auf gitea triggert automatisch:
|
||||
# 3. FERTIG! Push auf origin triggert automatisch:
|
||||
# - Gitea Actions: Lint → Tests → Validierung
|
||||
# - Coolify: Build → Deploy
|
||||
# - Orca: Build → Deploy
|
||||
# Dauer: ca. 3 Minuten
|
||||
# Status pruefen: https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions
|
||||
```
|
||||
|
||||
**NICHT MEHR NOETIG:** Manuelles `ssh macmini "docker compose build"` fuer Production.
|
||||
**NIEMALS** manuell in Coolify auf "Redeploy" klicken — Gitea Actions triggert Coolify automatisch.
|
||||
**NIEMALS** manuell in Orca auf "Redeploy" klicken — Gitea Actions triggert Orca automatisch.
|
||||
|
||||
### Post-Push Deploy-Monitoring (PFLICHT nach jedem Push auf gitea)
|
||||
### Post-Push Deploy-Monitoring (PFLICHT nach jedem Push)
|
||||
|
||||
**IMMER wenn Claude auf gitea pusht, MUSS danach automatisch das Deploy-Monitoring laufen:**
|
||||
**IMMER wenn Claude auf origin 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):
|
||||
@@ -42,32 +81,32 @@ git push origin main && git push gitea main
|
||||
```
|
||||
3. Sobald ALLE Endpoints healthy sind, dem User im Chat melden:
|
||||
**"Deploy abgeschlossen! Du kannst jetzt testen: https://admin-dev.breakpilot.ai"**
|
||||
4. Falls nach 5 Minuten noch nicht healthy → Fehlermeldung mit Hinweis auf Coolify-Logs.
|
||||
4. Falls nach 5 Minuten noch nicht healthy → Fehlermeldung mit Hinweis auf Orca-Logs.
|
||||
|
||||
**Ablauf im Terminal:**
|
||||
```
|
||||
> git push gitea main ✓
|
||||
> git push origin main ✓
|
||||
> "Deploy gestartet, ich ueberwache den Status..."
|
||||
> [Hintergrund-Polling laeuft]
|
||||
> "Deploy abgeschlossen! Alle Services healthy. Du kannst jetzt testen."
|
||||
```
|
||||
|
||||
### CI/CD Pipeline (Gitea Actions → Coolify)
|
||||
### CI/CD Pipeline (Gitea Actions → Orca)
|
||||
|
||||
```
|
||||
Push auf gitea main → go-lint/python-lint/nodejs-lint (nur PRs)
|
||||
Push auf origin 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)
|
||||
→ Orca: Build + Deploy (automatisch bei Push)
|
||||
```
|
||||
|
||||
**Dateien:**
|
||||
- `.gitea/workflows/ci.yaml` — Pipeline-Definition (Tests + Validierung)
|
||||
- `docker-compose.yml` — Haupt-Compose
|
||||
- `docker-compose.hetzner.yml` — Override: arm64→amd64 fuer Coolify Production (x86_64)
|
||||
- `docker-compose.hetzner.yml` — Override: arm64→amd64 fuer Orca Production (x86_64)
|
||||
|
||||
### Lokale Entwicklung (Mac Mini — optional)
|
||||
|
||||
@@ -106,7 +145,7 @@ Config via `.env` (nicht im Repo): `COMPLIANCE_DATABASE_URL`, `QDRANT_URL`, `QDR
|
||||
|
||||
## Haupt-URLs
|
||||
|
||||
### Production (Coolify-deployed)
|
||||
### Production (Orca-deployed)
|
||||
|
||||
| URL | Service | Beschreibung |
|
||||
|-----|---------|--------------|
|
||||
@@ -207,7 +246,7 @@ breakpilot-compliance/
|
||||
├── dsms-gateway/ # IPFS Gateway
|
||||
├── scripts/ # Helper Scripts
|
||||
├── docker-compose.yml # Compliance Compose (~10 Services, platform: arm64)
|
||||
├── docker-compose.hetzner.yml # Override: arm64→amd64 fuer Coolify Production
|
||||
├── docker-compose.hetzner.yml # Override: arm64→amd64 fuer Orca Production
|
||||
└── .gitea/workflows/ci.yaml # CI/CD Pipeline (Lint → Tests → Validierung)
|
||||
```
|
||||
|
||||
@@ -218,8 +257,8 @@ breakpilot-compliance/
|
||||
### Deployment (CI/CD — Standardweg)
|
||||
|
||||
```bash
|
||||
# Committen und pushen → Coolify deployt automatisch:
|
||||
git push origin main && git push gitea main
|
||||
# Committen und pushen → Orca deployt automatisch:
|
||||
git push origin main
|
||||
|
||||
# CI-Status pruefen (im Browser):
|
||||
# https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions
|
||||
@@ -232,12 +271,10 @@ curl -sf https://sdk-dev.breakpilot.ai/health
|
||||
### Git
|
||||
|
||||
```bash
|
||||
# Zu BEIDEN Remotes pushen (PFLICHT! — vom MacBook):
|
||||
git push origin main && git push gitea main
|
||||
git push origin main
|
||||
|
||||
# Remotes:
|
||||
# origin: lokale Gitea (macmini:3003)
|
||||
# gitea: gitea.meghsakha.com:22222
|
||||
# Remote:
|
||||
# origin: ssh://git@gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-compliance.git
|
||||
```
|
||||
|
||||
### Lokale Docker-Befehle (Mac Mini — nur fuer Dev/Tests)
|
||||
|
||||
43
.claude/rules/architecture.md
Normal file
43
.claude/rules/architecture.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Architecture Rules (auto-loaded)
|
||||
|
||||
These rules apply to **every** Claude Code session in this repository, regardless of who launched it. They are non-negotiable.
|
||||
|
||||
## File-size budget
|
||||
|
||||
- **Soft target:** 300 lines per non-test, non-generated source file.
|
||||
- **Hard cap:** 500 lines. The PreToolUse hook in `.claude/settings.json` blocks Write/Edit operations that would create or push a file past 500. The git pre-commit hook re-checks. CI is the final gate.
|
||||
- Exceptions live in `.claude/rules/loc-exceptions.txt` and require a written rationale plus `[guardrail-change]` in the commit message. The exceptions list should shrink over time, not grow.
|
||||
|
||||
## Clean architecture
|
||||
|
||||
- Python (FastAPI): see `AGENTS.python.md`. Layering: `api → services → repositories → db.models`. Routers ≤30 LOC per handler. Schemas split per domain.
|
||||
- Go (Gin): see `AGENTS.go.md`. Standard Go Project Layout + hexagonal. `cmd/` thin, wiring in `internal/app`.
|
||||
- TypeScript (Next.js): see `AGENTS.typescript.md`. Server-by-default, push the client boundary deep, colocate `_components/` and `_hooks/` per route.
|
||||
|
||||
## Database is frozen
|
||||
|
||||
- No new Alembic migrations. No `ALTER TABLE`. No `__tablename__` or column renames.
|
||||
- The pre-commit hook blocks any change under `migrations/` or `alembic/versions/` unless the commit message contains `[migration-approved]`.
|
||||
|
||||
## Public endpoints are a contract
|
||||
|
||||
- Any change to a path/method/status/request schema/response schema in a backend service must update every consumer in the same change set.
|
||||
- Each backend service has an OpenAPI baseline at `tests/contracts/openapi.baseline.json`. Contract tests fail on drift.
|
||||
|
||||
## Tests
|
||||
|
||||
- New code without tests fails CI.
|
||||
- Refactors must preserve coverage. Before splitting an oversized file, add a characterization test that pins current behavior.
|
||||
- Layout: `tests/unit/`, `tests/integration/`, `tests/contracts/`, `tests/e2e/`.
|
||||
|
||||
## Guardrails are themselves protected
|
||||
|
||||
- Edits to `.claude/settings.json`, `scripts/check-loc.sh`, `scripts/githooks/pre-commit`, `.claude/rules/loc-exceptions.txt`, or any `AGENTS.*.md` require `[guardrail-change]` in the commit message. The pre-commit hook enforces this.
|
||||
- If you (Claude) think a rule is wrong, surface it to the user. Do not silently weaken it.
|
||||
|
||||
## Tooling baseline
|
||||
|
||||
- Python: `ruff`, `mypy --strict` on new modules, `pytest --cov`.
|
||||
- Go: `golangci-lint` strict config, `go vet`, table-driven tests.
|
||||
- TS: `tsc --noEmit` strict, ESLint type-aware, Vitest, Playwright.
|
||||
- All three: dependency caching in CI, license/SBOM scan via `syft`+`grype`.
|
||||
103
.claude/rules/loc-exceptions.txt
Normal file
103
.claude/rules/loc-exceptions.txt
Normal file
@@ -0,0 +1,103 @@
|
||||
# loc-exceptions.txt — files allowed to exceed the 500-line hard cap.
|
||||
#
|
||||
# Format: one repo-relative path per line. Comments start with '#' and are ignored.
|
||||
# Each exception MUST be preceded by a comment explaining why splitting is not viable.
|
||||
#
|
||||
# Phase 0 baseline: this list is initially empty. Phases 1-4 will add grandfathered
|
||||
# entries as we encounter legitimate exceptions (e.g. large generated data tables).
|
||||
# The goal is for this list to SHRINK over time, never grow.
|
||||
|
||||
# --- admin-compliance: static data catalogs (Phase 3) ---
|
||||
# Splitting these would fragment lookup tables without improving readability.
|
||||
admin-compliance/lib/sdk/tom-generator/controls/loader.ts
|
||||
admin-compliance/lib/sdk/vendor-compliance/risk/controls-library.ts
|
||||
admin-compliance/lib/sdk/compliance-scope-triggers.ts
|
||||
admin-compliance/lib/sdk/vendor-compliance/catalog/processing-activities.ts
|
||||
admin-compliance/lib/sdk/catalog-manager/catalog-registry.ts
|
||||
admin-compliance/lib/sdk/dsfa/mitigation-library.ts
|
||||
admin-compliance/lib/sdk/vvt-baseline-catalog.ts
|
||||
admin-compliance/lib/sdk/dsfa/eu-legal-frameworks.ts
|
||||
admin-compliance/lib/sdk/dsfa/risk-catalog.ts
|
||||
admin-compliance/lib/sdk/loeschfristen-baseline-catalog.ts
|
||||
admin-compliance/lib/sdk/vendor-compliance/catalog/vendor-templates.ts
|
||||
admin-compliance/lib/sdk/vendor-compliance/catalog/legal-basis.ts
|
||||
admin-compliance/lib/sdk/vendor-compliance/contract-review/findings.ts
|
||||
admin-compliance/lib/sdk/vendor-compliance/contract-review/checklists.ts
|
||||
admin-compliance/lib/sdk/compliance-scope-types/document-scope-matrix-core.ts
|
||||
admin-compliance/lib/sdk/compliance-scope-types/document-scope-matrix-extended.ts
|
||||
admin-compliance/lib/sdk/demo-data/index.ts
|
||||
admin-compliance/lib/sdk/tom-generator/demo-data/index.ts
|
||||
|
||||
# --- admin-compliance: self-contained export generators (Phase 3) ---
|
||||
# Each file generates a complete document format. Splitting mid-generation
|
||||
# logic would create artificial module boundaries without benefit.
|
||||
admin-compliance/lib/sdk/tom-generator/export/zip.ts
|
||||
admin-compliance/lib/sdk/tom-generator/export/docx.ts
|
||||
admin-compliance/lib/sdk/tom-generator/export/pdf.ts
|
||||
admin-compliance/lib/sdk/einwilligungen/export/pdf.ts
|
||||
admin-compliance/lib/sdk/einwilligungen/generator/privacy-policy-sections.ts
|
||||
|
||||
# --- backend-compliance: legacy utility services (Phase 1) ---
|
||||
# Pre-refactor utility modules not yet split. Phase 5 targets.
|
||||
backend-compliance/compliance/services/control_generator.py
|
||||
backend-compliance/compliance/services/audit_pdf_generator.py
|
||||
backend-compliance/compliance/services/regulation_scraper.py
|
||||
backend-compliance/compliance/services/llm_provider.py
|
||||
backend-compliance/compliance/services/export_generator.py
|
||||
backend-compliance/compliance/services/pdf_extractor.py
|
||||
backend-compliance/compliance/services/ai_compliance_assistant.py
|
||||
|
||||
# --- backend-compliance: Phase 1 code refactor backlog ---
|
||||
# These are the remaining oversized route/service/data/auth files that Phase 1
|
||||
# did not reach. Each entry is a tracked refactor debt item — the list must shrink.
|
||||
backend-compliance/compliance/services/decomposition_pass.py
|
||||
backend-compliance/compliance/api/schemas.py
|
||||
backend-compliance/compliance/api/canonical_control_routes.py
|
||||
backend-compliance/compliance/db/repository.py
|
||||
backend-compliance/compliance/db/models.py
|
||||
backend-compliance/compliance/api/evidence_check_routes.py
|
||||
backend-compliance/compliance/api/control_generator_routes.py
|
||||
backend-compliance/compliance/api/process_task_routes.py
|
||||
backend-compliance/compliance/api/evidence_routes.py
|
||||
backend-compliance/compliance/api/crosswalk_routes.py
|
||||
backend-compliance/compliance/api/dashboard_routes.py
|
||||
backend-compliance/compliance/api/dsfa_routes.py
|
||||
backend-compliance/compliance/api/routes.py
|
||||
backend-compliance/compliance/api/tom_mapping_routes.py
|
||||
backend-compliance/compliance/services/control_dedup.py
|
||||
backend-compliance/compliance/services/framework_decomposition.py
|
||||
backend-compliance/compliance/services/pipeline_adapter.py
|
||||
backend-compliance/compliance/services/batch_dedup_runner.py
|
||||
backend-compliance/compliance/services/obligation_extractor.py
|
||||
backend-compliance/compliance/services/control_composer.py
|
||||
backend-compliance/compliance/services/pattern_matcher.py
|
||||
backend-compliance/compliance/data/iso27001_annex_a.py
|
||||
backend-compliance/compliance/data/service_modules.py
|
||||
backend-compliance/compliance/data/controls.py
|
||||
backend-compliance/services/pdf_service.py
|
||||
backend-compliance/services/file_processor.py
|
||||
backend-compliance/auth/keycloak_auth.py
|
||||
|
||||
# --- scripts: one-off ingestion, QA, and migration scripts ---
|
||||
# These are operational scripts, not production application code.
|
||||
# LOC rules don't apply in the same way to single-purpose scripts.
|
||||
scripts/ingest-legal-corpus.sh
|
||||
scripts/ingest-ce-corpus.sh
|
||||
scripts/ingest-dsfa-bundesland.sh
|
||||
scripts/edpb-crawler.py
|
||||
scripts/apply_templates_023.py
|
||||
scripts/qa/phase74_generate_gap_controls.py
|
||||
scripts/qa/pdf_qa_all.py
|
||||
scripts/qa/benchmark_llm_controls.py
|
||||
backend-compliance/scripts/seed_policy_templates.py
|
||||
|
||||
# --- docs-src: copies of backend source for documentation rendering ---
|
||||
# These are not production code; they are rendered into the static docs site.
|
||||
docs-src/control_generator.py
|
||||
docs-src/control_generator_routes.py
|
||||
|
||||
# --- consent-sdk: platform-native mobile SDKs (Swift / Dart) ---
|
||||
# Flutter and iOS SDKs follow platform conventions (verbose verbose) that make
|
||||
# splitting into multiple files awkward without sacrificing single-import ergonomics.
|
||||
consent-sdk/src/mobile/flutter/consent_sdk.dart
|
||||
consent-sdk/src/mobile/ios/ConsentManager.swift
|
||||
28
.claude/settings.json
Normal file
28
.claude/settings.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "f=$(jq -r '.tool_input.file_path // empty'); [ -z \"$f\" ] && exit 0; lines=$(printf '%s' \"$(jq -r '.tool_input.content // empty')\" | awk 'END{print NR}'); if [ \"${lines:-0}\" -gt 500 ]; then echo '{\"decision\":\"block\",\"reason\":\"breakpilot guardrail: file exceeds the 500-line hard cap. Split it into smaller modules per the layering rules in AGENTS.<lang>.md. If this is generated/data code, add an entry to .claude/rules/loc-exceptions.txt with rationale and reference [guardrail-change].\"}'; exit 0; fi",
|
||||
"shell": "bash",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "f=$(jq -r '.tool_input.file_path // empty'); [ -z \"$f\" ] || [ ! -f \"$f\" ] && exit 0; case \"$f\" in *.md|*.json|*.yaml|*.yml|*test*|*tests/*|*node_modules/*|*.next/*|*migrations/*) exit 0 ;; esac; new_str=$(jq -r '.tool_input.new_string // empty'); old_str=$(jq -r '.tool_input.old_string // empty'); old_lines=$(printf '%s' \"$old_str\" | awk 'END{print NR}'); new_lines=$(printf '%s' \"$new_str\" | awk 'END{print NR}'); cur=$(wc -l < \"$f\" | tr -d ' '); proj=$((cur - old_lines + new_lines)); if [ \"$proj\" -gt 500 ]; then echo \"{\\\"decision\\\":\\\"block\\\",\\\"reason\\\":\\\"breakpilot guardrail: this edit would push $f to ~$proj lines (hard cap is 500). Split the file before continuing. See AGENTS.<lang>.md for the layering rules.\\\"}\"; fi; exit 0",
|
||||
"shell": "bash",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
# =========================================================
|
||||
# BreakPilot Compliance — Coolify Environment Variables
|
||||
# BreakPilot Compliance — Orca Environment Variables
|
||||
# =========================================================
|
||||
# Copy these into Coolify's environment variable UI
|
||||
# Copy these into Orca'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
|
||||
# --- External PostgreSQL (Orca-managed, same as Core) ---
|
||||
COMPLIANCE_DATABASE_URL=postgresql://breakpilot:CHANGE_ME@<orca-postgres-hostname>:5432/breakpilot_db
|
||||
|
||||
# --- Security ---
|
||||
JWT_SECRET=CHANGE_ME_SAME_AS_CORE
|
||||
221
.gitea/workflows/build-push-deploy.yml
Normal file
221
.gitea/workflows/build-push-deploy.yml
Normal file
@@ -0,0 +1,221 @@
|
||||
# Build + push compliance service images to registry.meghsakha.com
|
||||
# and trigger orca redeploy on every push to main that touches a service.
|
||||
#
|
||||
# Requires Gitea Actions secrets:
|
||||
# REGISTRY_USERNAME / REGISTRY_PASSWORD — registry.meghsakha.com credentials
|
||||
# ORCA_WEBHOOK_SECRET — must match webhooks.json on orca master
|
||||
|
||||
name: Build + Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'admin-compliance/**'
|
||||
- 'backend-compliance/**'
|
||||
- 'ai-compliance-sdk/**'
|
||||
- 'developer-portal/**'
|
||||
- 'compliance-tts-service/**'
|
||||
- 'document-crawler/**'
|
||||
- 'dsms-gateway/**'
|
||||
- 'dsms-node/**'
|
||||
|
||||
jobs:
|
||||
# ── per-service builds run in parallel ────────────────────────────────────
|
||||
|
||||
build-admin-compliance:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Login
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||
- name: Build + push
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
docker build \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-admin:latest \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-admin:${SHORT_SHA} \
|
||||
admin-compliance/
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-admin:latest
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-admin:${SHORT_SHA}
|
||||
|
||||
build-backend-compliance:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Login
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||
- name: Build + push
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
docker build \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-backend:latest \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-backend:${SHORT_SHA} \
|
||||
backend-compliance/
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-backend:latest
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-backend:${SHORT_SHA}
|
||||
|
||||
build-ai-sdk:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Login
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||
- name: Build + push
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
docker build \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-sdk:latest \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-sdk:${SHORT_SHA} \
|
||||
ai-compliance-sdk/
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-sdk:latest
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-sdk:${SHORT_SHA}
|
||||
|
||||
build-developer-portal:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Login
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||
- name: Build + push
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
docker build \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-portal:latest \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-portal:${SHORT_SHA} \
|
||||
developer-portal/
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-portal:latest
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-portal:${SHORT_SHA}
|
||||
|
||||
build-tts:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Login
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||
- name: Build + push
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
docker build \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-tts:latest \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-tts:${SHORT_SHA} \
|
||||
compliance-tts-service/
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-tts:latest
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-tts:${SHORT_SHA}
|
||||
|
||||
build-document-crawler:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Login
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||
- name: Build + push
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
docker build \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-crawler:latest \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-crawler:${SHORT_SHA} \
|
||||
document-crawler/
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-crawler:latest
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-crawler:${SHORT_SHA}
|
||||
|
||||
build-dsms-gateway:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Login
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||
- name: Build + push
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
docker build \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-dsms-gateway:latest \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-dsms-gateway:${SHORT_SHA} \
|
||||
dsms-gateway/
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-dsms-gateway:latest
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-dsms-gateway:${SHORT_SHA}
|
||||
|
||||
# ── orca redeploy (only after all builds succeed) ─────────────────────────
|
||||
|
||||
trigger-orca:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs:
|
||||
- build-admin-compliance
|
||||
- build-backend-compliance
|
||||
- build-ai-sdk
|
||||
- build-developer-portal
|
||||
- build-tts
|
||||
- build-document-crawler
|
||||
- build-dsms-gateway
|
||||
steps:
|
||||
- name: Checkout (for SHA)
|
||||
run: |
|
||||
apk add --no-cache git curl openssl
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Trigger orca redeploy
|
||||
env:
|
||||
ORCA_WEBHOOK_SECRET: ${{ secrets.ORCA_WEBHOOK_SECRET }}
|
||||
ORCA_WEBHOOK_URL: http://46.225.100.82:6880/api/v1/webhooks/github
|
||||
run: |
|
||||
SHA=$(git rev-parse HEAD)
|
||||
PAYLOAD="{\"ref\":\"refs/heads/main\",\"repository\":{\"full_name\":\"${GITHUB_REPOSITORY}\"},\"head_commit\":{\"id\":\"$SHA\",\"message\":\"ci: compliance images built\"}}"
|
||||
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$ORCA_WEBHOOK_SECRET" -r | awk '{print $1}')
|
||||
curl -sSf -k \
|
||||
-X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-GitHub-Event: push" \
|
||||
-H "X-Hub-Signature-256: sha256=$SIG" \
|
||||
-d "$PAYLOAD" \
|
||||
"$ORCA_WEBHOOK_URL" \
|
||||
|| { echo "Orca redeploy failed"; exit 1; }
|
||||
echo "Orca redeploy triggered for compliance services"
|
||||
@@ -1,28 +1,92 @@
|
||||
# Gitea Actions CI/CD Pipeline
|
||||
# BreakPilot Compliance
|
||||
# BreakPilot Compliance — CI Pipeline
|
||||
#
|
||||
# Services:
|
||||
# Go: ai-compliance-sdk
|
||||
# Python: backend-compliance, document-crawler, dsms-gateway
|
||||
# Node.js: admin-compliance, developer-portal
|
||||
# Feature branch workflow:
|
||||
# feat/* | feature/* | fix/* | hotfix/* | chore/* | refactor/* | docs/* | test/* | ci/*
|
||||
# → open PR targeting main
|
||||
# → all jobs run as PR gates
|
||||
# → squash merge to main
|
||||
# → subset of jobs re-run on main to catch merge surprises
|
||||
#
|
||||
# Workflow:
|
||||
# Push auf main → Tests → Deploy (Coolify)
|
||||
# Pull Request → Lint + Tests (kein Deploy)
|
||||
# Deploy is handled by build-push-deploy.yml on push to main.
|
||||
|
||||
name: CI/CD
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
# ========================================
|
||||
# Lint (nur bei PRs)
|
||||
# ========================================
|
||||
|
||||
# ── Branch naming convention (PR only) ──────────────────────────────────
|
||||
branch-name:
|
||||
runs-on: docker
|
||||
container: alpine:3.20
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Validate branch name
|
||||
run: |
|
||||
BRANCH="${GITHUB_HEAD_REF}"
|
||||
if ! echo "$BRANCH" | grep -qE '^(feat|feature|fix|hotfix|chore|refactor|docs|test|ci)/.+'; then
|
||||
echo "::error::Branch '$BRANCH' does not follow naming convention."
|
||||
echo "Required prefix: feat/ feature/ fix/ hotfix/ chore/ refactor/ docs/ test/ ci/"
|
||||
exit 1
|
||||
fi
|
||||
echo "Branch name OK: $BRANCH"
|
||||
|
||||
# ── Guardrail integrity (PR only) ────────────────────────────────────────
|
||||
guardrail-integrity:
|
||||
runs-on: docker
|
||||
container: alpine:3.20
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git bash
|
||||
git clone --depth 20 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
git fetch origin ${GITHUB_BASE_REF}:base
|
||||
- name: Require [guardrail-change] in commits touching guardrails
|
||||
run: |
|
||||
changed=$(git diff --name-only base...HEAD)
|
||||
echo "$changed" | grep -qE '^(\.claude/settings\.json|\.claude/rules/loc-exceptions\.txt|scripts/check-loc\.sh|scripts/githooks/pre-commit|AGENTS\.(python|go|typescript)\.md)$' || exit 0
|
||||
if ! git log base..HEAD --format=%B | grep -q '\[guardrail-change\]'; then
|
||||
echo "::error::Guardrail files modified without [guardrail-change] in any commit message."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── LOC budget (always) ──────────────────────────────────────────────────
|
||||
loc-budget:
|
||||
runs-on: docker
|
||||
container: alpine:3.20
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git bash
|
||||
git clone --depth 50 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Enforce 500-line hard cap
|
||||
run: |
|
||||
chmod +x scripts/check-loc.sh
|
||||
scripts/check-loc.sh
|
||||
|
||||
# ── Secret scanning (PR only) ────────────────────────────────────────────
|
||||
secret-scan:
|
||||
runs-on: docker
|
||||
container: zricethezav/gitleaks:v8.21.2
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 50 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Scan for secrets
|
||||
run: |
|
||||
gitleaks detect --source . --no-git \
|
||||
--exit-code 1 \
|
||||
--redact \
|
||||
|| { echo "::error::Secrets detected — remove them before merging."; exit 1; }
|
||||
|
||||
# ── Go lint + build (PR only) ────────────────────────────────────────────
|
||||
go-lint:
|
||||
runs-on: docker
|
||||
if: github.event_name == 'pull_request'
|
||||
@@ -34,10 +98,16 @@ jobs:
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Lint ai-compliance-sdk
|
||||
run: |
|
||||
if [ -d "ai-compliance-sdk" ]; then
|
||||
cd ai-compliance-sdk && golangci-lint run --timeout 5m ./...
|
||||
fi
|
||||
[ -d "ai-compliance-sdk" ] || exit 0
|
||||
cd ai-compliance-sdk
|
||||
golangci-lint run --timeout 5m ./...
|
||||
- name: Build ai-compliance-sdk
|
||||
run: |
|
||||
[ -d "ai-compliance-sdk" ] || exit 0
|
||||
cd ai-compliance-sdk
|
||||
go build ./...
|
||||
|
||||
# ── Python lint + import check (PR only) ────────────────────────────────
|
||||
python-lint:
|
||||
runs-on: docker
|
||||
if: github.event_name == 'pull_request'
|
||||
@@ -47,16 +117,27 @@ jobs:
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Lint Python services
|
||||
- name: Lint (ruff) + type-check (mypy)
|
||||
run: |
|
||||
pip install --quiet ruff
|
||||
for svc in backend-compliance document-crawler dsms-gateway; do
|
||||
if [ -d "$svc" ]; then
|
||||
echo "=== Linting $svc ==="
|
||||
ruff check "$svc/" --output-format=github || true
|
||||
fi
|
||||
pip install --quiet ruff mypy
|
||||
fail=0
|
||||
for svc in backend-compliance document-crawler dsms-gateway compliance-tts-service; do
|
||||
[ -d "$svc" ] || continue
|
||||
echo "=== ruff: $svc ===" && ruff check "$svc/" --output-format=github || fail=1
|
||||
done
|
||||
if [ -f "backend-compliance/mypy.ini" ]; then
|
||||
cd backend-compliance && mypy compliance/ || fail=1
|
||||
fi
|
||||
exit $fail
|
||||
- name: Import sanity check (catches NameError at collection time)
|
||||
run: |
|
||||
cd backend-compliance
|
||||
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
||||
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
|
||||
python -c "import compliance; print('Import OK')" \
|
||||
|| { echo "::error::compliance package fails to import — missing import or syntax error."; exit 1; }
|
||||
|
||||
# ── Node.js lint + type-check (PR only) ─────────────────────────────────
|
||||
nodejs-lint:
|
||||
runs-on: docker
|
||||
if: github.event_name == 'pull_request'
|
||||
@@ -66,23 +147,105 @@ jobs:
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Lint Node.js services
|
||||
- name: Lint + type-check
|
||||
run: |
|
||||
fail=0
|
||||
for svc in admin-compliance developer-portal; do
|
||||
if [ -d "$svc" ]; then
|
||||
echo "=== Linting $svc ==="
|
||||
cd "$svc"
|
||||
npm ci --silent 2>/dev/null || npm install --silent
|
||||
npx next lint || true
|
||||
cd ..
|
||||
fi
|
||||
[ -d "$svc" ] || continue
|
||||
echo "=== $svc: install ===" && (cd "$svc" && npm ci --silent 2>/dev/null || npm install --silent)
|
||||
echo "=== $svc: next lint ===" && (cd "$svc" && npx next lint) || fail=1
|
||||
echo "=== $svc: tsc ===" && (cd "$svc" && npx tsc --noEmit) || fail=1
|
||||
done
|
||||
exit $fail
|
||||
|
||||
# ========================================
|
||||
# Unit Tests
|
||||
# ========================================
|
||||
# ── Node.js build — next build (PR + push to main) ───────────────────────
|
||||
nodejs-build:
|
||||
runs-on: docker
|
||||
container: node:20-alpine
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Build Next.js services
|
||||
run: |
|
||||
fail=0
|
||||
for svc in admin-compliance developer-portal; do
|
||||
[ -d "$svc" ] || continue
|
||||
echo "=== $svc: install ==="
|
||||
(cd "$svc" && npm ci --silent 2>/dev/null || npm install --silent)
|
||||
echo "=== $svc: next build ==="
|
||||
(cd "$svc" && \
|
||||
NEXT_PUBLIC_API_URL=https://api-dev.breakpilot.ai \
|
||||
NEXT_PUBLIC_SDK_URL=https://sdk-dev.breakpilot.ai \
|
||||
npm run build) || fail=1
|
||||
done
|
||||
exit $fail
|
||||
|
||||
test-go-ai-compliance:
|
||||
# ── Dependency audit (PR only) ───────────────────────────────────────────
|
||||
dep-audit:
|
||||
runs-on: docker
|
||||
if: github.event_name == 'pull_request'
|
||||
container: python:3.12-slim
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq git curl > /dev/null 2>&1
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Install Node.js + Go
|
||||
run: |
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - > /dev/null 2>&1
|
||||
apt-get install -y nodejs golang-go > /dev/null 2>&1
|
||||
- name: Python — pip-audit
|
||||
run: |
|
||||
pip install --quiet pip-audit
|
||||
fail=0
|
||||
for svc in backend-compliance document-crawler dsms-gateway compliance-tts-service; do
|
||||
[ -f "$svc/requirements.txt" ] || continue
|
||||
echo "=== pip-audit: $svc ==="
|
||||
pip-audit -r "$svc/requirements.txt" --skip-editable -f columns || fail=1
|
||||
done
|
||||
exit $fail
|
||||
- name: Node.js — npm audit
|
||||
run: |
|
||||
fail=0
|
||||
for svc in admin-compliance developer-portal; do
|
||||
[ -d "$svc" ] || continue
|
||||
echo "=== npm audit: $svc ==="
|
||||
(cd "$svc" && npm audit --audit-level=moderate --json 2>/dev/null | \
|
||||
node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); \
|
||||
const hi=Object.values(d.vulnerabilities||{}).filter(v=>['high','critical'].includes(v.severity)).length; \
|
||||
if(hi>0){console.error('HIGH/CRITICAL: '+hi);process.exit(1)}") || fail=1
|
||||
done
|
||||
exit $fail
|
||||
- name: Go — govulncheck
|
||||
run: |
|
||||
[ -d "ai-compliance-sdk" ] || exit 0
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest 2>/dev/null
|
||||
cd ai-compliance-sdk && govulncheck ./... || true
|
||||
# Non-blocking until Go module versions are pinned
|
||||
|
||||
# ── SBOM + vulnerability scan (PR only) ─────────────────────────────────
|
||||
sbom-scan:
|
||||
runs-on: docker
|
||||
if: github.event_name == 'pull_request'
|
||||
container: alpine:3.20
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git curl bash
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Install syft + grype
|
||||
run: |
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
|
||||
- name: Generate SBOM
|
||||
run: mkdir -p sbom-out && syft dir:. -o cyclonedx-json=sbom-out/sbom.cdx.json -q
|
||||
- name: Vulnerability scan (fail on high+)
|
||||
run: grype sbom:sbom-out/sbom.cdx.json --fail-on high -q
|
||||
|
||||
# ── Tests (PR + push to main) ─────────────────────────────────────────────
|
||||
test-go:
|
||||
runs-on: docker
|
||||
container: golang:1.24-alpine
|
||||
env:
|
||||
@@ -94,16 +257,12 @@ jobs:
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Test ai-compliance-sdk
|
||||
run: |
|
||||
if [ ! -d "ai-compliance-sdk" ]; then
|
||||
echo "WARNUNG: ai-compliance-sdk nicht gefunden"
|
||||
exit 0
|
||||
fi
|
||||
[ -d "ai-compliance-sdk" ] || exit 0
|
||||
cd ai-compliance-sdk
|
||||
go test -v -coverprofile=coverage.out ./... 2>&1
|
||||
COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' || echo "0%")
|
||||
echo "Coverage: $COVERAGE"
|
||||
go test -v -coverprofile=coverage.out ./...
|
||||
go tool cover -func=coverage.out | tail -1
|
||||
|
||||
test-python-backend-compliance:
|
||||
test-python-backend:
|
||||
runs-on: docker
|
||||
container: python:3.12-slim
|
||||
env:
|
||||
@@ -115,14 +274,11 @@ jobs:
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Test backend-compliance
|
||||
run: |
|
||||
if [ ! -d "backend-compliance" ]; then
|
||||
echo "WARNUNG: backend-compliance nicht gefunden"
|
||||
exit 0
|
||||
fi
|
||||
[ -d "backend-compliance" ] || exit 0
|
||||
cd backend-compliance
|
||||
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
|
||||
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
||||
pip install --quiet --no-cache-dir fastapi uvicorn pytest pytest-asyncio
|
||||
pip install --quiet --no-cache-dir pytest pytest-asyncio
|
||||
python -m pytest compliance/tests/ -v --tb=short
|
||||
|
||||
test-python-document-crawler:
|
||||
@@ -137,10 +293,7 @@ jobs:
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Test document-crawler
|
||||
run: |
|
||||
if [ ! -d "document-crawler" ]; then
|
||||
echo "WARNUNG: document-crawler nicht gefunden"
|
||||
exit 0
|
||||
fi
|
||||
[ -d "document-crawler" ] || exit 0
|
||||
cd document-crawler
|
||||
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
|
||||
pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || true
|
||||
@@ -159,20 +312,14 @@ jobs:
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Test dsms-gateway
|
||||
run: |
|
||||
if [ ! -d "dsms-gateway" ]; then
|
||||
echo "WARNUNG: dsms-gateway nicht gefunden"
|
||||
exit 0
|
||||
fi
|
||||
[ -d "dsms-gateway" ] || exit 0
|
||||
cd dsms-gateway
|
||||
export PYTHONPATH="$(pwd):${PYTHONPATH:-}"
|
||||
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
|
||||
# ========================================
|
||||
|
||||
# ── OpenAPI contract validation (always) ─────────────────────────────────
|
||||
validate-canonical-controls:
|
||||
runs-on: docker
|
||||
container: python:3.12-slim
|
||||
@@ -182,28 +329,4 @@ jobs:
|
||||
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 }}"
|
||||
run: python scripts/validate-controls.py
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
#
|
||||
# Phasen: gesetze, eu, templates, datenschutz, verbraucherschutz, verify, version, all
|
||||
#
|
||||
# Voraussetzung: RAG-Service und Qdrant muessen auf Coolify laufen.
|
||||
# Die BreakPilot-Services muessen deployed sein (ci.yaml deploy-coolify).
|
||||
# Voraussetzung: RAG-Service und Qdrant muessen auf Orca laufen.
|
||||
# Die BreakPilot-Services muessen deployed sein (ci.yaml deploy-orca).
|
||||
|
||||
name: RAG Ingestion
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -11,6 +11,10 @@ secrets/
|
||||
# Node
|
||||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
.turbo/
|
||||
pnpm-lock.yaml
|
||||
.pnpm-store/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
|
||||
176
AGENTS.go.md
Normal file
176
AGENTS.go.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# AGENTS.go.md — Go Service Conventions
|
||||
|
||||
Applies to: `ai-compliance-sdk/`.
|
||||
|
||||
## Layered architecture (Gin)
|
||||
|
||||
Follows [Standard Go Project Layout](https://github.com/golang-standards/project-layout) + hexagonal/clean-arch.
|
||||
|
||||
```
|
||||
ai-compliance-sdk/
|
||||
├── cmd/server/main.go # Thin: parse flags → app.New → app.Run. <50 LOC.
|
||||
├── internal/
|
||||
│ ├── app/ # Wiring: config + DI graph + lifecycle.
|
||||
│ ├── domain/ # Pure types, interfaces, errors. No I/O imports.
|
||||
│ │ └── <aggregate>/
|
||||
│ ├── service/ # Business logic. Depends on domain interfaces only.
|
||||
│ │ └── <aggregate>/
|
||||
│ ├── repository/postgres/ # Concrete repo implementations.
|
||||
│ │ └── <aggregate>/
|
||||
│ ├── transport/http/ # Gin handlers. Thin. One handler per file group.
|
||||
│ │ ├── handler/<aggregate>/
|
||||
│ │ ├── middleware/
|
||||
│ │ └── router.go
|
||||
│ └── platform/ # DB pool, logger, config, tracing.
|
||||
└── pkg/ # Importable by other repos. Empty unless needed.
|
||||
```
|
||||
|
||||
**Dependency direction:** `transport → service → domain ← repository`. `domain` imports nothing from siblings.
|
||||
|
||||
## Handlers
|
||||
|
||||
- One handler = one Gin function. ≤40 LOC.
|
||||
- Bind → call service → map domain error to HTTP via `httperr.Write(c, err)` → respond.
|
||||
- Return early on errors. No business logic, no SQL.
|
||||
|
||||
```go
|
||||
func (h *IACEHandler) Create(c *gin.Context) {
|
||||
var req CreateIACERequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Write(c, httperr.BadRequest(err))
|
||||
return
|
||||
}
|
||||
out, err := h.svc.Create(c.Request.Context(), req.ToInput())
|
||||
if err != nil {
|
||||
httperr.Write(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
```
|
||||
|
||||
## Services
|
||||
|
||||
- Struct + constructor + interface methods. No package-level state.
|
||||
- Take `context.Context` as first arg always. Propagate to repos.
|
||||
- Return `(value, error)`. Wrap with `fmt.Errorf("create iace: %w", err)`.
|
||||
- Domain errors implemented as sentinel vars or typed errors; matched with `errors.Is` / `errors.As`.
|
||||
|
||||
## Repositories
|
||||
|
||||
- Interface lives in `domain/<aggregate>/repository.go`. Implementation in `repository/postgres/<aggregate>/`.
|
||||
- One file per query group; no file >500 LOC.
|
||||
- Use `pgx`/`sqlc` over hand-rolled string SQL when feasible. No ORM globals.
|
||||
- All queries take `ctx`. No background goroutines without explicit lifecycle.
|
||||
|
||||
## Errors
|
||||
|
||||
Single `internal/platform/httperr` package maps `error` → HTTP status:
|
||||
|
||||
```go
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrNotFound): return 404
|
||||
case errors.Is(err, domain.ErrConflict): return 409
|
||||
case errors.As(err, &validationErr): return 422
|
||||
default: return 500
|
||||
}
|
||||
```
|
||||
|
||||
Never `panic` in request handling. `recover` middleware logs and returns 500.
|
||||
|
||||
## Tests
|
||||
|
||||
- Co-located `*_test.go`.
|
||||
- **Table-driven** tests for service logic; use `t.Run(tt.name, ...)`.
|
||||
- Handlers tested with `httptest.NewRecorder`.
|
||||
- Repos tested with `testcontainers-go` (or the existing compose Postgres) — never mocks at the SQL boundary.
|
||||
- Coverage target: 80% on `service/`. CI fails on regression.
|
||||
|
||||
```go
|
||||
func TestIACEService_Create(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input service.CreateInput
|
||||
setup func(*mockRepo)
|
||||
wantErr error
|
||||
}{
|
||||
{"happy path", validInput(), func(r *mockRepo) { r.createReturns(nil) }, nil},
|
||||
{"conflict", validInput(), func(r *mockRepo) { r.createReturns(domain.ErrConflict) }, domain.ErrConflict},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) { /* ... */ })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tooling
|
||||
|
||||
Run lint before pushing:
|
||||
|
||||
```bash
|
||||
golangci-lint run --timeout 5m ./...
|
||||
```
|
||||
|
||||
The `.golangci.yml` at the service root (`ai-compliance-sdk/.golangci.yml`) enables: `errcheck, govet, staticcheck, gosec, gocyclo (≤20), gocritic, revive, goimports, unused, ineffassign`. Fix lint violations in new code; legacy violations are tracked but not required to fix immediately.
|
||||
|
||||
- `gofumpt` formatting.
|
||||
- `go vet ./...` clean.
|
||||
- `go mod tidy` clean — no unused deps.
|
||||
|
||||
## File splitting pattern
|
||||
|
||||
When a Go file exceeds the 500-line hard cap, split it in place — no new packages needed:
|
||||
|
||||
- All split files stay in **the same package directory** with the **same `package <name>` declaration**.
|
||||
- No import changes are needed anywhere because Go packages are directory-scoped.
|
||||
- Naming: `store_projects.go`, `store_components.go` (noun + underscore + sub-resource).
|
||||
- For handlers: `iace_handler_projects.go`, `iace_handler_hazards.go`, etc.
|
||||
- Before splitting, add a characterization test that pins current behaviour.
|
||||
|
||||
## Error handling
|
||||
|
||||
Domain errors are defined in `internal/domain/<aggregate>/errors.go` as sentinel vars or typed errors. The mapping from domain error to HTTP status lives exclusively in `internal/platform/httperr/httperr.go` via `errors.Is` / `errors.As`. Handlers call `httperr.Write(c, err)` — **never** directly call `c.JSON` with a status code derived from business logic.
|
||||
|
||||
## Context propagation
|
||||
|
||||
- Always pass `ctx context.Context` as the **first parameter** in every service and repository method.
|
||||
- Never store a context in a struct field — pass it per call.
|
||||
- Cancellation must be respected: check `ctx.Err()` in loops; propagate to all I/O calls.
|
||||
|
||||
## Concurrency
|
||||
|
||||
- Goroutines must have a clear lifecycle owner (struct method that started them must stop them).
|
||||
- Pass `ctx` everywhere. Cancellation respected.
|
||||
- No global mutexes for request data. Use per-request context.
|
||||
|
||||
## Before every push — MANDATORY
|
||||
|
||||
Run all steps for `ai-compliance-sdk/` before pushing. CI runs the same checks and will fail if you skip this.
|
||||
|
||||
```bash
|
||||
cd ai-compliance-sdk
|
||||
|
||||
# 1. Vet + lint
|
||||
go vet ./...
|
||||
golangci-lint run --timeout 5m ./...
|
||||
|
||||
# 2. Tests
|
||||
go test ./...
|
||||
|
||||
# 3. Build
|
||||
go build ./...
|
||||
```
|
||||
|
||||
All steps must exit 0. Do not push if any step fails.
|
||||
|
||||
## What you may NOT do
|
||||
|
||||
- Touch DB schema/migrations.
|
||||
- Add a new top-level package directly under `internal/` without architectural review.
|
||||
- `import "C"`, unsafe, reflection-heavy code.
|
||||
- Use `init()` for non-trivial setup. Wire it in `internal/app`.
|
||||
- Use `interface{}` / `any` in new code without an explicit comment justifying it.
|
||||
- Call `log.Fatal` outside of `main.go`; panicking in request handling is also forbidden.
|
||||
- Shadow `err` with `:=` inside an `if`-block when the outer scope already declares `err` — use `=` or rename.
|
||||
- Create a file >500 lines.
|
||||
- Change a public route's contract without updating consumers.
|
||||
168
AGENTS.python.md
Normal file
168
AGENTS.python.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# AGENTS.python.md — Python Service Conventions
|
||||
|
||||
Applies to: `backend-compliance/`, `document-crawler/`, `dsms-gateway/`, `compliance-tts-service/`.
|
||||
|
||||
## Layered architecture (FastAPI)
|
||||
|
||||
```
|
||||
compliance/
|
||||
├── api/ # HTTP layer — routers only. Thin (≤30 LOC per handler).
|
||||
│ └── <domain>_routes.py
|
||||
├── services/ # Business logic. Pure-ish; no FastAPI imports.
|
||||
│ └── <domain>_service.py
|
||||
├── repositories/ # DB access. Owns SQLAlchemy session usage.
|
||||
│ └── <domain>_repository.py
|
||||
├── domain/ # Value objects, enums, domain exceptions.
|
||||
├── schemas/ # Pydantic models, split per domain. NEVER one giant schemas.py.
|
||||
│ └── <domain>.py
|
||||
└── db/
|
||||
└── models/ # SQLAlchemy ORM, one module per aggregate. __tablename__ frozen.
|
||||
```
|
||||
|
||||
**Dependency direction:** `api → services → repositories → db.models`. Lower layers must not import upper layers.
|
||||
|
||||
## Routers
|
||||
|
||||
- One `APIRouter` per domain file.
|
||||
- Handlers do exactly: parse request → call service → map domain errors to HTTPException → return response model.
|
||||
- Inject services via `Depends`. No globals.
|
||||
- Tag routes; document with summary + response_model.
|
||||
|
||||
```python
|
||||
@router.post("/dsr/requests", response_model=DSRRequestRead, status_code=201)
|
||||
async def create_dsr_request(
|
||||
payload: DSRRequestCreate,
|
||||
service: DSRService = Depends(get_dsr_service),
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
) -> DSRRequestRead:
|
||||
try:
|
||||
return await service.create(tenant_id, payload)
|
||||
except DSRConflict as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
```
|
||||
|
||||
## Services
|
||||
|
||||
- Constructor takes the repository (interface, not concrete).
|
||||
- No `Request`, `Response`, or HTTP knowledge.
|
||||
- Raise domain exceptions (e.g. `DSRConflict`, `DSRNotFound`), never `HTTPException`.
|
||||
- Return domain objects or Pydantic schemas — pick one and stay consistent inside a service.
|
||||
|
||||
## Repositories
|
||||
|
||||
- Methods are intent-named (`get_pending_for_tenant`), not CRUD-named (`select_where`).
|
||||
- Sessions injected, not constructed inside.
|
||||
- No business logic. No cross-aggregate joins for unrelated workflows — that belongs in a service.
|
||||
- Return ORM models or domain VOs; never `Row`.
|
||||
|
||||
## Schemas (Pydantic v2)
|
||||
|
||||
- One module per domain. Module ≤300 lines.
|
||||
- Use `model_config = ConfigDict(from_attributes=True, frozen=True)` for read models.
|
||||
- Separate `*Create`, `*Update`, `*Read`. No giant union schemas.
|
||||
|
||||
## Tests (`pytest`)
|
||||
|
||||
- Layout: `tests/unit/`, `tests/integration/`, `tests/contracts/`.
|
||||
- Unit tests mock the repository. Use `pytest.fixture` + `unittest.mock.AsyncMock`.
|
||||
- Integration tests run against the real Postgres from `docker-compose.yml` via a transactional fixture (rollback after each test).
|
||||
- Contract tests diff `/openapi.json` against `tests/contracts/openapi.baseline.json`.
|
||||
- Naming: `test_<unit>_<scenario>_<expected>.py::TestClass::test_method`.
|
||||
- `pytest-asyncio` mode = `auto`. Mark slow tests with `@pytest.mark.slow`.
|
||||
- Coverage target: 80% for new code; never decrease the service baseline.
|
||||
|
||||
## Tooling
|
||||
|
||||
- `ruff check` + `ruff format` (line length 100).
|
||||
- `mypy --strict` on `services/`, `repositories/`, `domain/`. Expand outward.
|
||||
- `pip-audit` in CI.
|
||||
- Async-first: prefer `httpx.AsyncClient`, `asyncpg`/`SQLAlchemy 2.x async`.
|
||||
|
||||
## mypy configuration
|
||||
|
||||
`backend-compliance/mypy.ini` is the mypy config. Strict mode is on globally; per-module overrides exist only for legacy files that have not been cleaned up yet.
|
||||
|
||||
- New modules added to `compliance/services/` or `compliance/repositories/` **must** pass `mypy --strict`.
|
||||
- To type-check a new module: `cd backend-compliance && mypy compliance/your_new_module.py`
|
||||
- When you fully type a legacy file, **remove its loose-override block** from `mypy.ini` as part of the same PR.
|
||||
|
||||
## Dependency injection
|
||||
|
||||
Services and repositories are wired via FastAPI `Depends`. Never instantiate a service or repository directly inside a handler.
|
||||
|
||||
```python
|
||||
# dependencies.py
|
||||
def get_my_service(db: AsyncSession = Depends(get_db)) -> MyService:
|
||||
return MyService(MyRepository(db))
|
||||
|
||||
# router
|
||||
@router.get("/items", response_model=list[ItemRead])
|
||||
async def list_items(svc: MyService = Depends(get_my_service)) -> list[ItemRead]:
|
||||
return await svc.list()
|
||||
```
|
||||
|
||||
- Services take repositories in `__init__`; repositories take `Session` or `AsyncSession`.
|
||||
|
||||
## Structured logging
|
||||
|
||||
```python
|
||||
import structlog
|
||||
logger = structlog.get_logger()
|
||||
|
||||
# Always bind context before logging:
|
||||
logger.bind(tenant_id=str(tid), action="create_dsfa").info("dsfa created")
|
||||
```
|
||||
|
||||
- Audit-relevant actions must use the audit logger with a `legal_basis` field.
|
||||
- Never log secrets, PII, or full request bodies.
|
||||
|
||||
## Barrel re-export pattern
|
||||
|
||||
When an oversized file (e.g. `schemas.py`, `models.py`) is split into a sub-package, the original stays as a **thin re-exporter** so existing consumer imports keep working:
|
||||
|
||||
```python
|
||||
# compliance/schemas.py (barrel — DO NOT ADD NEW CODE HERE)
|
||||
from .schemas.ai import * # noqa: F401, F403
|
||||
from .schemas.consent import * # noqa: F401, F403
|
||||
```
|
||||
|
||||
- New code imports from the specific module (e.g. `from compliance.schemas.ai import AIRiskRead`), not the barrel.
|
||||
- `from module import *` is only permitted in barrel files.
|
||||
|
||||
## Errors & logging
|
||||
|
||||
- Domain errors inherit from a single `DomainError` base per service.
|
||||
- Log via `structlog` with bound context (`tenant_id`, `request_id`). Never log secrets, PII, or full request bodies.
|
||||
- Audit-relevant actions go through the audit logger, not the application logger.
|
||||
|
||||
## Before every push — MANDATORY
|
||||
|
||||
Run all three steps for every Python service you touched before pushing. CI runs the same checks and will fail if you skip this.
|
||||
|
||||
```bash
|
||||
cd <service> # backend-compliance | document-crawler | dsms-gateway | compliance-tts-service
|
||||
|
||||
# 1. Lint
|
||||
ruff check .
|
||||
mypy compliance/ # only for backend-compliance
|
||||
|
||||
# 2. Tests
|
||||
pytest -x
|
||||
|
||||
# 3. Import sanity (catches NameError at collection time)
|
||||
python -c "import compliance" # or the service's main module
|
||||
```
|
||||
|
||||
All steps must exit 0. Do not push if any step fails.
|
||||
|
||||
## What you may NOT do
|
||||
|
||||
- Add a new Alembic migration.
|
||||
- Rename a `__tablename__`, column, or enum value.
|
||||
- Change a public route's path/method/status/schema without simultaneous dashboard fix.
|
||||
- Catch `Exception` broadly — catch the specific domain or library error.
|
||||
- Put business logic in a router or in a Pydantic validator.
|
||||
- `from module import *` in new code — only in barrel re-exporters.
|
||||
- `raise HTTPException` inside the service layer — raise domain exceptions; map them in the router.
|
||||
- Use `model_validate` on untrusted external data without an explicit schema boundary.
|
||||
- Create a new file >500 lines. Period.
|
||||
145
AGENTS.typescript.md
Normal file
145
AGENTS.typescript.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# AGENTS.typescript.md — TypeScript / Next.js Conventions
|
||||
|
||||
Applies to: `admin-compliance/`, `developer-portal/`, `breakpilot-compliance-sdk/`, `consent-sdk/`, `dsms-node/` (where applicable).
|
||||
|
||||
## Layered architecture (Next.js 15 App Router)
|
||||
|
||||
```
|
||||
app/
|
||||
├── <route>/
|
||||
│ ├── page.tsx # Server Component by default. ≤200 LOC.
|
||||
│ ├── layout.tsx
|
||||
│ ├── _components/ # Private folder; not routable. Colocated UI.
|
||||
│ │ └── <Component>.tsx # Each file ≤300 LOC.
|
||||
│ ├── _hooks/ # Client hooks for this route.
|
||||
│ ├── _server/ # Server actions, data loaders for this route.
|
||||
│ └── loading.tsx / error.tsx
|
||||
├── api/
|
||||
│ └── <domain>/route.ts # Thin handler. Delegates to lib/server/<domain>/.
|
||||
lib/
|
||||
├── <domain>/ # Pure helpers, types, schemas (zod). Reusable.
|
||||
└── server/<domain>/ # Server-only logic; uses "server-only" import.
|
||||
components/ # Truly shared, app-wide components.
|
||||
```
|
||||
|
||||
**Server vs Client:** Default is Server Component. Add `"use client"` only when you need state, effects, or browser APIs. Push the boundary as deep as possible.
|
||||
|
||||
## API routes (route.ts)
|
||||
|
||||
- One handler per HTTP method, ≤40 LOC.
|
||||
- Validate input with zod `safeParse` — never `parse` (throws and bypasses error handling).
|
||||
- Delegate to `lib/server/<domain>/`. No business logic in `route.ts`.
|
||||
- Always return `NextResponse.json(..., { status })`. Let the framework's error boundary handle unexpected errors — don't wrap the entire handler in `try/catch`.
|
||||
|
||||
```typescript
|
||||
// app/api/<domain>/route.ts (≤40 LOC)
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { mySchema } from '@/lib/schemas/<domain>';
|
||||
import { myService } from '@/lib/server/<domain>';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = mySchema.safeParse(await req.json());
|
||||
if (!body.success) return NextResponse.json({ error: body.error }, { status: 400 });
|
||||
const result = await myService.create(body.data);
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
}
|
||||
```
|
||||
|
||||
## Page components
|
||||
|
||||
- Pages >300 lines must be split into colocated `_components/`.
|
||||
- Server Components fetch data; pass plain objects to Client Components.
|
||||
- No data fetching in `useEffect` for server-renderable data.
|
||||
- State management: prefer URL state (`searchParams`) and Server Components over global stores.
|
||||
|
||||
## Types
|
||||
|
||||
- `lib/sdk/types.ts` is being split into `lib/sdk/types/<domain>.ts`. Mirror backend domain boundaries.
|
||||
- All API DTOs are zod schemas; infer types via `z.infer`.
|
||||
- No `any`. No `as unknown as`. If you reach for it, the type is wrong.
|
||||
- Always use `import type { Foo }` for type-only imports.
|
||||
- Never use `as` type assertions except when bridging external data at a boundary (add a comment explaining why).
|
||||
- No `@ts-ignore`. `@ts-expect-error` only with a comment explaining the suppression.
|
||||
|
||||
## Barrel re-export pattern
|
||||
|
||||
`lib/sdk/types.ts` is a barrel — it re-exports from domain-specific files. **Do not add new types directly to it.**
|
||||
|
||||
```typescript
|
||||
// lib/sdk/types.ts (barrel — DO NOT ADD NEW TYPES HERE)
|
||||
export * from './types/enums';
|
||||
export * from './types/company-profile';
|
||||
// ... etc.
|
||||
|
||||
// New types go in lib/sdk/types/<domain>.ts
|
||||
```
|
||||
|
||||
- When splitting an oversized file, keep the original as a thin barrel so existing imports don't break.
|
||||
- New code imports directly from the specific module (e.g. `import type { CompanyProfile } from '@/lib/sdk/types/company-profile'`), not the barrel.
|
||||
|
||||
## Server vs Client components
|
||||
|
||||
Default is Server Component. Add `"use client"` only when required:
|
||||
|
||||
| Need | Pattern |
|
||||
|------|---------|
|
||||
| Data fetching only | Server Component (no directive) |
|
||||
| `useState` / `useEffect` | Client Component (`"use client"`) |
|
||||
| Browser API | Client Component |
|
||||
| Event handlers | Client Component |
|
||||
|
||||
- Pass only serializable props from Server → Client Components (no functions, no class instances).
|
||||
- Never add `"use client"` to a layout or page just because one child needs it — extract the client part into a `_components/` file.
|
||||
|
||||
## Tests
|
||||
|
||||
- Unit: **Vitest** (`*.test.ts`/`*.test.tsx`), colocated.
|
||||
- Hooks: `@testing-library/react` `renderHook`.
|
||||
- E2E: **Playwright** (`tests/e2e/`), one spec per top-level page, smoke happy path minimum.
|
||||
- Snapshot tests sparingly — only for stable output (CSV, JSON-LD).
|
||||
- Coverage target: 70% on `lib/`, smoke coverage on `app/`.
|
||||
|
||||
## Tooling
|
||||
|
||||
- `tsc --noEmit` clean (strict mode, `noUncheckedIndexedAccess: true`).
|
||||
- ESLint with `@typescript-eslint`, `eslint-config-next`, type-aware rules on.
|
||||
- `prettier`.
|
||||
- `next build` clean. No `// @ts-ignore`. `// @ts-expect-error` only with a comment explaining why.
|
||||
|
||||
## Before every push — MANDATORY
|
||||
|
||||
Run all three steps for every affected service (`admin-compliance/`, `developer-portal/`) before pushing. CI runs the same checks and will fail if you skip this.
|
||||
|
||||
```bash
|
||||
cd admin-compliance # or developer-portal
|
||||
|
||||
# 1. Build — catches type errors and module resolution failures
|
||||
npm run build
|
||||
|
||||
# 2. Lint
|
||||
npx tsc --noEmit
|
||||
npx eslint . --max-warnings 0
|
||||
|
||||
# 3. Tests
|
||||
npm test
|
||||
```
|
||||
|
||||
All three must exit 0. Do not push if any step fails.
|
||||
|
||||
## Performance
|
||||
|
||||
- Use `next/dynamic` for heavy client-only components.
|
||||
- Image: `next/image` with explicit width/height.
|
||||
- Avoid waterfalls — `Promise.all` for parallel data fetches in Server Components.
|
||||
|
||||
## What you may NOT do
|
||||
|
||||
- Put business logic in a `page.tsx` or `route.ts`.
|
||||
- Reach across module boundaries (e.g. `admin-compliance` importing from `developer-portal`).
|
||||
- Use `dangerouslySetInnerHTML` without DOMPurify sanitization.
|
||||
- Call internal backend APIs directly from Client Components — use Server Components or API routes as a proxy.
|
||||
- Add `"use client"` to a layout or page just because one child needs it — extract the client part.
|
||||
- Spread `...props` onto a DOM element without filtering the props first (type error risk).
|
||||
- Change a public API route's path/method/schema without updating SDK consumers in the same change.
|
||||
- Create a file >500 lines.
|
||||
- Disable a lint or type rule globally to silence a finding — fix the root cause.
|
||||
202
CONTRIBUTING.md
Normal file
202
CONTRIBUTING.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# Contributing to breakpilot-compliance
|
||||
|
||||
---
|
||||
|
||||
## 1. Getting Started
|
||||
|
||||
```bash
|
||||
git clone ssh://git@gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-compliance.git
|
||||
cd breakpilot-compliance
|
||||
```
|
||||
|
||||
**Branch conventions** (branch from `main`):
|
||||
|
||||
| Prefix | Use for |
|
||||
|--------|---------|
|
||||
| `feature/` | New functionality |
|
||||
| `fix/` | Bug fixes |
|
||||
| `chore/` | Tooling, deps, CI, docs |
|
||||
|
||||
Example: `git checkout -b feature/ai-sdk-risk-scoring`
|
||||
|
||||
---
|
||||
|
||||
## 2. Dev Environment
|
||||
|
||||
Each service runs independently. Start only what you need.
|
||||
|
||||
**Go — ai-compliance-sdk**
|
||||
```bash
|
||||
cd ai-compliance-sdk
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
**Python — backend-compliance**
|
||||
```bash
|
||||
cd backend-compliance
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload
|
||||
```
|
||||
|
||||
**Python — dsms-gateway / document-crawler / compliance-tts-service**
|
||||
```bash
|
||||
cd <service>
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload --port <port>
|
||||
```
|
||||
|
||||
**Node.js — admin-compliance**
|
||||
```bash
|
||||
cd admin-compliance
|
||||
npm install
|
||||
npm run dev # http://localhost:3007
|
||||
```
|
||||
|
||||
**Node.js — developer-portal**
|
||||
```bash
|
||||
cd developer-portal
|
||||
npm install
|
||||
npm run dev # http://localhost:3006
|
||||
```
|
||||
|
||||
**All services together (local Docker)**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Config lives in `.env` (not committed). Copy `.env.example` and fill in `COMPLIANCE_DATABASE_URL`, `QDRANT_URL`, `QDRANT_API_KEY`, and Vault tokens.
|
||||
|
||||
---
|
||||
|
||||
## 3. Before Your First Commit
|
||||
|
||||
Run all of these locally. CI will run the same checks and fail if they don't pass.
|
||||
|
||||
**LOC budget (mandatory)**
|
||||
```bash
|
||||
bash scripts/check-loc.sh # must exit 0
|
||||
```
|
||||
|
||||
**Go lint**
|
||||
```bash
|
||||
cd ai-compliance-sdk
|
||||
golangci-lint run --timeout 5m ./...
|
||||
```
|
||||
|
||||
**Python lint**
|
||||
```bash
|
||||
cd backend-compliance
|
||||
ruff check .
|
||||
mypy compliance/ # only if mypy.ini exists
|
||||
```
|
||||
|
||||
**TypeScript type-check**
|
||||
```bash
|
||||
cd admin-compliance
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
**Tests**
|
||||
```bash
|
||||
# Go
|
||||
cd ai-compliance-sdk && go test ./...
|
||||
|
||||
# Python backend
|
||||
cd backend-compliance && pytest
|
||||
|
||||
# DSMS gateway
|
||||
cd dsms-gateway && pytest test_main.py
|
||||
```
|
||||
|
||||
If any step fails, fix it before committing. The git pre-commit hook re-runs `check-loc.sh` automatically.
|
||||
|
||||
---
|
||||
|
||||
## 4. Commit Message Rules
|
||||
|
||||
Use [Conventional Commits](https://www.conventionalcommits.org/) style:
|
||||
|
||||
```
|
||||
<type>(<scope>): <short summary>
|
||||
|
||||
[optional body]
|
||||
[optional footer]
|
||||
```
|
||||
|
||||
Types: `feat`, `fix`, `chore`, `refactor`, `test`, `docs`, `ci`.
|
||||
|
||||
### `[guardrail-change]` marker — REQUIRED
|
||||
|
||||
Add `[guardrail-change]` anywhere in the commit message body (or footer) when your changeset touches **any** of these files:
|
||||
|
||||
| File / path | Reason protected |
|
||||
|-------------|-----------------|
|
||||
| `.claude/settings.json` | PreToolUse/PostToolUse hooks |
|
||||
| `scripts/check-loc.sh` | LOC enforcement script |
|
||||
| `scripts/githooks/pre-commit` | Git hook |
|
||||
| `.claude/rules/loc-exceptions.txt` | Exception registry |
|
||||
| `AGENTS.*.md` (any) | Per-language architecture rules |
|
||||
|
||||
The `guardrail-integrity` CI job checks for this marker and **fails the build** if it is missing.
|
||||
|
||||
**Valid guardrail commit example:**
|
||||
```
|
||||
chore(guardrail): add exception for generated protobuf file
|
||||
|
||||
proto/generated/compliance.pb.go exceeds 500 LOC because it is
|
||||
machine-generated and cannot be split. Added to loc-exceptions.txt
|
||||
with rationale.
|
||||
|
||||
[guardrail-change]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Architecture Rules (Non-Negotiable)
|
||||
|
||||
### File budget
|
||||
- **500 LOC hard cap** on every non-test, non-generated source file.
|
||||
- The `PreToolUse` hook in `.claude/settings.json` blocks Claude Code from creating or editing files that would breach this limit.
|
||||
- Exceptions require a written rationale in `.claude/rules/loc-exceptions.txt` plus `[guardrail-change]` in the commit.
|
||||
|
||||
### Clean architecture per service
|
||||
- Python (FastAPI): `api → services → repositories → db.models`. Handlers ≤ 30 LOC. See `AGENTS.python.md`.
|
||||
- Go (Gin): Standard Go Project Layout + hexagonal. `cmd/` is thin wiring. See `AGENTS.go.md`.
|
||||
- TypeScript (Next.js 15): server-first, push client boundary deep, colocate `_components/` + `_hooks/` per route. See `AGENTS.typescript.md`.
|
||||
|
||||
### Database is frozen
|
||||
- No new Alembic migrations, no `ALTER TABLE`, no `__tablename__` or column renames.
|
||||
- The pre-commit hook blocks any change under `migrations/` or `alembic/versions/` unless the commit message contains `[migration-approved]`.
|
||||
|
||||
### Public endpoints are a contract
|
||||
- Any change to a route path, HTTP method, status code, request schema, or response schema in `backend-compliance/`, `ai-compliance-sdk/`, `dsms-gateway/`, `document-crawler/`, or `compliance-tts-service/` **must** be accompanied by a matching update in every consumer (`admin-compliance/`, `developer-portal/`, `breakpilot-compliance-sdk/`, `consent-sdk/`) in the **same changeset**.
|
||||
- OpenAPI baseline snapshots live in `tests/contracts/`. Contract tests fail on any drift.
|
||||
|
||||
---
|
||||
|
||||
## 6. Pull Requests
|
||||
|
||||
- **Target branch: `main`** — squash merge your feature branch into `main`.
|
||||
- Keep PRs focused; one logical change per PR.
|
||||
|
||||
**PR checklist before requesting review:**
|
||||
|
||||
- [ ] `bash scripts/check-loc.sh` exits 0
|
||||
- [ ] All lint checks pass (go, python, tsc)
|
||||
- [ ] All tests pass locally
|
||||
- [ ] No endpoint drift without consumer updates in the same PR
|
||||
- [ ] `[guardrail-change]` present in commit message if guardrail files were touched
|
||||
- [ ] Docs updated if new endpoints, config vars, or architecture changed
|
||||
|
||||
---
|
||||
|
||||
## 7. Claude Code Users
|
||||
|
||||
This section is for AI-assisted development sessions using Claude Code.
|
||||
|
||||
- **Always work on a feature branch** (`feat/*`, `feature/*`, `hotfix/*`), never directly on `main`.
|
||||
- The `.claude/settings.json` `PreToolUse` hooks will automatically block Write/Edit operations on files that would exceed 500 lines. This is intentional — split the file instead.
|
||||
- If the `guardrail-integrity` CI job fails, check that your commit message body includes `[guardrail-change]`. Add it and amend or create a fixup commit.
|
||||
- **Never use `git add -A` or `git add .`** — always stage specific files by path to avoid accidentally committing `.env`, `node_modules/`, `.next/`, or compiled binaries.
|
||||
- After every session: `bash scripts/check-loc.sh` must exit 0 before pushing.
|
||||
- Read `CLAUDE.md` and the relevant `AGENTS.<lang>.md` before starting work on a service.
|
||||
128
README.md
Normal file
128
README.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# breakpilot-compliance
|
||||
|
||||
**DSGVO/AI-Act compliance platform — 10 services, Go · Python · TypeScript**
|
||||
|
||||
[](https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions)
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
breakpilot-compliance is a multi-tenant DSGVO/EU AI Act compliance platform that provides an SDK for consent management, data subject requests (DSR), audit logging, iACE impact assessments, and document archival. It ships as 10 containerised services covering an admin dashboard, a developer portal, a Python/FastAPI backend, a Go AI compliance engine, TTS, and a decentralised document store on IPFS. Every service is deployed automatically via Gitea Actions → Orca on every push to `main`.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
| Service | Tech | Port | Container |
|
||||
|---------|------|------|-----------|
|
||||
| admin-compliance | Next.js 15 | 3007 | bp-compliance-admin |
|
||||
| backend-compliance | Python / FastAPI 0.123 | 8002 | bp-compliance-backend |
|
||||
| ai-compliance-sdk | Go 1.24 / Gin | 8093 | bp-compliance-ai-sdk |
|
||||
| developer-portal | Next.js 15 | 3006 | bp-compliance-developer-portal |
|
||||
| breakpilot-compliance-sdk | TypeScript SDK (React/Vue/Angular/vanilla) | — | — |
|
||||
| consent-sdk | JS/TS Consent SDK | — | — |
|
||||
| compliance-tts-service | Python / Piper TTS | 8095 | bp-compliance-tts |
|
||||
| document-crawler | Python / FastAPI | 8098 | bp-compliance-document-crawler |
|
||||
| dsms-gateway | Python / FastAPI / IPFS | 8082 | bp-compliance-dsms-gateway |
|
||||
| dsms-node | IPFS Kubo v0.24.0 | — | bp-compliance-dsms-node |
|
||||
|
||||
All containers share the external `breakpilot-network` Docker network and depend on `breakpilot-core` (Valkey, Vault, RAG service, Nginx reverse proxy).
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Prerequisites:** Docker, Go 1.24+, Python 3.12+, Node.js 20+
|
||||
|
||||
```bash
|
||||
git clone ssh://git@gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-compliance.git
|
||||
cd breakpilot-compliance
|
||||
|
||||
# Copy and populate secrets (never commit .env)
|
||||
cp .env.example .env
|
||||
|
||||
# Start all services
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
For the Orca/Hetzner production target (x86_64), use the override:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.hetzner.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development Workflow
|
||||
|
||||
Use feature branches off `main`. Supported prefixes: `feat/`, `feature/`, `hotfix/`.
|
||||
|
||||
```bash
|
||||
git checkout main && git pull origin main
|
||||
git checkout -b feat/my-change
|
||||
# ... make changes ...
|
||||
git push origin feat/my-change
|
||||
# Open a PR → squash merge to main
|
||||
```
|
||||
|
||||
Push to `main` triggers:
|
||||
1. **Gitea Actions** — lint → test → validate (see CI Pipeline below)
|
||||
2. **Orca** — automatic build + deploy (~3 min total)
|
||||
|
||||
Monitor status: <https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions>
|
||||
|
||||
---
|
||||
|
||||
## CI Pipeline
|
||||
|
||||
Defined in `.gitea/workflows/ci.yaml`.
|
||||
|
||||
| Job | What it checks |
|
||||
|-----|----------------|
|
||||
| `loc-budget` | All source files ≤ 500 LOC; soft target 300 |
|
||||
| `guardrail-integrity` | Commits touching guardrail files carry `[guardrail-change]` |
|
||||
| `go-lint` | `golangci-lint` on `ai-compliance-sdk/` |
|
||||
| `python-lint` | `ruff` + `mypy` on Python services |
|
||||
| `nodejs-lint` | `tsc --noEmit` + ESLint on Next.js services |
|
||||
| `test-go-ai-compliance` | `go test ./...` in `ai-compliance-sdk/` |
|
||||
| `test-python-backend-compliance` | `pytest` in `backend-compliance/` |
|
||||
| `test-python-document-crawler` | `pytest` in `document-crawler/` |
|
||||
| `test-python-dsms-gateway` | `pytest test_main.py` in `dsms-gateway/` |
|
||||
| `sbom-scan` | License + vulnerability scan via `syft` + `grype` |
|
||||
| `validate-canonical-controls` | OpenAPI contract baseline diff |
|
||||
|
||||
---
|
||||
|
||||
## File Budget
|
||||
|
||||
| Limit | Value | How to check |
|
||||
|-------|-------|--------------|
|
||||
| Soft target | 300 LOC | `bash scripts/check-loc.sh` |
|
||||
| Hard cap | 500 LOC | Same; also enforced by `PreToolUse` hook + git pre-commit + CI |
|
||||
| Exceptions | `.claude/rules/loc-exceptions.txt` | Require written rationale + `[guardrail-change]` commit marker |
|
||||
|
||||
The `.claude/settings.json` `PreToolUse` hook blocks Claude Code from writing or editing files that would exceed the hard cap. The git pre-commit hook re-checks. CI is the final gate.
|
||||
|
||||
---
|
||||
|
||||
## Links
|
||||
|
||||
| | URL |
|
||||
|-|-----|
|
||||
| Admin dashboard | <https://admin-dev.breakpilot.ai> |
|
||||
| Developer portal | <https://developers-dev.breakpilot.ai> |
|
||||
| Backend API | <https://api-dev.breakpilot.ai> |
|
||||
| AI SDK API | <https://sdk-dev.breakpilot.ai> |
|
||||
| Gitea repo | <https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance> |
|
||||
| Gitea Actions | <https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions> |
|
||||
|
||||
915
REFACTOR_PLAYBOOK.md
Normal file
915
REFACTOR_PLAYBOOK.md
Normal file
@@ -0,0 +1,915 @@
|
||||
|
||||
---
|
||||
|
||||
## 1.9 `AGENTS.python.md` — Python / FastAPI conventions
|
||||
|
||||
```markdown
|
||||
# AGENTS.python.md — Python Service Conventions
|
||||
|
||||
## Layered architecture (FastAPI)
|
||||
|
||||
|
||||
## 1. Guardrail files (drop these in first)
|
||||
|
||||
These artifacts enforce the rules without you or Claude having to remember them. Install them as **Phase 0**, before touching any real code.
|
||||
|
||||
### 1.1 `.claude/CLAUDE.md` — loaded into every Claude session
|
||||
|
||||
```markdown
|
||||
# <Your Project Name>
|
||||
|
||||
> **NON-NEGOTIABLE STRUCTURE RULES** (enforced by `.claude/settings.json` hook, git pre-commit, and CI):
|
||||
> 1. **File-size budget:** soft target **300** lines, **hard cap 500** lines for any non-test, non-generated source file. Anything larger → split it. Exceptions are listed in `.claude/rules/loc-exceptions.txt` and require a written rationale.
|
||||
> 2. **Clean architecture per service.** Routers/handlers stay thin (≤30 lines per handler) and delegate to services; services use repositories; repositories own DB I/O. See `AGENTS.python.md` / `AGENTS.go.md` / `AGENTS.typescript.md`.
|
||||
> 3. **Do not touch the database schema.** No new migrations, no `ALTER TABLE`, no model field renames without an explicit migration plan reviewed by the DB owner.
|
||||
> 4. **Public endpoints are a contract.** Any change to a path/method/status/schema in a backend must be accompanied by a matching update in **every** consumer. OpenAPI snapshot tests in `tests/contracts/` are the gate.
|
||||
> 5. **Tests are not optional.** New code without tests fails CI. Refactors must preserve coverage and add a characterization test before splitting an oversized file.
|
||||
> 6. **Do not bypass the guardrails.** Do not edit `.claude/settings.json`, `scripts/check-loc.sh`, or the loc-exceptions list to silence violations. If a rule is wrong, raise it in a PR description.
|
||||
>
|
||||
> These rules apply to every Claude Code session opened inside this repository, regardless of who launched it. They are loaded automatically via this CLAUDE.md.
|
||||
```
|
||||
|
||||
Keep project-specific notes (dev environment, URLs, tech stack) under this header.
|
||||
|
||||
### 1.2 `.claude/settings.json` — PreToolUse LOC hook
|
||||
|
||||
First line of defense. Blocks Write/Edit operations that would create or push a file past 500 lines. This stops Claude from ever producing oversized files.
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "f=$(jq -r '.tool_input.file_path // empty'); [ -z \"$f\" ] && exit 0; lines=$(printf '%s' \"$(jq -r '.tool_input.content // empty')\" | awk 'END{print NR}'); if [ \"${lines:-0}\" -gt 500 ]; then echo '{\"decision\":\"block\",\"reason\":\"guardrail: file exceeds the 500-line hard cap. Split it into smaller modules per the layering rules in AGENTS.<lang>.md.\"}'; exit 0; fi",
|
||||
"shell": "bash",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "f=$(jq -r '.tool_input.file_path // empty'); [ -z \"$f\" ] || [ ! -f \"$f\" ] && exit 0; case \"$f\" in *.md|*.json|*.yaml|*.yml|*test*|*tests/*|*node_modules/*|*.next/*|*migrations/*) exit 0 ;; esac; new_str=$(jq -r '.tool_input.new_string // empty'); old_str=$(jq -r '.tool_input.old_string // empty'); old_lines=$(printf '%s' \"$old_str\" | awk 'END{print NR}'); new_lines=$(printf '%s' \"$new_str\" | awk 'END{print NR}'); cur=$(wc -l < \"$f\" | tr -d ' '); proj=$((cur - old_lines + new_lines)); if [ \"$proj\" -gt 500 ]; then echo \"{\\\"decision\\\":\\\"block\\\",\\\"reason\\\":\\\"guardrail: this edit would push $f to ~$proj lines (hard cap is 500). Split the file before continuing.\\\"}\"; fi; exit 0",
|
||||
"shell": "bash",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 `.claude/rules/architecture.md` — auto-loaded architecture rule
|
||||
|
||||
```markdown
|
||||
# Architecture Rules (auto-loaded)
|
||||
|
||||
Non-negotiable. Applied to every Claude Code session in this repo.
|
||||
|
||||
## File-size budget
|
||||
- **Soft target:** 300 lines. **Hard cap:** 500 lines.
|
||||
- Enforced by PreToolUse hook, pre-commit hook, and CI.
|
||||
- Exceptions live in `.claude/rules/loc-exceptions.txt` and require `[guardrail-change]` in the commit message. This list should SHRINK over time.
|
||||
|
||||
## Clean architecture
|
||||
- Python: see `AGENTS.python.md`. Layering: api → services → repositories → db.models.
|
||||
- Go: see `AGENTS.go.md`. Standard Go Project Layout + hexagonal.
|
||||
- TypeScript: see `AGENTS.typescript.md`. Server-by-default, push client boundary deep, colocate `_components/` and `_hooks/` per route.
|
||||
|
||||
## Database is frozen
|
||||
- No new migrations. No `ALTER TABLE`. No column renames.
|
||||
- Pre-commit hook blocks any change under `migrations/` unless commit message contains `[migration-approved]`.
|
||||
|
||||
## Public endpoints are a contract
|
||||
- Any change to path/method/status/schema must update every consumer in the same change set.
|
||||
- OpenAPI baseline at `tests/contracts/openapi.baseline.json`. Contract tests fail on drift.
|
||||
|
||||
## Tests
|
||||
- New code without tests fails CI.
|
||||
- Refactors preserve coverage. Before splitting an oversized file, add a characterization test pinning current behavior.
|
||||
- Layout: `tests/unit/`, `tests/integration/`, `tests/contracts/`, `tests/e2e/`.
|
||||
|
||||
## Guardrails are protected
|
||||
- Edits to `.claude/settings.json`, `scripts/check-loc.sh`, `scripts/githooks/pre-commit`, `.claude/rules/loc-exceptions.txt`, or any `AGENTS.*.md` require `[guardrail-change]` in the commit message.
|
||||
- If Claude thinks a rule is wrong, surface it to the user. Do not silently weaken.
|
||||
|
||||
## Tooling baseline
|
||||
- Python: `ruff`, `mypy --strict` on new modules, `pytest --cov`.
|
||||
- Go: `golangci-lint` strict, `go vet`, table-driven tests.
|
||||
- TS: `tsc --noEmit` strict, ESLint type-aware, Vitest, Playwright.
|
||||
- All: dependency caching in CI, license/SBOM scan via `syft`+`grype`.
|
||||
```
|
||||
|
||||
### 1.4 `.claude/rules/loc-exceptions.txt`
|
||||
|
||||
```
|
||||
# loc-exceptions.txt — files allowed to exceed the 500-line hard cap.
|
||||
#
|
||||
# Format: one repo-relative path per line. Comments start with '#'.
|
||||
# Each exception MUST be preceded by a comment explaining why splitting is not viable.
|
||||
# Goal: this list SHRINKS over time.
|
||||
|
||||
# --- Example entries ---
|
||||
# Static data catalogs — splitting fragments lookup tables without improving readability.
|
||||
# src/catalogs/country-data.ts
|
||||
# src/catalogs/industry-taxonomy.ts
|
||||
|
||||
# Generated files — regenerated from schemas.
|
||||
# api/generated/types.ts
|
||||
```
|
||||
|
||||
|
||||
### 1.5 `scripts/check-loc.sh`
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# check-loc.sh — File-size budget enforcer. Soft: 300. Hard: 500.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/check-loc.sh # scan whole repo
|
||||
# scripts/check-loc.sh --changed # only files changed vs origin/main
|
||||
# scripts/check-loc.sh path/to/file.py # check specific files
|
||||
# scripts/check-loc.sh --json # machine-readable output
|
||||
# Exit codes: 0 clean, 1 hard violation, 2 bad invocation.
|
||||
|
||||
set -euo pipefail
|
||||
SOFT=300
|
||||
HARD=500
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
EXCEPTIONS_FILE="$REPO_ROOT/.claude/rules/loc-exceptions.txt"
|
||||
|
||||
CHANGED_ONLY=0; JSON=0; TARGETS=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--changed) CHANGED_ONLY=1 ;;
|
||||
--json) JSON=1 ;;
|
||||
-h|--help) sed -n '2,10p' "$0"; exit 0 ;;
|
||||
-*) echo "unknown flag: $arg" >&2; exit 2 ;;
|
||||
*) TARGETS+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
is_excluded() {
|
||||
local f="$1"
|
||||
case "$f" in
|
||||
*/node_modules/*|*/.next/*|*/.git/*|*/dist/*|*/build/*|*/__pycache__/*|*/vendor/*) return 0 ;;
|
||||
*/migrations/*|*/alembic/versions/*) return 0 ;;
|
||||
*_test.go|*.test.ts|*.test.tsx|*.spec.ts|*.spec.tsx) return 0 ;;
|
||||
*/tests/*|*/test/*) return 0 ;;
|
||||
*.md|*.json|*.yaml|*.yml|*.lock|*.sum|*.mod|*.toml|*.cfg|*.ini) return 0 ;;
|
||||
*.svg|*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf|*.woff|*.woff2|*.ttf) return 0 ;;
|
||||
*.generated.*|*.gen.*|*_pb.go|*_pb2.py|*.pb.go) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
is_in_exceptions() {
|
||||
[[ -f "$EXCEPTIONS_FILE" ]] || return 1
|
||||
local rel="${1#$REPO_ROOT/}"
|
||||
grep -Fxq "$rel" "$EXCEPTIONS_FILE"
|
||||
}
|
||||
collect_targets() {
|
||||
if (( ${#TARGETS[@]} > 0 )); then printf '%s\n' "${TARGETS[@]}"
|
||||
elif (( CHANGED_ONLY )); then
|
||||
git -C "$REPO_ROOT" diff --name-only --diff-filter=AM origin/main...HEAD 2>/dev/null \
|
||||
|| git -C "$REPO_ROOT" diff --name-only --diff-filter=AM HEAD
|
||||
else git -C "$REPO_ROOT" ls-files; fi
|
||||
}
|
||||
|
||||
violations_hard=(); violations_soft=()
|
||||
while IFS= read -r f; do
|
||||
[[ -z "$f" ]] && continue
|
||||
abs="$f"; [[ "$abs" != /* ]] && abs="$REPO_ROOT/$f"
|
||||
[[ -f "$abs" ]] || continue
|
||||
is_excluded "$abs" && continue
|
||||
is_in_exceptions "$abs" && continue
|
||||
loc=$(wc -l < "$abs" | tr -d ' ')
|
||||
if (( loc > HARD )); then violations_hard+=("$loc $f")
|
||||
elif (( loc > SOFT )); then violations_soft+=("$loc $f"); fi
|
||||
done < <(collect_targets)
|
||||
|
||||
if (( JSON )); then
|
||||
printf '{"hard":['
|
||||
first=1; for v in "${violations_hard[@]}"; do
|
||||
loc="${v%% *}"; path="${v#* }"
|
||||
(( first )) || printf ','; first=0
|
||||
printf '{"loc":%s,"path":"%s"}' "$loc" "$path"
|
||||
done
|
||||
printf '],"soft":['
|
||||
first=1; for v in "${violations_soft[@]}"; do
|
||||
loc="${v%% *}"; path="${v#* }"
|
||||
(( first )) || printf ','; first=0
|
||||
printf '{"loc":%s,"path":"%s"}' "$loc" "$path"
|
||||
done
|
||||
printf ']}\n'
|
||||
else
|
||||
if (( ${#violations_soft[@]} > 0 )); then
|
||||
echo "::warning:: $((${#violations_soft[@]})) file(s) exceed soft target ($SOFT lines):"
|
||||
printf ' %s\n' "${violations_soft[@]}" | sort -rn
|
||||
fi
|
||||
if (( ${#violations_hard[@]} > 0 )); then
|
||||
echo "::error:: $((${#violations_hard[@]})) file(s) exceed HARD CAP ($HARD lines) — split required:"
|
||||
printf ' %s\n' "${violations_hard[@]}" | sort -rn
|
||||
fi
|
||||
fi
|
||||
(( ${#violations_hard[@]} == 0 ))
|
||||
```
|
||||
|
||||
Make executable: `chmod +x scripts/check-loc.sh`.
|
||||
|
||||
### 1.6 `scripts/githooks/pre-commit`
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# pre-commit — enforces structural guardrails.
|
||||
#
|
||||
# 1. Blocks commits that introduce a non-test, non-generated source file > 500 LOC.
|
||||
# 2. Blocks commits touching migrations/ unless commit message contains [migration-approved].
|
||||
# 3. Blocks edits to guardrail files unless [guardrail-change] is in the commit message.
|
||||
|
||||
set -euo pipefail
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
|
||||
mapfile -t staged < <(git diff --cached --name-only --diff-filter=ACM)
|
||||
[[ ${#staged[@]} -eq 0 ]] && exit 0
|
||||
|
||||
# 1. LOC budget on staged files.
|
||||
loc_targets=()
|
||||
for f in "${staged[@]}"; do
|
||||
[[ -f "$REPO_ROOT/$f" ]] && loc_targets+=("$REPO_ROOT/$f")
|
||||
done
|
||||
if [[ ${#loc_targets[@]} -gt 0 ]]; then
|
||||
if ! "$REPO_ROOT/scripts/check-loc.sh" "${loc_targets[@]}"; then
|
||||
echo; echo "Commit blocked: file-size budget violated."
|
||||
echo "Split the file (preferred) or add to .claude/rules/loc-exceptions.txt."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 2. Migrations frozen unless approved.
|
||||
if printf '%s\n' "${staged[@]}" | grep -qE '(^|/)(migrations|alembic/versions)/'; then
|
||||
if ! grep -q '\[migration-approved\]' "$(git rev-parse --git-dir)/COMMIT_EDITMSG" 2>/dev/null; then
|
||||
echo "Commit blocked: this change touches a migrations directory."
|
||||
echo "Add '[migration-approved]' to your commit message if approved."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3. Guardrail files protected.
|
||||
guarded='^(\.claude/settings\.json|\.claude/rules/loc-exceptions\.txt|scripts/check-loc\.sh|scripts/githooks/pre-commit|AGENTS\.(python|go|typescript)\.md)$'
|
||||
if printf '%s\n' "${staged[@]}" | grep -qE "$guarded"; then
|
||||
if ! grep -q '\[guardrail-change\]' "$(git rev-parse --git-dir)/COMMIT_EDITMSG" 2>/dev/null; then
|
||||
echo "Commit blocked: this change modifies guardrail files."
|
||||
echo "Add '[guardrail-change]' to your commit message and explain why in the body."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
```
|
||||
|
||||
### 1.7 `scripts/install-hooks.sh`
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# install-hooks.sh — installs git hooks that enforce repo guardrails locally.
|
||||
# Idempotent. Safe to re-run. Run once per clone: bash scripts/install-hooks.sh
|
||||
set -euo pipefail
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
HOOKS_DIR="$REPO_ROOT/.git/hooks"
|
||||
SRC_DIR="$REPO_ROOT/scripts/githooks"
|
||||
|
||||
[[ -d "$REPO_ROOT/.git" ]] || { echo "Not a git repository: $REPO_ROOT" >&2; exit 1; }
|
||||
mkdir -p "$HOOKS_DIR"
|
||||
for hook in pre-commit; do
|
||||
src="$SRC_DIR/$hook"; dst="$HOOKS_DIR/$hook"
|
||||
if [[ -f "$src" ]]; then cp "$src" "$dst"; chmod +x "$dst"; echo "installed: $dst"; fi
|
||||
done
|
||||
echo "Done. Hooks active for this clone."
|
||||
```
|
||||
|
||||
### 1.8 CI additions (`.github/workflows/ci.yaml` or `.gitea/workflows/ci.yaml`)
|
||||
|
||||
Add a `loc-budget` job that fails on hard violations:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
loc-budget:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check file-size budget
|
||||
run: bash scripts/check-loc.sh --changed
|
||||
|
||||
python-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: ruff
|
||||
run: pip install ruff && ruff check .
|
||||
- name: mypy on new modules
|
||||
run: pip install mypy && mypy --strict services/ repositories/ domain/
|
||||
|
||||
go-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v4
|
||||
with: { version: latest }
|
||||
|
||||
ts-lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: npm ci && npx tsc --noEmit && npx next build
|
||||
|
||||
contract-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: pytest tests/contracts/ -v
|
||||
|
||||
license-sbom-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: anchore/sbom-action@v0
|
||||
- uses: anchore/scan-action@v3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### 1.9 `AGENTS.python.md` (Python / FastAPI)
|
||||
|
||||
````markdown
|
||||
# AGENTS.python.md — Python Service Conventions
|
||||
|
||||
## Layered architecture
|
||||
|
||||
```
|
||||
<service>/
|
||||
├── api/ # HTTP layer — routers only. Thin (≤30 LOC per handler).
|
||||
│ └── <domain>_routes.py
|
||||
├── services/ # Business logic. Pure-ish; no FastAPI imports.
|
||||
├── repositories/ # DB access. Owns SQLAlchemy session usage.
|
||||
├── domain/ # Value objects, enums, domain exceptions.
|
||||
├── schemas/ # Pydantic models, split per domain. Never one giant schemas.py.
|
||||
└── db/models/ # SQLAlchemy ORM, one module per aggregate. __tablename__ frozen.
|
||||
```
|
||||
|
||||
Dependency direction: `api → services → repositories → db.models`. Lower layers must not import upper.
|
||||
|
||||
## Routers
|
||||
- One `APIRouter` per domain file. Handlers ≤30 LOC.
|
||||
- Parse request → call service → map domain errors → return response model.
|
||||
- Inject services via `Depends`. No globals.
|
||||
|
||||
```python
|
||||
@router.post("/items", response_model=ItemRead, status_code=201)
|
||||
async def create_item(
|
||||
payload: ItemCreate,
|
||||
service: ItemService = Depends(get_item_service),
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
) -> ItemRead:
|
||||
with translate_domain_errors():
|
||||
return await service.create(tenant_id, payload)
|
||||
```
|
||||
|
||||
## Domain errors + translator
|
||||
|
||||
```python
|
||||
# domain/errors.py
|
||||
class DomainError(Exception): ...
|
||||
class NotFoundError(DomainError): ...
|
||||
class ConflictError(DomainError): ...
|
||||
class ValidationError(DomainError): ...
|
||||
class PermissionError(DomainError): ...
|
||||
|
||||
# api/_http_errors.py
|
||||
from contextlib import contextmanager
|
||||
from fastapi import HTTPException
|
||||
|
||||
@contextmanager
|
||||
def translate_domain_errors():
|
||||
try: yield
|
||||
except NotFoundError as e: raise HTTPException(404, str(e)) from e
|
||||
except ConflictError as e: raise HTTPException(409, str(e)) from e
|
||||
except ValidationError as e: raise HTTPException(400, str(e)) from e
|
||||
except PermissionError as e: raise HTTPException(403, str(e)) from e
|
||||
```
|
||||
|
||||
## Services
|
||||
- Constructor takes repository interface, not concrete.
|
||||
- No FastAPI / HTTP knowledge.
|
||||
- Raise domain exceptions, never HTTPException.
|
||||
|
||||
## Repositories
|
||||
- Intent-named methods (`get_pending_for_tenant`), not CRUD-named (`select_where`).
|
||||
- Session injected. No business logic.
|
||||
- Return ORM models or domain VOs; never `Row`.
|
||||
|
||||
## Schemas (Pydantic v2)
|
||||
- One module per domain. ≤300 lines.
|
||||
- `model_config = ConfigDict(from_attributes=True, frozen=True)` for reads.
|
||||
- Separate `*Create`, `*Update`, `*Read`.
|
||||
|
||||
## Tests
|
||||
- `tests/unit/`, `tests/integration/`, `tests/contracts/`.
|
||||
- Unit tests mock repository via `AsyncMock`.
|
||||
- Integration tests use real Postgres from compose via transactional fixture (rollback per test).
|
||||
- Contract tests diff `/openapi.json` against `tests/contracts/openapi.baseline.json`.
|
||||
- Naming: `test_<unit>_<scenario>_<expected>.py::TestX::test_method`.
|
||||
- `pytest-asyncio` mode = `auto`. Coverage target: 80% new code.
|
||||
|
||||
## Tooling
|
||||
- `ruff check` + `ruff format` (line length 100).
|
||||
- `mypy --strict` on `services/`, `repositories/`, `domain/` first. Expand outward via per-module overrides in mypy.ini:
|
||||
|
||||
```ini
|
||||
[mypy]
|
||||
strict = True
|
||||
|
||||
[mypy-<service>.services.*]
|
||||
strict = True
|
||||
|
||||
[mypy-<service>.legacy.*]
|
||||
# Legacy modules not yet refactored — expand strictness over time.
|
||||
ignore_errors = True
|
||||
```
|
||||
|
||||
## What you may NOT do
|
||||
- Add a new migration.
|
||||
- Rename `__tablename__`, column, or enum value.
|
||||
- Change route contract without simultaneous consumer update.
|
||||
- Catch `Exception` broadly.
|
||||
- Put business logic in a router or a Pydantic validator.
|
||||
- Create a file > 500 lines.
|
||||
````
|
||||
|
||||
### 1.10 `AGENTS.go.md` (Go / Gin or chi)
|
||||
|
||||
````markdown
|
||||
# AGENTS.go.md — Go Service Conventions
|
||||
|
||||
## Layered architecture (Standard Go Project Layout + hexagonal)
|
||||
|
||||
```
|
||||
<service>/
|
||||
├── cmd/server/main.go # Thin: parse flags → app.New → app.Run. < 50 LOC.
|
||||
├── internal/
|
||||
│ ├── app/ # Wiring: config + DI + lifecycle.
|
||||
│ ├── domain/<aggregate>/ # Pure types, interfaces, errors. No I/O.
|
||||
│ ├── service/<aggregate>/ # Business logic. Depends on domain interfaces.
|
||||
│ ├── repository/postgres/<aggregate>/ # Concrete repos.
|
||||
│ ├── transport/http/
|
||||
│ │ ├── handler/<aggregate>/
|
||||
│ │ ├── middleware/
|
||||
│ │ └── router.go
|
||||
│ └── platform/ # DB pool, logger, config, tracing.
|
||||
└── pkg/ # Importable by other repos. Empty unless needed.
|
||||
```
|
||||
|
||||
Direction: `transport → service → domain ← repository`. `domain` imports no siblings.
|
||||
|
||||
## Handlers
|
||||
- ≤40 LOC. Bind → call service → map error via `httperr.Write(c, err)` → respond.
|
||||
|
||||
```go
|
||||
func (h *ItemHandler) Create(c *gin.Context) {
|
||||
var req CreateItemRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Write(c, httperr.BadRequest(err)); return
|
||||
}
|
||||
out, err := h.svc.Create(c.Request.Context(), req.ToInput())
|
||||
if err != nil { httperr.Write(c, err); return }
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
```
|
||||
|
||||
## Errors — single `httperr` package
|
||||
```go
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrNotFound): return 404
|
||||
case errors.Is(err, domain.ErrConflict): return 409
|
||||
case errors.As(err, &validationErr): return 422
|
||||
default: return 500
|
||||
}
|
||||
```
|
||||
Never `panic` in request handling. Recovery middleware logs and returns 500.
|
||||
|
||||
## Services
|
||||
- Struct + constructor + interface methods. No package-level state.
|
||||
- `context.Context` first arg always.
|
||||
- Return `(value, error)`. Wrap with `fmt.Errorf("create item: %w", err)`.
|
||||
- Domain errors as sentinel vars or typed; match with `errors.Is` / `errors.As`.
|
||||
|
||||
## Repositories
|
||||
- Interface in `domain/<aggregate>/repository.go`. Impl in `repository/postgres/<aggregate>/`.
|
||||
- One file per query group; no file > 500 LOC.
|
||||
- `pgx`/`sqlc` over hand-rolled SQL. No ORM globals. Everything takes `ctx`.
|
||||
|
||||
## Tests
|
||||
- Co-located `*_test.go`. Table-driven for service logic.
|
||||
- Handlers via `httptest.NewRecorder`.
|
||||
- Repos via `testcontainers-go` (or the compose Postgres). Never mocks at SQL boundary.
|
||||
- Coverage target: 80% on `service/`.
|
||||
|
||||
## Tooling (`golangci-lint` strict config)
|
||||
- Linters: `errcheck, govet, staticcheck, revive, gosec, gocyclo(max 15), gocognit(max 20), unused, ineffassign, errorlint, nilerr, nolintlint, contextcheck`.
|
||||
- `gofumpt` formatting. `go vet ./...` clean. `go mod tidy` clean.
|
||||
|
||||
## What you may NOT do
|
||||
- Touch DB schema/migrations.
|
||||
- Add a new top-level package under `internal/` without review.
|
||||
- `import "C"`, unsafe, reflection-heavy code.
|
||||
- Non-trivial setup in `init()`. Wire in `internal/app`.
|
||||
- File > 500 lines.
|
||||
- Change route contract without updating consumers.
|
||||
````
|
||||
|
||||
### 1.11 `AGENTS.typescript.md` (TypeScript / Next.js)
|
||||
|
||||
````markdown
|
||||
# AGENTS.typescript.md — TypeScript / Next.js Conventions
|
||||
|
||||
## Layered architecture (Next.js 15 App Router)
|
||||
|
||||
```
|
||||
app/
|
||||
├── <route>/
|
||||
│ ├── page.tsx # Server Component by default. ≤200 LOC.
|
||||
│ ├── layout.tsx
|
||||
│ ├── _components/ # Private folder; colocated UI. Each file ≤300 LOC.
|
||||
│ ├── _hooks/ # Client hooks for this route.
|
||||
│ ├── _server/ # Server actions, data loaders for this route.
|
||||
│ └── loading.tsx / error.tsx
|
||||
├── api/<domain>/route.ts # Thin handler. Delegates to lib/server/<domain>/.
|
||||
lib/
|
||||
├── <domain>/ # Pure helpers, types, zod schemas. Reusable.
|
||||
└── server/<domain>/ # Server-only logic; uses "server-only".
|
||||
components/ # Truly shared, app-wide components.
|
||||
```
|
||||
|
||||
Server vs Client: default is Server Component. Add `"use client"` only when state/effects/browser APIs needed. Push client boundary as deep as possible.
|
||||
|
||||
## API routes (route.ts)
|
||||
- One handler per HTTP method, ≤40 LOC.
|
||||
- Validate with `zod`. Reject invalid → 400.
|
||||
- Delegate to `lib/server/<domain>/`.
|
||||
|
||||
```ts
|
||||
export async function POST(req: Request) {
|
||||
const parsed = CreateItemSchema.safeParse(await req.json());
|
||||
if (!parsed.success)
|
||||
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const result = await itemService.create(parsed.data);
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
}
|
||||
```
|
||||
|
||||
## Page components
|
||||
- Pages > 300 lines → split into colocated `_components/`.
|
||||
- Server Components fetch data; pass plain objects to Client Components.
|
||||
- No data fetching in `useEffect` for server-renderable data.
|
||||
- State: prefer URL state (`searchParams`) + Server Components over global stores.
|
||||
|
||||
## Types — barrel re-export pattern for splitting monolithic type files
|
||||
|
||||
```ts
|
||||
// lib/sdk/types/index.ts
|
||||
export * from './enums'
|
||||
export * from './vendor'
|
||||
export * from './dsfa'
|
||||
// consumers still `import { Foo } from '@/lib/sdk/types'`
|
||||
```
|
||||
|
||||
Rules: no `any`. No `as unknown as`. All DTOs are zod schemas; infer via `z.infer`.
|
||||
|
||||
## Tests
|
||||
- Unit: **Vitest** (`*.test.ts`/`*.test.tsx`), colocated.
|
||||
- Hooks: `@testing-library/react` `renderHook`.
|
||||
- E2E: **Playwright** (`tests/e2e/`), one spec per top-level page minimum.
|
||||
- Coverage: 70% on `lib/`, smoke on `app/`.
|
||||
|
||||
## Tooling
|
||||
- `tsc --noEmit` clean (strict, `noUncheckedIndexedAccess: true`).
|
||||
- ESLint with `@typescript-eslint`, type-aware rules on.
|
||||
- `next build` clean. No `@ts-ignore`. `@ts-expect-error` only with a reason comment.
|
||||
|
||||
## What you may NOT do
|
||||
- Business logic in `page.tsx` or `route.ts`.
|
||||
- Cross-app module imports.
|
||||
- `dangerouslySetInnerHTML` without explicit sanitization.
|
||||
- Backend API calls from Client Components when a Server Component/Action would do.
|
||||
- Change route contract without updating consumers in the same change.
|
||||
- File > 500 lines.
|
||||
- Globally disable lint/type rules — fix the root cause.
|
||||
````
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 2. Phase plan — behavior-preserving refactor
|
||||
|
||||
Work in phases. Each phase ends green (tests pass, build clean, contract baseline unchanged). Do **not** skip ahead.
|
||||
|
||||
### Phase 0 — Foundation (single PR, low risk)
|
||||
|
||||
**Goal:** Set up rails. No code refactors yet.
|
||||
|
||||
1. Drop in all files from Section 1. Install hooks: `bash scripts/install-hooks.sh`.
|
||||
2. Populate `.claude/rules/loc-exceptions.txt` with grandfathered entries (one line each, with a comment rationale) so CI doesn't fail day 1.
|
||||
3. Append the non-negotiable rules block to root `CLAUDE.md`.
|
||||
4. Add per-language `AGENTS.*.md` at repo root.
|
||||
5. Add the CI jobs from §1.8.
|
||||
6. Per-service `README.md` + `CLAUDE.md` stubs: what it does, run/test commands, layered architecture diagram, env vars, API surface link.
|
||||
|
||||
**Verification:** CI green; loc-budget job passes with allowlist; next Claude session loads the rules automatically.
|
||||
|
||||
### Phase 1 — Backend service (Python/FastAPI)
|
||||
|
||||
**Critical targets:** any `routes.py` / `schemas.py` / `repository.py` / `models.py` over 500 LOC.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. **Snapshot the API contract:** `curl /openapi.json > tests/contracts/openapi.baseline.json`. Add a contract test that diffs current vs baseline and fails on any path/method/param drift.
|
||||
2. **Characterization tests first.** For each oversized route file, add `TestClient` tests exercising every endpoint (happy path + one error path). Use `httpx.AsyncClient` + factory fixtures.
|
||||
3. **Split models.py per aggregate.** Keep a shim: `from <service>.db.models import *` re-exports so existing imports keep working. One module per aggregate; `__tablename__` unchanged (no migration).
|
||||
4. **Split schemas.py** similarly with a re-export shim.
|
||||
5. **Extract service layer.** Each route handler delegates to a `*Service` class injected via `Depends`. Handlers shrink to ≤30 LOC.
|
||||
6. **Repository extraction** from the giant repository file; one class per aggregate.
|
||||
7. **`mypy --strict` scoped to new packages first.** Expand outward via `mypy.ini` per-module overrides.
|
||||
8. **Tests:** unit tests per service (mocked repo), repo tests against a transactional fixture (real Postgres), integration tests at API layer.
|
||||
|
||||
**Gotchas we hit:**
|
||||
- Tests that patch module-level symbols (e.g. `SessionLocal`, `scan_X`) break when you move logic behind `Depends`. Fix: re-export the symbol from the route module, or have the service lookup use the module-level symbol directly so the patch still takes effect.
|
||||
- `from __future__ import annotations` can break Pydantic TypeAdapter forward refs. Remove it where it conflicts.
|
||||
- Sibling test file status codes drift when you introduce the domain-error translator (e.g. 422 → 400). Update assertions in the same commit.
|
||||
|
||||
**Verification:** all pytest files green. Characterization tests green. Contract test green (no drift). `mypy` clean on new packages. Coverage ≥ baseline + 10%.
|
||||
|
||||
### Phase 2 — Go backend
|
||||
|
||||
**Critical targets:** any handler / store / rules file over 500 LOC.
|
||||
|
||||
**Steps:**
|
||||
1. OpenAPI/Swagger snapshot (or generate via `swag`) → contract tests.
|
||||
2. Generate handler-level tests with `httptest` for every endpoint pre-refactor.
|
||||
3. Define hexagonal layout (see AGENTS.go.md). Move incrementally with type aliases for back-compat where needed.
|
||||
4. Replace ad-hoc error handling with `errors.Is/As` + a single `httperr` package.
|
||||
5. Add `golangci-lint` strict config; fix new findings only (don't chase legacy lint).
|
||||
6. Table-driven service tests. `testcontainers-go` for repo layer.
|
||||
|
||||
**Verification:** `go test ./...` passes; `golangci-lint run` clean; contract tests green; no DB schema diff.
|
||||
|
||||
### Phase 3 — Frontend (Next.js)
|
||||
|
||||
**Biggest beast — expect this to dominate.** Critical targets: `page.tsx` / monolithic types / API routes over 500 LOC.
|
||||
|
||||
**Per oversized page:**
|
||||
1. Extract presentational components into `app/<route>/_components/` (private folder, Next.js convention).
|
||||
2. Move data fetching into Server Components / Server Actions; Client Components become small.
|
||||
3. Hooks → `app/<route>/_hooks/`.
|
||||
4. Pure helpers → `lib/<domain>/`.
|
||||
5. Add Vitest unit tests for hooks and pure helpers; Playwright smoke tests for each top-level page.
|
||||
|
||||
**Monolithic types file:** use barrel re-export pattern.
|
||||
- Create `types/` directory with domain files.
|
||||
- Create `types/index.ts` with `export * from './<domain>'` lines.
|
||||
- **Critical:** TypeScript won't allow both `types.ts` AND `types/index.ts` — delete the file, atomic swap to directory.
|
||||
|
||||
**API routes (`route.ts`):** same router→service split as backend. Each `route.ts` becomes a thin handler delegating to `lib/server/<domain>/`.
|
||||
|
||||
**Endpoint preservation:** if any internal route URL changes, grep every consumer (SDK packages, developer portal, sibling apps) and update in the same change.
|
||||
|
||||
**Gotchas:**
|
||||
- Pre-existing type bugs often surface when you try to build. Fix them as drive-by if they block your refactor; otherwise document in a separate follow-up.
|
||||
- `useClient` component imports from `'../provider'` that rely on re-exports: preserve the re-export or update importers in the same commit.
|
||||
- Next.js build can fail at page-manifest stage with unrelated prerender errors. Run `next build` fresh (not from cache) to see real status.
|
||||
|
||||
**Verification:** `next build` clean; `tsc --noEmit` clean; Playwright smoke tests pass; visual diff check on key pages (manual + screenshots in PR).
|
||||
|
||||
### Phase 4 — SDKs & smaller services
|
||||
|
||||
Apply the same patterns at smaller scale:
|
||||
- **SDK packages (0 tests):** add Vitest unit tests for public surface before/while splitting.
|
||||
- **Manager/Client classes:** extract config defaults, side-effect helpers (e.g. Google Consent Mode wiring), framework adapters into sibling files. Keep the main class as orchestration.
|
||||
- **Framework adapters (React/Vue/Angular):** each component/composable/service/module goes in its own sibling file; the entry `index.ts` is a thin barrel of re-exports.
|
||||
- **Doc monoliths (`index.md` thousands of lines):** split per topic with mkdocs nav.
|
||||
|
||||
### Phase 5 — CI hardening & governance
|
||||
|
||||
1. Promote `loc-budget` from warning → blocking once the allowlist has drained to legitimate exceptions only.
|
||||
2. Add mutation testing in nightly (`mutmut` for Python, `gomutesting` for Go).
|
||||
3. Add `dependabot`/`renovate` for npm + pip + go mod.
|
||||
4. Add release tagging workflow.
|
||||
5. Write ADRs (`docs/adr/`) capturing the architecture decisions from phases 1–3.
|
||||
6. Distill recurring patterns into `.claude/rules/` updates.
|
||||
|
||||
---
|
||||
|
||||
## 3. Agent prompt templates
|
||||
|
||||
When the work volume is big, parallelize with subagents. These prompts were battle-tested in practice.
|
||||
|
||||
### 3.1 Backend route file split (Python)
|
||||
|
||||
> You are working in `<repo>` on branch `<branch>`. Every source file must be under 500 LOC (hard cap enforced by a PreToolUse hook); soft target 300.
|
||||
>
|
||||
> **Task:** split `<path/to/file>_routes.py` (NNN LOC) following the router → service → repository layering described in `AGENTS.python.md`.
|
||||
>
|
||||
> **Steps:**
|
||||
> 1. Snapshot the relevant slice of `/openapi.json` and add a contract test that pins current behavior.
|
||||
> 2. Add characterization tests for every endpoint in this file (happy path + one error path) using `httpx.AsyncClient`.
|
||||
> 3. Extract each route handler's business logic into a `<domain>Service` class in `<service>/services/<domain>_service.py`. Inject via `Depends(get_<domain>_service)`.
|
||||
> 4. Raise domain errors (`NotFoundError`, `ConflictError`, `ValidationError`), never `HTTPException`. Use the `translate_domain_errors()` context manager in handlers.
|
||||
> 5. Move DB access to `<service>/repositories/<domain>_repository.py`. Session injected.
|
||||
> 6. Split Pydantic schemas from the giant `schemas.py` into `<service>/schemas/<domain>.py` if >300 lines.
|
||||
>
|
||||
> **Constraints:**
|
||||
> - Behavior preservation. No route rename/method/status/schema changes.
|
||||
> - Tests that patch module-level symbols must keep working — re-export the symbol or refactor the lookup so the patch still takes effect.
|
||||
> - Run `pytest` after each step. Commit each file as its own commit.
|
||||
> - Push at end: `git push origin <branch>`.
|
||||
>
|
||||
> When done, report: (a) new LOC counts, (b) test results, (c) mypy status, (d) commit SHAs. Under 300 words.
|
||||
|
||||
### 3.2 Go handler file split
|
||||
|
||||
> You are working in `<repo>` on branch `<branch>`. Hard cap 500 LOC.
|
||||
>
|
||||
> **Task:** split `<path>/handlers/<domain>_handler.go` (NNN LOC) into a hexagonal layout per `AGENTS.go.md`.
|
||||
>
|
||||
> **Steps:**
|
||||
> 1. Add `httptest` tests for every endpoint pre-refactor.
|
||||
> 2. Define `internal/domain/<aggregate>/` with types + interfaces + sentinel errors.
|
||||
> 3. Create `internal/service/<aggregate>/` with business logic implementing domain interfaces.
|
||||
> 4. Create `internal/repository/postgres/<aggregate>/` splitting queries by group.
|
||||
> 5. Thin handlers under `internal/transport/http/handler/<aggregate>/`. Each handler ≤40 LOC. Error mapping via `internal/platform/httperr`.
|
||||
> 6. Use `errors.Is` / `errors.As` for domain error matching.
|
||||
>
|
||||
> **Constraints:**
|
||||
> - No DB schema change.
|
||||
> - Table-driven service tests. `testcontainers-go` (or compose Postgres) for repo tests.
|
||||
> - `golangci-lint run` clean.
|
||||
>
|
||||
> Report new LOC, test status, lint status, commit SHAs. Under 300 words.
|
||||
|
||||
### 3.3 Next.js page split (the one we parallelized heavily)
|
||||
|
||||
> You are working in `<repo>` on branch `<branch>`. Every source file must be under 500 LOC (hard cap enforced by a PreToolUse hook); soft target 300. Other agents are working on OTHER pages in parallel — stay in your lane.
|
||||
>
|
||||
> **Task:** split the following Next.js 15 App Router client pages into colocated components so each `page.tsx` drops below 500 LOC.
|
||||
>
|
||||
> 1. `admin-compliance/app/sdk/<page-a>/page.tsx` (NNNN LOC)
|
||||
> 2. `admin-compliance/app/sdk/<page-b>/page.tsx` (NNNN LOC)
|
||||
>
|
||||
> **Pattern** (reference `admin-compliance/app/sdk/<already-split-example>/` for "done"):
|
||||
> - Create `_components/` subdirectory (Next.js private folder, won't create routes).
|
||||
> - Extract each logically-grouped section (forms, tables, modals, tabs, headers, cards) into its own component file. Name files after the component.
|
||||
> - Create `_hooks/` for custom hooks that were inline.
|
||||
> - Create `_types.ts` or `_data.ts` for hoisted types or data arrays.
|
||||
> - Remaining `page.tsx` wires extracted pieces — aim for under 300 LOC, hard cap 500.
|
||||
> - Preserve `'use client'` when present on original.
|
||||
> - DO NOT rename any exports that other files import. Grep first before moving.
|
||||
>
|
||||
> **Constraints:**
|
||||
> - Behavior preservation. No logic changes, no improvements.
|
||||
> - Imports must resolve (relative `./_components/Foo`).
|
||||
> - Run `cd admin-compliance && npx next build` after each file is done. Don't commit broken builds.
|
||||
> - DO NOT edit `.claude/settings.json`, `scripts/check-loc.sh`, `loc-exceptions.txt`, or any `AGENTS.*.md`.
|
||||
> - Commit each page as its own commit: `refactor(admin): split <name> page.tsx into colocated components`. HEREDOC body, include `Co-Authored-By:` trailer.
|
||||
> - Pull before push: `git pull --rebase origin <branch>`, then `git push origin <branch>`.
|
||||
>
|
||||
> **Coordination:** DO NOT touch `<list of pages other agents own>`. You own only `<your pages>`.
|
||||
>
|
||||
> When done, report: (a) each file's new LOC count, (b) how many `_components` were created, (c) whether `next build` is clean, (d) commit SHAs. Under 300 words.
|
||||
>
|
||||
> If the LOC hook blocks a Write, split further. If you hit rate limits partway, commit what's done and report progress honestly.
|
||||
|
||||
### 3.4 Monolithic types file split (TypeScript)
|
||||
|
||||
> `<repo>`, branch `<branch>`. Hard cap 500 LOC.
|
||||
>
|
||||
> **Task:** split `<lib>/types.ts` (NNNN LOC) into per-domain modules under `<lib>/types/`.
|
||||
>
|
||||
> **Steps:**
|
||||
> 1. Identify domain groupings (enums, API DTOs, one group per business aggregate).
|
||||
> 2. Create `<lib>/types/` directory with `<domain>.ts` files.
|
||||
> 3. Create `<lib>/types/index.ts` barrel: `export * from './<domain>'` per file.
|
||||
> 4. **Atomic swap:** delete the old `types.ts` in the same commit as the new `types/` directory. TypeScript won't resolve both a file and a directory with the same stem.
|
||||
> 5. Grep every consumer — imports from `'<lib>/types'` should still work via the barrel. No consumer file changes needed unless there's a name collision.
|
||||
> 6. Resolve collisions by renaming the less-canonical export (e.g. if two modules both export `LegalDocument`, rename the RAG one to `RagLegalDocument`).
|
||||
>
|
||||
> **Verification:** `tsc --noEmit` clean, `next build` clean.
|
||||
>
|
||||
> Report new LOC per file, collisions resolved, consumer updates, commit SHAs.
|
||||
|
||||
### 3.5 Agent orchestration rules (from hard-won experience)
|
||||
|
||||
When you spawn multiple agents in parallel:
|
||||
|
||||
1. **Own disjoint paths.** Give each agent a bounded list of files under specific directories. Spell out the "do NOT touch" list explicitly.
|
||||
2. **Always instruct `git pull --rebase origin <branch>` before push.** Agents running in parallel will push and cause non-fast-forward rejects without this.
|
||||
3. **Instruct `commit each file as its own commit`** — not a single mega-commit. Makes revert surgical.
|
||||
4. **Ask for concise reports (≤300 words):** new LOC counts, component counts, build status, commit SHAs.
|
||||
5. **Tell them to commit partial progress on rate-limit.** If they don't, their partial work lives in the working tree and you have to chase it with `git status` after. (We hit this — 4 agents silently left uncommitted work.)
|
||||
6. **Don't give an agent more than 2 big files at once.** Each page-split in practice took ~10–20 minutes + ~150k tokens. Two is a comfortable batch.
|
||||
7. **Reference a prior "done" example.** Commit SHAs are gold — the agent can inspect exactly the style you want.
|
||||
8. **Run one final `next build` / `pytest` / `go test` yourself after all agents finish.** Agent reports of "build clean" can be scoped (e.g. only their files); you want the whole-repo gate.
|
||||
|
||||
---
|
||||
|
||||
## 4. Workflow loop (per file)
|
||||
|
||||
```
|
||||
1. Read the oversized file end to end. Identify 3–6 extraction sections.
|
||||
2. Write characterization test (if backend) — pin behavior.
|
||||
3. Create the sibling files one at a time.
|
||||
- If the PreToolUse hook blocks (file still > 500), split further.
|
||||
4. Edit the root file: replace extracted bodies with imports + delegations.
|
||||
5. Run the full verification: pytest / next build / go test.
|
||||
6. Run LOC check: scripts/check-loc.sh <changed files>
|
||||
7. Commit with a scoped message and a 1–2 line body explaining why.
|
||||
8. Push.
|
||||
```
|
||||
|
||||
## 5. Commit message conventions
|
||||
|
||||
```
|
||||
refactor(<area>): <one-line what, not how>
|
||||
|
||||
<optional 1-3 sentence body: what split changed + verification result>
|
||||
<LOC table: before → after per file>
|
||||
<non-behavior changes flagged as drive-by fixes, with reason>
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||||
```
|
||||
|
||||
Markers that unlock pre-commit guards:
|
||||
- `[migration-approved]` — allows changes under `migrations/` / `alembic/versions/`.
|
||||
- `[guardrail-change]` — allows changes to `.claude/settings.json`, `.claude/rules/loc-exceptions.txt`, `scripts/check-loc.sh`, `scripts/githooks/pre-commit`, or any `AGENTS.*.md`.
|
||||
|
||||
Good examples from our session:
|
||||
- `refactor(consent-sdk): split ConsentManager + framework adapters under 500 LOC`
|
||||
- `refactor(compliance-sdk): split client/provider/embed/state under 500 LOC`
|
||||
- `refactor(admin): split whistleblower page.tsx + restore scope helpers`
|
||||
- `chore: document data-catalog + legacy-service LOC exceptions` (with `[guardrail-change]` body)
|
||||
|
||||
## 6. Verification commands cheatsheet
|
||||
|
||||
```bash
|
||||
# LOC budget
|
||||
scripts/check-loc.sh --changed # only changed files
|
||||
scripts/check-loc.sh # whole repo
|
||||
scripts/check-loc.sh --json # for CI parsing
|
||||
|
||||
# Python
|
||||
pytest --cov=<package> --cov-report=term-missing
|
||||
ruff check .
|
||||
mypy --strict <package>/services <package>/repositories
|
||||
|
||||
# Go
|
||||
go test ./... -cover
|
||||
golangci-lint run
|
||||
go vet ./...
|
||||
|
||||
# TypeScript
|
||||
npx tsc --noEmit
|
||||
npx next build # from the Next.js app dir
|
||||
npm test -- --run # vitest one-shot
|
||||
npx playwright test tests/e2e # e2e smoke
|
||||
|
||||
# Contracts
|
||||
pytest tests/contracts/ # OpenAPI snapshot diff
|
||||
```
|
||||
|
||||
## 7. Out of scope (don't drift)
|
||||
|
||||
- DB schema / migrations — unless separate green-lit plan.
|
||||
- New features. This is a refactor.
|
||||
- Public endpoint renames without simultaneous consumer fix-up (exception: intra-monorepo URLs when you do the grep sweep).
|
||||
- Unrelated dead code cleanup — do it in a separate PR.
|
||||
- Bundling refactors across services in one commit — one service = one commit.
|
||||
|
||||
## 8. Memory / session handoff
|
||||
|
||||
If using Claude Code with persistent memory, save a `project_refactor_status.md` in your memory store after each phase:
|
||||
- What's done (files split, LOC before → after).
|
||||
- What's in progress (current file, blocker if any).
|
||||
- What's deferred (pre-existing bugs surfaced but left for follow-up).
|
||||
- Key patterns established (so next session doesn't rediscover them).
|
||||
|
||||
This lets you resume after context compacts or after rate-limit windows without losing the thread.
|
||||
|
||||
---
|
||||
|
||||
That's the whole methodology. Install Section 1, follow Section 2 phase-by-phase, use Section 3 to parallelize the grind. The guardrails do the policing so you don't have to remember anything.
|
||||
|
||||
53
admin-compliance/README.md
Normal file
53
admin-compliance/README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# admin-compliance
|
||||
|
||||
Next.js 15 dashboard for BreakPilot Compliance — SDK module UI, company profile, DSR, DSFA, VVT, TOM, consent, AI Act, training, audit, change requests, etc. Also hosts 96+ API routes that proxy/orchestrate backend services.
|
||||
|
||||
**Port:** `3007` (container: `bp-compliance-admin`)
|
||||
**Stack:** Next.js 15 App Router, React 18, TailwindCSS, TypeScript strict.
|
||||
|
||||
## Architecture (Phase 3 — in progress)
|
||||
|
||||
```
|
||||
app/
|
||||
├── <route>/
|
||||
│ ├── page.tsx # Server Component (≤200 LOC)
|
||||
│ ├── _components/ # Colocated UI, each ≤300 LOC
|
||||
│ ├── _hooks/ # Client hooks
|
||||
│ └── _server/ # Server actions
|
||||
├── api/<domain>/route.ts # Thin handlers → lib/server/<domain>/
|
||||
lib/
|
||||
├── <domain>/ # Pure helpers, zod schemas
|
||||
└── server/<domain>/ # "server-only" logic
|
||||
components/ # App-wide shared UI
|
||||
```
|
||||
|
||||
See `../AGENTS.typescript.md`.
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
cd admin-compliance
|
||||
npm install
|
||||
npm run dev # http://localhost:3007
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
npm test # Vitest unit + component tests
|
||||
npx playwright test # E2E
|
||||
npx tsc --noEmit # Type-check
|
||||
npx next lint
|
||||
```
|
||||
|
||||
## Known debt
|
||||
|
||||
- `lib/sdk/types.ts` has been split: it is now a barrel re-export to `lib/sdk/types/` (12 domain files: enums, company-profile, sdk-steps, and others).
|
||||
- `lib/sdk/tom-generator/controls/loader.ts` has been split: it is now a barrel re-export to `categories/` (8 category files).
|
||||
- Phase 3 refactoring is ongoing — several large page files remain and are being addressed incrementally.
|
||||
- **0 test files** for the page layer. Adding Playwright smoke + Vitest unit coverage is ongoing Phase 3 work.
|
||||
|
||||
## Don't touch
|
||||
|
||||
- Backend API paths without updating `backend-compliance/` in the same change.
|
||||
- `lib/sdk/types/` barrel re-exports — add new types to the appropriate domain file, not back into the root.
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Drafting Engine - v2 Pipeline Helpers
|
||||
*
|
||||
* DOCUMENT_PROSE_BLOCKS, buildV2SystemPrompt, buildBlockSpecificPrompt,
|
||||
* callOllama, handleV2Draft — split from draft-helpers.ts for the 500 LOC hard cap.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { DraftContext, DraftRevision, DraftSection } from '@/lib/sdk/drafting-engine/types'
|
||||
import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types'
|
||||
import { deriveNarrativeTags, extractScoresFromDraftContext, narrativeTagsToPromptString } from '@/lib/sdk/drafting-engine/narrative-tags'
|
||||
import type { NarrativeTags } from '@/lib/sdk/drafting-engine/narrative-tags'
|
||||
import { buildAllowedFactsFromDraftContext, allowedFactsToPromptString, disallowedTopicsToPromptString } from '@/lib/sdk/drafting-engine/allowed-facts-v2'
|
||||
import { sanitizeAllowedFacts, validateNoRemainingPII, SanitizationError } from '@/lib/sdk/drafting-engine/sanitizer'
|
||||
import { terminologyToPromptString, styleContractToPromptString } from '@/lib/sdk/drafting-engine/terminology'
|
||||
import { executeRepairLoop, type ProseBlockOutput, type RepairAudit } from '@/lib/sdk/drafting-engine/prose-validator'
|
||||
import { computeChecksumSync, type CacheKeyParams } from '@/lib/sdk/drafting-engine/cache'
|
||||
import { queryRAG } from '@/lib/sdk/drafting-engine/rag-query'
|
||||
import { DOCUMENT_RAG_CONFIG } from '@/lib/sdk/drafting-engine/rag-config'
|
||||
import {
|
||||
constraintEnforcer,
|
||||
proseCache,
|
||||
TEMPLATE_VERSION,
|
||||
TERMINOLOGY_VERSION,
|
||||
VALIDATOR_VERSION,
|
||||
V1_SYSTEM_PROMPT,
|
||||
buildPromptForDocumentType,
|
||||
} from './draft-helpers'
|
||||
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
||||
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
||||
|
||||
// ============================================================================
|
||||
// v2 Personalisierte Pipeline
|
||||
// ============================================================================
|
||||
|
||||
export const DOCUMENT_PROSE_BLOCKS: Record<string, Array<{ blockId: string; blockType: ProseBlockOutput['blockType']; sectionName: string; targetWords: number }>> = {
|
||||
tom: [
|
||||
{ blockId: 'tom-intro', blockType: 'introduction', sectionName: 'Einleitung TOM', targetWords: 120 },
|
||||
{ blockId: 'tom-transition', blockType: 'transition', sectionName: 'Ueberleitung Massnahmen', targetWords: 40 },
|
||||
{ blockId: 'tom-conclusion', blockType: 'conclusion', sectionName: 'Fazit TOM', targetWords: 80 },
|
||||
],
|
||||
dsfa: [
|
||||
{ blockId: 'dsfa-intro', blockType: 'introduction', sectionName: 'Einleitung DSFA', targetWords: 150 },
|
||||
{ blockId: 'dsfa-transition', blockType: 'transition', sectionName: 'Ueberleitung Risikobewertung', targetWords: 40 },
|
||||
{ blockId: 'dsfa-appreciation', blockType: 'appreciation', sectionName: 'Wuerdigung bestehender Massnahmen', targetWords: 60 },
|
||||
{ blockId: 'dsfa-conclusion', blockType: 'conclusion', sectionName: 'Fazit DSFA', targetWords: 100 },
|
||||
],
|
||||
vvt: [
|
||||
{ blockId: 'vvt-intro', blockType: 'introduction', sectionName: 'Einleitung VVT', targetWords: 120 },
|
||||
{ blockId: 'vvt-conclusion', blockType: 'conclusion', sectionName: 'Fazit VVT', targetWords: 80 },
|
||||
],
|
||||
dsi: [
|
||||
{ blockId: 'dsi-intro', blockType: 'introduction', sectionName: 'Einleitung Datenschutzerklaerung', targetWords: 130 },
|
||||
{ blockId: 'dsi-conclusion', blockType: 'conclusion', sectionName: 'Fazit Datenschutzerklaerung', targetWords: 80 },
|
||||
],
|
||||
lf: [
|
||||
{ blockId: 'lf-intro', blockType: 'introduction', sectionName: 'Einleitung Loeschfristen', targetWords: 100 },
|
||||
{ blockId: 'lf-conclusion', blockType: 'conclusion', sectionName: 'Fazit Loeschfristen', targetWords: 60 },
|
||||
],
|
||||
av_vertrag: [
|
||||
{ blockId: 'av-intro', blockType: 'introduction', sectionName: 'Einleitung Auftragsverarbeitung', targetWords: 130 },
|
||||
{ blockId: 'av-conclusion', blockType: 'conclusion', sectionName: 'Fazit Auftragsverarbeitung', targetWords: 80 },
|
||||
],
|
||||
betroffenenrechte: [
|
||||
{ blockId: 'betr-intro', blockType: 'introduction', sectionName: 'Einleitung Betroffenenrechte', targetWords: 120 },
|
||||
{ blockId: 'betr-conclusion', blockType: 'conclusion', sectionName: 'Fazit Betroffenenrechte', targetWords: 80 },
|
||||
],
|
||||
risikoanalyse: [
|
||||
{ blockId: 'risk-intro', blockType: 'introduction', sectionName: 'Einleitung Risikoanalyse', targetWords: 130 },
|
||||
{ blockId: 'risk-conclusion', blockType: 'conclusion', sectionName: 'Fazit Risikoanalyse', targetWords: 80 },
|
||||
],
|
||||
notfallplan: [
|
||||
{ blockId: 'notfall-intro', blockType: 'introduction', sectionName: 'Einleitung Notfallplan', targetWords: 120 },
|
||||
{ blockId: 'notfall-conclusion', blockType: 'conclusion', sectionName: 'Fazit Notfallplan', targetWords: 80 },
|
||||
],
|
||||
iace_ce_assessment: [
|
||||
{ blockId: 'iace-intro', blockType: 'introduction', sectionName: 'Einleitung IACE CE-Bewertung', targetWords: 150 },
|
||||
{ blockId: 'iace-appreciation', blockType: 'appreciation', sectionName: 'Wuerdigung bestehender Konformitaetsbewertung', targetWords: 60 },
|
||||
{ blockId: 'iace-conclusion', blockType: 'conclusion', sectionName: 'Fazit IACE CE-Bewertung', targetWords: 100 },
|
||||
],
|
||||
}
|
||||
|
||||
export function buildV2SystemPrompt(
|
||||
sanitizedFactsString: string,
|
||||
narrativeTagsString: string,
|
||||
terminologyString: string,
|
||||
styleString: string,
|
||||
disallowedString: string,
|
||||
companyName: string,
|
||||
blockId: string,
|
||||
blockType: string,
|
||||
sectionName: string,
|
||||
documentType: string,
|
||||
targetWords: number
|
||||
): string {
|
||||
return `Du bist ein Compliance-Dokumenten-Redakteur.
|
||||
Du schreibst einzelne Textabschnitte fuer offizielle Compliance-Dokumente.
|
||||
|
||||
KUNDENPROFIL (ERLAUBTE FAKTEN — nur diese darfst du verwenden):
|
||||
${sanitizedFactsString}
|
||||
|
||||
BEWERTUNGSERGEBNIS (sprachliche Tags — verwende nur diese Begriffe):
|
||||
${narrativeTagsString}
|
||||
|
||||
TERMINOLOGIE (verwende ausschliesslich diese Fachbegriffe):
|
||||
${terminologyString}
|
||||
|
||||
STIL:
|
||||
${styleString}
|
||||
|
||||
VERBOTENE INHALTE:
|
||||
${disallowedString}
|
||||
- Keine konkreten Prozentwerte, Scores oder Zahlen
|
||||
- Keine Compliance-Level-Bezeichnungen (L1, L2, L3, L4)
|
||||
- Keine direkte Ansprache ("Sie", "Ihr")
|
||||
- Kein Denglisch, keine Marketing-Sprache, keine Superlative
|
||||
|
||||
STRIKTE REGELN:
|
||||
1. Verwende den Firmennamen "${companyName}" — nie "Ihr Unternehmen"
|
||||
2. Schreibe in der dritten Person ("Die ${companyName}...")
|
||||
3. Beziehe dich auf die Branche und organisatorische Merkmale
|
||||
4. Verwende NUR Fakten aus dem Kundenprofil oben
|
||||
5. Verwende NUR die sprachlichen Tags aus dem Bewertungsergebnis
|
||||
6. Erfinde KEINE zusaetzlichen Fakten oder Bewertungen
|
||||
7. Halte dich an die Terminologie-Vorgaben
|
||||
8. Dein Text wird ZWISCHEN feste Datentabellen eingefuegt
|
||||
|
||||
OUTPUT-FORMAT: Antworte ausschliesslich als JSON:
|
||||
{
|
||||
"blockId": "${blockId}",
|
||||
"blockType": "${blockType}",
|
||||
"language": "de",
|
||||
"text": "...",
|
||||
"assertions": {
|
||||
"companyNameUsed": true/false,
|
||||
"industryReferenced": true/false,
|
||||
"structureReferenced": true/false,
|
||||
"itLandscapeReferenced": true/false,
|
||||
"narrativeTagsUsed": ["riskSummary", ...]
|
||||
},
|
||||
"forbiddenContentDetected": []
|
||||
}
|
||||
|
||||
DOKUMENTENTYP: ${documentType}
|
||||
SEKTION: ${sectionName}
|
||||
BLOCK-TYP: ${blockType}
|
||||
ZIEL-LAENGE: ${targetWords} Woerter`
|
||||
}
|
||||
|
||||
export function buildBlockSpecificPrompt(blockType: string, sectionName: string, documentType: string): string {
|
||||
switch (blockType) {
|
||||
case 'introduction':
|
||||
return `Schreibe eine Einleitung fuer das Dokument "${documentType}" (Sektion: ${sectionName}).
|
||||
Erklaere, warum dieses Dokument fuer das Unternehmen erstellt wurde.
|
||||
Gehe auf die spezifische Situation des Unternehmens ein.
|
||||
Erwaehne die Branche, die Organisationsform und die IT-Strategie.`
|
||||
case 'transition':
|
||||
return `Schreibe eine kurze Ueberleitung zur naechsten Sektion "${sectionName}".
|
||||
Verknuepfe den vorherigen Abschnitt logisch mit dem folgenden.`
|
||||
case 'conclusion':
|
||||
return `Schreibe einen abschliessenden Absatz fuer die Sektion "${sectionName}".
|
||||
Fasse die wesentlichen Punkte zusammen und verweise auf die fortlaufende Pflege.`
|
||||
case 'appreciation':
|
||||
return `Schreibe einen wertschaetzenden Satz ueber die bestehenden Massnahmen.
|
||||
Verwende dabei die sprachlichen Tags aus dem Bewertungsergebnis.
|
||||
Keine neuen Fakten erfinden — nur das Profil wuerdigen.`
|
||||
default:
|
||||
return `Schreibe einen Textabschnitt fuer "${sectionName}".`
|
||||
}
|
||||
}
|
||||
|
||||
export async function callOllama(systemPrompt: string, userPrompt: string): Promise<string> {
|
||||
const response = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: LLM_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
stream: false,
|
||||
think: false,
|
||||
options: { temperature: 0.15, num_predict: 4096, num_ctx: 8192 },
|
||||
format: 'json',
|
||||
}),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama error: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return result.message?.content || ''
|
||||
}
|
||||
|
||||
export async function handleV2Draft(body: Record<string, unknown>): Promise<NextResponse> {
|
||||
const { documentType, draftContext, instructions } = body as {
|
||||
documentType: ScopeDocumentType
|
||||
draftContext: DraftContext
|
||||
instructions?: string
|
||||
}
|
||||
|
||||
const constraintCheck = constraintEnforcer.checkFromContext(documentType, draftContext)
|
||||
if (!constraintCheck.allowed) {
|
||||
return NextResponse.json({
|
||||
draft: null, constraintCheck, tokensUsed: 0,
|
||||
error: 'Constraint-Verletzung: ' + constraintCheck.violations.join('; '),
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
const scores = extractScoresFromDraftContext(draftContext)
|
||||
const narrativeTags: NarrativeTags = deriveNarrativeTags(scores)
|
||||
const allowedFacts = buildAllowedFactsFromDraftContext(draftContext, narrativeTags)
|
||||
|
||||
let sanitizationResult
|
||||
try {
|
||||
sanitizationResult = sanitizeAllowedFacts(allowedFacts)
|
||||
} catch (error) {
|
||||
if (error instanceof SanitizationError) {
|
||||
return NextResponse.json({
|
||||
error: `Sanitization Hard Abort: ${error.message} (Feld: ${error.field})`,
|
||||
draft: null, constraintCheck, tokensUsed: 0,
|
||||
}, { status: 422 })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const sanitizedFacts = sanitizationResult.facts
|
||||
const piiWarnings = validateNoRemainingPII(sanitizedFacts)
|
||||
if (piiWarnings.length > 0) console.warn('PII-Warnungen nach Sanitization:', piiWarnings)
|
||||
|
||||
const factsString = allowedFactsToPromptString(sanitizedFacts)
|
||||
const tagsString = narrativeTagsToPromptString(narrativeTags)
|
||||
const termsString = terminologyToPromptString()
|
||||
const styleString = styleContractToPromptString()
|
||||
const disallowedString = disallowedTopicsToPromptString()
|
||||
const promptHash = computeChecksumSync({ factsString, tagsString, termsString, styleString, disallowedString })
|
||||
|
||||
const v2RagCfg = DOCUMENT_RAG_CONFIG[documentType]
|
||||
const v2RagContext = await queryRAG(v2RagCfg.query, 3, v2RagCfg.collection)
|
||||
|
||||
const proseBlocks = DOCUMENT_PROSE_BLOCKS[documentType] || DOCUMENT_PROSE_BLOCKS.tom
|
||||
const generatedBlocks: ProseBlockOutput[] = []
|
||||
const repairAudits: RepairAudit[] = []
|
||||
let totalTokens = 0
|
||||
|
||||
for (const blockDef of proseBlocks) {
|
||||
const cacheParams: CacheKeyParams = {
|
||||
allowedFacts: sanitizedFacts, templateVersion: TEMPLATE_VERSION,
|
||||
terminologyVersion: TERMINOLOGY_VERSION, narrativeTags,
|
||||
promptHash, blockType: blockDef.blockType, sectionName: blockDef.sectionName,
|
||||
}
|
||||
|
||||
const cached = proseCache.getSync(cacheParams)
|
||||
if (cached) {
|
||||
generatedBlocks.push(cached)
|
||||
repairAudits.push({ repairAttempts: 0, validatorFailures: [], repairSuccessful: true, fallbackUsed: false })
|
||||
continue
|
||||
}
|
||||
|
||||
let systemPrompt = buildV2SystemPrompt(
|
||||
factsString, tagsString, termsString, styleString, disallowedString,
|
||||
sanitizedFacts.companyName, blockDef.blockId, blockDef.blockType,
|
||||
blockDef.sectionName, documentType, blockDef.targetWords
|
||||
)
|
||||
if (v2RagContext) systemPrompt += `\n\nRECHTSKONTEXT (als Referenz, nicht woertlich uebernehmen):\n${v2RagContext}`
|
||||
const userPrompt = buildBlockSpecificPrompt(blockDef.blockType, blockDef.sectionName, documentType)
|
||||
+ (instructions ? `\n\nZusaetzliche Anweisungen: ${instructions}` : '')
|
||||
|
||||
try {
|
||||
const rawOutput = await callOllama(systemPrompt, userPrompt)
|
||||
totalTokens += rawOutput.length / 4
|
||||
const { block, audit } = await executeRepairLoop(
|
||||
rawOutput, sanitizedFacts, narrativeTags, blockDef.blockId, blockDef.blockType,
|
||||
async (repairPrompt) => callOllama(systemPrompt, repairPrompt), documentType
|
||||
)
|
||||
generatedBlocks.push(block)
|
||||
repairAudits.push(audit)
|
||||
if (!audit.fallbackUsed) proseCache.setSync(cacheParams, block)
|
||||
} catch (error) {
|
||||
const { buildFallbackBlock } = await import('@/lib/sdk/drafting-engine/prose-validator')
|
||||
generatedBlocks.push(buildFallbackBlock(blockDef.blockId, blockDef.blockType, sanitizedFacts, documentType))
|
||||
repairAudits.push({
|
||||
repairAttempts: 0, validatorFailures: [[(error as Error).message]],
|
||||
repairSuccessful: false, fallbackUsed: true,
|
||||
fallbackReason: `LLM-Fehler: ${(error as Error).message}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const draftPrompt = buildPromptForDocumentType(documentType, draftContext, instructions)
|
||||
|
||||
let dataSections: DraftSection[] = []
|
||||
try {
|
||||
const dataResponse = await callOllama(V1_SYSTEM_PROMPT, draftPrompt)
|
||||
const parsed = JSON.parse(dataResponse)
|
||||
dataSections = (parsed.sections || []).map((s: Record<string, unknown>, i: number) => ({
|
||||
id: String(s.id || `section-${i}`), title: String(s.title || ''),
|
||||
content: String(s.content || ''), schemaField: s.schemaField ? String(s.schemaField) : undefined,
|
||||
}))
|
||||
totalTokens += dataResponse.length / 4
|
||||
} catch { dataSections = [] }
|
||||
|
||||
const introBlock = generatedBlocks.find(b => b.blockType === 'introduction')
|
||||
const transitionBlocks = generatedBlocks.filter(b => b.blockType === 'transition')
|
||||
const appreciationBlocks = generatedBlocks.filter(b => b.blockType === 'appreciation')
|
||||
const conclusionBlock = generatedBlocks.find(b => b.blockType === 'conclusion')
|
||||
|
||||
const mergedSections: DraftSection[] = []
|
||||
if (introBlock) mergedSections.push({ id: introBlock.blockId, title: 'Einleitung', content: introBlock.text })
|
||||
for (let i = 0; i < dataSections.length; i++) {
|
||||
if (i > 0 && transitionBlocks[i - 1]) mergedSections.push({ id: transitionBlocks[i - 1].blockId, title: '', content: transitionBlocks[i - 1].text })
|
||||
mergedSections.push(dataSections[i])
|
||||
}
|
||||
for (const block of appreciationBlocks) mergedSections.push({ id: block.blockId, title: 'Wuerdigung', content: block.text })
|
||||
if (conclusionBlock) mergedSections.push({ id: conclusionBlock.blockId, title: 'Fazit', content: conclusionBlock.text })
|
||||
|
||||
const finalSections = mergedSections.length > 0 ? mergedSections : generatedBlocks.map(b => ({
|
||||
id: b.blockId,
|
||||
title: b.blockType === 'introduction' ? 'Einleitung' : b.blockType === 'conclusion' ? 'Fazit' : b.blockType === 'appreciation' ? 'Wuerdigung' : 'Ueberleitung',
|
||||
content: b.text,
|
||||
}))
|
||||
|
||||
const draft: DraftRevision = {
|
||||
id: `draft-v2-${Date.now()}`,
|
||||
content: finalSections.map(s => s.title ? `## ${s.title}\n\n${s.content}` : s.content).join('\n\n'),
|
||||
sections: finalSections, createdAt: new Date().toISOString(), instruction: instructions,
|
||||
}
|
||||
|
||||
const auditTrail = {
|
||||
documentType, templateVersion: TEMPLATE_VERSION, terminologyVersion: TERMINOLOGY_VERSION,
|
||||
validatorVersion: VALIDATOR_VERSION, promptHash, llmModel: LLM_MODEL,
|
||||
llmTemperature: 0.15, llmProvider: 'ollama', narrativeTags,
|
||||
sanitization: sanitizationResult.audit, repairAudits,
|
||||
proseBlocks: generatedBlocks.map((b, i) => ({
|
||||
blockId: b.blockId, blockType: b.blockType,
|
||||
wordCount: b.text.split(/\s+/).filter(Boolean).length,
|
||||
fallbackUsed: repairAudits[i]?.fallbackUsed ?? false,
|
||||
repairAttempts: repairAudits[i]?.repairAttempts ?? 0,
|
||||
})),
|
||||
cacheStats: proseCache.getStats(),
|
||||
}
|
||||
|
||||
const truthLabel = { generation_mode: 'draft_assistance', truth_status: 'generated', may_be_used_as_evidence: false, generated_by: 'system' }
|
||||
|
||||
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 })
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Drafting Engine - Draft Helper Functions (v1 pipeline + shared constants)
|
||||
*
|
||||
* Shared state, v1 legacy pipeline helpers.
|
||||
* v2 pipeline lives in draft-helpers-v2.ts.
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import { buildVVTDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-vvt'
|
||||
import { buildTOMDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-tom'
|
||||
import { buildDSFADraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-dsfa'
|
||||
import { buildPrivacyPolicyDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-privacy-policy'
|
||||
import { buildLoeschfristenDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-loeschfristen'
|
||||
import type { DraftContext, DraftResponse, DraftRevision, DraftSection } from '@/lib/sdk/drafting-engine/types'
|
||||
import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types'
|
||||
import { ConstraintEnforcer } from '@/lib/sdk/drafting-engine/constraint-enforcer'
|
||||
import { ProseCacheManager } from '@/lib/sdk/drafting-engine/cache'
|
||||
import { queryRAG } from '@/lib/sdk/drafting-engine/rag-query'
|
||||
import { DOCUMENT_RAG_CONFIG } from '@/lib/sdk/drafting-engine/rag-config'
|
||||
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
||||
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
||||
|
||||
export const constraintEnforcer = new ConstraintEnforcer()
|
||||
export const proseCache = new ProseCacheManager({ maxEntries: 200, ttlHours: 24 })
|
||||
|
||||
export const TEMPLATE_VERSION = '2.0.0'
|
||||
export const TERMINOLOGY_VERSION = '1.0.0'
|
||||
export const VALIDATOR_VERSION = '1.0.0'
|
||||
|
||||
// ============================================================================
|
||||
// v1 Legacy Pipeline
|
||||
// ============================================================================
|
||||
|
||||
export const V1_SYSTEM_PROMPT = `Du bist ein DSGVO-Compliance-Experte und erstellst strukturierte Dokument-Entwuerfe.
|
||||
Du MUSST immer im JSON-Format antworten mit einem "sections" Array.
|
||||
Jede Section hat: id, title, content, schemaField.
|
||||
Halte die Tiefe strikt am vorgegebenen Level.
|
||||
Markiere fehlende Informationen mit [PLATZHALTER: Beschreibung].
|
||||
Sprache: Deutsch.`
|
||||
|
||||
export function buildPromptForDocumentType(
|
||||
documentType: ScopeDocumentType,
|
||||
context: DraftContext,
|
||||
instructions?: string
|
||||
): string {
|
||||
switch (documentType) {
|
||||
case 'vvt':
|
||||
return buildVVTDraftPrompt({ context, instructions })
|
||||
case 'tom':
|
||||
return buildTOMDraftPrompt({ context, instructions })
|
||||
case 'dsfa':
|
||||
return buildDSFADraftPrompt({ context, instructions })
|
||||
case 'dsi':
|
||||
return buildPrivacyPolicyDraftPrompt({ context, instructions })
|
||||
case 'lf':
|
||||
return buildLoeschfristenDraftPrompt({ context, instructions })
|
||||
default:
|
||||
return `## Aufgabe: Entwurf fuer ${documentType}
|
||||
|
||||
### Level: ${context.decisions.level}
|
||||
### Tiefe: ${context.constraints.depthRequirements.depth}
|
||||
### Erforderliche Inhalte:
|
||||
${context.constraints.depthRequirements.detailItems.map((item, i) => `${i + 1}. ${item}`).join('\n')}
|
||||
|
||||
${instructions ? `### Anweisungen: ${instructions}` : ''}
|
||||
|
||||
Antworte als JSON mit "sections" Array.`
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleV1Draft(body: Record<string, unknown>): Promise<NextResponse> {
|
||||
const { documentType, draftContext, instructions, existingDraft } = body as {
|
||||
documentType: ScopeDocumentType
|
||||
draftContext: DraftContext
|
||||
instructions?: string
|
||||
existingDraft?: DraftRevision
|
||||
}
|
||||
|
||||
const constraintCheck = constraintEnforcer.checkFromContext(documentType, draftContext)
|
||||
if (!constraintCheck.allowed) {
|
||||
return NextResponse.json({
|
||||
draft: null,
|
||||
constraintCheck,
|
||||
tokensUsed: 0,
|
||||
error: 'Constraint-Verletzung: ' + constraintCheck.violations.join('; '),
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
const ragCfg = DOCUMENT_RAG_CONFIG[documentType]
|
||||
const ragContext = await queryRAG(ragCfg.query, 3, ragCfg.collection)
|
||||
|
||||
let v1SystemPrompt = V1_SYSTEM_PROMPT
|
||||
if (ragContext) {
|
||||
v1SystemPrompt += `\n\n## Relevanter Rechtskontext\n${ragContext}`
|
||||
}
|
||||
|
||||
const draftPrompt = buildPromptForDocumentType(documentType, draftContext, instructions)
|
||||
const messages = [
|
||||
{ role: 'system', content: v1SystemPrompt },
|
||||
...(existingDraft ? [{
|
||||
role: 'assistant',
|
||||
content: `Bisheriger Entwurf:\n${JSON.stringify(existingDraft.sections, null, 2)}`,
|
||||
}] : []),
|
||||
{ role: 'user', content: draftPrompt },
|
||||
]
|
||||
|
||||
const ollamaResponse = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: LLM_MODEL,
|
||||
messages,
|
||||
stream: false,
|
||||
think: false,
|
||||
options: { temperature: 0.15, num_predict: 16384, num_ctx: 8192 },
|
||||
format: 'json',
|
||||
}),
|
||||
signal: AbortSignal.timeout(180000),
|
||||
})
|
||||
|
||||
if (!ollamaResponse.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `LLM nicht erreichbar (Status ${ollamaResponse.status})` },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await ollamaResponse.json()
|
||||
const content = result.message?.content || ''
|
||||
|
||||
let sections: DraftSection[] = []
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
sections = (parsed.sections || []).map((s: Record<string, unknown>, i: number) => ({
|
||||
id: String(s.id || `section-${i}`),
|
||||
title: String(s.title || ''),
|
||||
content: String(s.content || ''),
|
||||
schemaField: s.schemaField ? String(s.schemaField) : undefined,
|
||||
}))
|
||||
} catch {
|
||||
sections = [{ id: 'raw', title: 'Entwurf', content }]
|
||||
}
|
||||
|
||||
const draft: DraftRevision = {
|
||||
id: `draft-${Date.now()}`,
|
||||
content: sections.map(s => `## ${s.title}\n\n${s.content}`).join('\n\n'),
|
||||
sections,
|
||||
createdAt: new Date().toISOString(),
|
||||
instruction: instructions as string | undefined,
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
draft,
|
||||
constraintCheck,
|
||||
tokensUsed: result.eval_count || 0,
|
||||
} satisfies DraftResponse)
|
||||
}
|
||||
|
||||
// Re-export v2 handler for route.ts (backward compat — single import point)
|
||||
export { handleV2Draft } from './draft-helpers-v2'
|
||||
@@ -9,627 +9,7 @@
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://host.docker.internal:11434'
|
||||
const LLM_MODEL = process.env.COMPLIANCE_LLM_MODEL || 'qwen2.5vl:32b'
|
||||
|
||||
// v1 imports (Legacy)
|
||||
import { buildVVTDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-vvt'
|
||||
import { buildTOMDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-tom'
|
||||
import { buildDSFADraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-dsfa'
|
||||
import { buildPrivacyPolicyDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-privacy-policy'
|
||||
import { buildLoeschfristenDraftPrompt } from '@/lib/sdk/drafting-engine/prompts/draft-loeschfristen'
|
||||
import type { DraftContext, DraftResponse, DraftRevision, DraftSection } from '@/lib/sdk/drafting-engine/types'
|
||||
import type { ScopeDocumentType } from '@/lib/sdk/compliance-scope-types'
|
||||
import { ConstraintEnforcer } from '@/lib/sdk/drafting-engine/constraint-enforcer'
|
||||
|
||||
// v2 imports (Personalisierte Pipeline)
|
||||
import { deriveNarrativeTags, extractScoresFromDraftContext, narrativeTagsToPromptString } from '@/lib/sdk/drafting-engine/narrative-tags'
|
||||
import type { NarrativeTags } from '@/lib/sdk/drafting-engine/narrative-tags'
|
||||
import { buildAllowedFactsFromDraftContext, allowedFactsToPromptString, disallowedTopicsToPromptString } from '@/lib/sdk/drafting-engine/allowed-facts-v2'
|
||||
import { sanitizeAllowedFacts, validateNoRemainingPII, SanitizationError } from '@/lib/sdk/drafting-engine/sanitizer'
|
||||
import { terminologyToPromptString, styleContractToPromptString } from '@/lib/sdk/drafting-engine/terminology'
|
||||
import { executeRepairLoop, type ProseBlockOutput, type RepairAudit } from '@/lib/sdk/drafting-engine/prose-validator'
|
||||
import { ProseCacheManager, computeChecksumSync, type CacheKeyParams } from '@/lib/sdk/drafting-engine/cache'
|
||||
import { queryRAG } from '@/lib/sdk/drafting-engine/rag-query'
|
||||
import { DOCUMENT_RAG_CONFIG } from '@/lib/sdk/drafting-engine/rag-config'
|
||||
|
||||
// ============================================================================
|
||||
// Shared State
|
||||
// ============================================================================
|
||||
|
||||
const constraintEnforcer = new ConstraintEnforcer()
|
||||
const proseCache = new ProseCacheManager({ maxEntries: 200, ttlHours: 24 })
|
||||
|
||||
// Template/Terminology Versionen (fuer Cache-Key)
|
||||
const TEMPLATE_VERSION = '2.0.0'
|
||||
const TERMINOLOGY_VERSION = '1.0.0'
|
||||
const VALIDATOR_VERSION = '1.0.0'
|
||||
|
||||
// ============================================================================
|
||||
// v1 Legacy Pipeline
|
||||
// ============================================================================
|
||||
|
||||
const V1_SYSTEM_PROMPT = `Du bist ein DSGVO-Compliance-Experte und erstellst strukturierte Dokument-Entwuerfe.
|
||||
Du MUSST immer im JSON-Format antworten mit einem "sections" Array.
|
||||
Jede Section hat: id, title, content, schemaField.
|
||||
Halte die Tiefe strikt am vorgegebenen Level.
|
||||
Markiere fehlende Informationen mit [PLATZHALTER: Beschreibung].
|
||||
Sprache: Deutsch.`
|
||||
|
||||
function buildPromptForDocumentType(
|
||||
documentType: ScopeDocumentType,
|
||||
context: DraftContext,
|
||||
instructions?: string
|
||||
): string {
|
||||
switch (documentType) {
|
||||
case 'vvt':
|
||||
return buildVVTDraftPrompt({ context, instructions })
|
||||
case 'tom':
|
||||
return buildTOMDraftPrompt({ context, instructions })
|
||||
case 'dsfa':
|
||||
return buildDSFADraftPrompt({ context, instructions })
|
||||
case 'dsi':
|
||||
return buildPrivacyPolicyDraftPrompt({ context, instructions })
|
||||
case 'lf':
|
||||
return buildLoeschfristenDraftPrompt({ context, instructions })
|
||||
default:
|
||||
return `## Aufgabe: Entwurf fuer ${documentType}
|
||||
|
||||
### Level: ${context.decisions.level}
|
||||
### Tiefe: ${context.constraints.depthRequirements.depth}
|
||||
### Erforderliche Inhalte:
|
||||
${context.constraints.depthRequirements.detailItems.map((item, i) => `${i + 1}. ${item}`).join('\n')}
|
||||
|
||||
${instructions ? `### Anweisungen: ${instructions}` : ''}
|
||||
|
||||
Antworte als JSON mit "sections" Array.`
|
||||
}
|
||||
}
|
||||
|
||||
async function handleV1Draft(body: Record<string, unknown>): Promise<NextResponse> {
|
||||
const { documentType, draftContext, instructions, existingDraft } = body as {
|
||||
documentType: ScopeDocumentType
|
||||
draftContext: DraftContext
|
||||
instructions?: string
|
||||
existingDraft?: DraftRevision
|
||||
}
|
||||
|
||||
const constraintCheck = constraintEnforcer.checkFromContext(documentType, draftContext)
|
||||
if (!constraintCheck.allowed) {
|
||||
return NextResponse.json({
|
||||
draft: null,
|
||||
constraintCheck,
|
||||
tokensUsed: 0,
|
||||
error: 'Constraint-Verletzung: ' + constraintCheck.violations.join('; '),
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
// RAG: Fetch relevant legal context (config-based)
|
||||
const ragCfg = DOCUMENT_RAG_CONFIG[documentType]
|
||||
const ragContext = await queryRAG(ragCfg.query, 3, ragCfg.collection)
|
||||
|
||||
let v1SystemPrompt = V1_SYSTEM_PROMPT
|
||||
if (ragContext) {
|
||||
v1SystemPrompt += `\n\n## Relevanter Rechtskontext\n${ragContext}`
|
||||
}
|
||||
|
||||
const draftPrompt = buildPromptForDocumentType(documentType, draftContext, instructions)
|
||||
const messages = [
|
||||
{ role: 'system', content: v1SystemPrompt },
|
||||
...(existingDraft ? [{
|
||||
role: 'assistant',
|
||||
content: `Bisheriger Entwurf:\n${JSON.stringify(existingDraft.sections, null, 2)}`,
|
||||
}] : []),
|
||||
{ role: 'user', content: draftPrompt },
|
||||
]
|
||||
|
||||
const ollamaResponse = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: LLM_MODEL,
|
||||
messages,
|
||||
stream: false,
|
||||
think: false,
|
||||
options: { temperature: 0.15, num_predict: 16384, num_ctx: 8192 },
|
||||
format: 'json',
|
||||
}),
|
||||
signal: AbortSignal.timeout(180000),
|
||||
})
|
||||
|
||||
if (!ollamaResponse.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `LLM nicht erreichbar (Status ${ollamaResponse.status})` },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await ollamaResponse.json()
|
||||
const content = result.message?.content || ''
|
||||
|
||||
let sections: DraftSection[] = []
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
sections = (parsed.sections || []).map((s: Record<string, unknown>, i: number) => ({
|
||||
id: String(s.id || `section-${i}`),
|
||||
title: String(s.title || ''),
|
||||
content: String(s.content || ''),
|
||||
schemaField: s.schemaField ? String(s.schemaField) : undefined,
|
||||
}))
|
||||
} catch {
|
||||
sections = [{ id: 'raw', title: 'Entwurf', content }]
|
||||
}
|
||||
|
||||
const draft: DraftRevision = {
|
||||
id: `draft-${Date.now()}`,
|
||||
content: sections.map(s => `## ${s.title}\n\n${s.content}`).join('\n\n'),
|
||||
sections,
|
||||
createdAt: new Date().toISOString(),
|
||||
instruction: instructions as string | undefined,
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
draft,
|
||||
constraintCheck,
|
||||
tokensUsed: result.eval_count || 0,
|
||||
} satisfies DraftResponse)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// v2 Personalisierte Pipeline
|
||||
// ============================================================================
|
||||
|
||||
/** Prose block definitions per document type */
|
||||
const DOCUMENT_PROSE_BLOCKS: Record<string, Array<{ blockId: string; blockType: ProseBlockOutput['blockType']; sectionName: string; targetWords: number }>> = {
|
||||
tom: [
|
||||
{ blockId: 'tom-intro', blockType: 'introduction', sectionName: 'Einleitung TOM', targetWords: 120 },
|
||||
{ blockId: 'tom-transition', blockType: 'transition', sectionName: 'Ueberleitung Massnahmen', targetWords: 40 },
|
||||
{ blockId: 'tom-conclusion', blockType: 'conclusion', sectionName: 'Fazit TOM', targetWords: 80 },
|
||||
],
|
||||
dsfa: [
|
||||
{ blockId: 'dsfa-intro', blockType: 'introduction', sectionName: 'Einleitung DSFA', targetWords: 150 },
|
||||
{ blockId: 'dsfa-transition', blockType: 'transition', sectionName: 'Ueberleitung Risikobewertung', targetWords: 40 },
|
||||
{ blockId: 'dsfa-appreciation', blockType: 'appreciation', sectionName: 'Wuerdigung bestehender Massnahmen', targetWords: 60 },
|
||||
{ blockId: 'dsfa-conclusion', blockType: 'conclusion', sectionName: 'Fazit DSFA', targetWords: 100 },
|
||||
],
|
||||
vvt: [
|
||||
{ blockId: 'vvt-intro', blockType: 'introduction', sectionName: 'Einleitung VVT', targetWords: 120 },
|
||||
{ blockId: 'vvt-conclusion', blockType: 'conclusion', sectionName: 'Fazit VVT', targetWords: 80 },
|
||||
],
|
||||
dsi: [
|
||||
{ blockId: 'dsi-intro', blockType: 'introduction', sectionName: 'Einleitung Datenschutzerklaerung', targetWords: 130 },
|
||||
{ blockId: 'dsi-conclusion', blockType: 'conclusion', sectionName: 'Fazit Datenschutzerklaerung', targetWords: 80 },
|
||||
],
|
||||
lf: [
|
||||
{ blockId: 'lf-intro', blockType: 'introduction', sectionName: 'Einleitung Loeschfristen', targetWords: 100 },
|
||||
{ blockId: 'lf-conclusion', blockType: 'conclusion', sectionName: 'Fazit Loeschfristen', targetWords: 60 },
|
||||
],
|
||||
av_vertrag: [
|
||||
{ blockId: 'av-intro', blockType: 'introduction', sectionName: 'Einleitung Auftragsverarbeitung', targetWords: 130 },
|
||||
{ blockId: 'av-conclusion', blockType: 'conclusion', sectionName: 'Fazit Auftragsverarbeitung', targetWords: 80 },
|
||||
],
|
||||
betroffenenrechte: [
|
||||
{ blockId: 'betr-intro', blockType: 'introduction', sectionName: 'Einleitung Betroffenenrechte', targetWords: 120 },
|
||||
{ blockId: 'betr-conclusion', blockType: 'conclusion', sectionName: 'Fazit Betroffenenrechte', targetWords: 80 },
|
||||
],
|
||||
risikoanalyse: [
|
||||
{ blockId: 'risk-intro', blockType: 'introduction', sectionName: 'Einleitung Risikoanalyse', targetWords: 130 },
|
||||
{ blockId: 'risk-conclusion', blockType: 'conclusion', sectionName: 'Fazit Risikoanalyse', targetWords: 80 },
|
||||
],
|
||||
notfallplan: [
|
||||
{ blockId: 'notfall-intro', blockType: 'introduction', sectionName: 'Einleitung Notfallplan', targetWords: 120 },
|
||||
{ blockId: 'notfall-conclusion', blockType: 'conclusion', sectionName: 'Fazit Notfallplan', targetWords: 80 },
|
||||
],
|
||||
iace_ce_assessment: [
|
||||
{ blockId: 'iace-intro', blockType: 'introduction', sectionName: 'Einleitung IACE CE-Bewertung', targetWords: 150 },
|
||||
{ blockId: 'iace-appreciation', blockType: 'appreciation', sectionName: 'Wuerdigung bestehender Konformitaetsbewertung', targetWords: 60 },
|
||||
{ blockId: 'iace-conclusion', blockType: 'conclusion', sectionName: 'Fazit IACE CE-Bewertung', targetWords: 100 },
|
||||
],
|
||||
}
|
||||
|
||||
function buildV2SystemPrompt(
|
||||
sanitizedFactsString: string,
|
||||
narrativeTagsString: string,
|
||||
terminologyString: string,
|
||||
styleString: string,
|
||||
disallowedString: string,
|
||||
companyName: string,
|
||||
blockId: string,
|
||||
blockType: string,
|
||||
sectionName: string,
|
||||
documentType: string,
|
||||
targetWords: number
|
||||
): string {
|
||||
return `Du bist ein Compliance-Dokumenten-Redakteur.
|
||||
Du schreibst einzelne Textabschnitte fuer offizielle Compliance-Dokumente.
|
||||
|
||||
KUNDENPROFIL (ERLAUBTE FAKTEN — nur diese darfst du verwenden):
|
||||
${sanitizedFactsString}
|
||||
|
||||
BEWERTUNGSERGEBNIS (sprachliche Tags — verwende nur diese Begriffe):
|
||||
${narrativeTagsString}
|
||||
|
||||
TERMINOLOGIE (verwende ausschliesslich diese Fachbegriffe):
|
||||
${terminologyString}
|
||||
|
||||
STIL:
|
||||
${styleString}
|
||||
|
||||
VERBOTENE INHALTE:
|
||||
${disallowedString}
|
||||
- Keine konkreten Prozentwerte, Scores oder Zahlen
|
||||
- Keine Compliance-Level-Bezeichnungen (L1, L2, L3, L4)
|
||||
- Keine direkte Ansprache ("Sie", "Ihr")
|
||||
- Kein Denglisch, keine Marketing-Sprache, keine Superlative
|
||||
|
||||
STRIKTE REGELN:
|
||||
1. Verwende den Firmennamen "${companyName}" — nie "Ihr Unternehmen"
|
||||
2. Schreibe in der dritten Person ("Die ${companyName}...")
|
||||
3. Beziehe dich auf die Branche und organisatorische Merkmale
|
||||
4. Verwende NUR Fakten aus dem Kundenprofil oben
|
||||
5. Verwende NUR die sprachlichen Tags aus dem Bewertungsergebnis
|
||||
6. Erfinde KEINE zusaetzlichen Fakten oder Bewertungen
|
||||
7. Halte dich an die Terminologie-Vorgaben
|
||||
8. Dein Text wird ZWISCHEN feste Datentabellen eingefuegt
|
||||
|
||||
OUTPUT-FORMAT: Antworte ausschliesslich als JSON:
|
||||
{
|
||||
"blockId": "${blockId}",
|
||||
"blockType": "${blockType}",
|
||||
"language": "de",
|
||||
"text": "...",
|
||||
"assertions": {
|
||||
"companyNameUsed": true/false,
|
||||
"industryReferenced": true/false,
|
||||
"structureReferenced": true/false,
|
||||
"itLandscapeReferenced": true/false,
|
||||
"narrativeTagsUsed": ["riskSummary", ...]
|
||||
},
|
||||
"forbiddenContentDetected": []
|
||||
}
|
||||
|
||||
DOKUMENTENTYP: ${documentType}
|
||||
SEKTION: ${sectionName}
|
||||
BLOCK-TYP: ${blockType}
|
||||
ZIEL-LAENGE: ${targetWords} Woerter`
|
||||
}
|
||||
|
||||
function buildBlockSpecificPrompt(blockType: string, sectionName: string, documentType: string): string {
|
||||
switch (blockType) {
|
||||
case 'introduction':
|
||||
return `Schreibe eine Einleitung fuer das Dokument "${documentType}" (Sektion: ${sectionName}).
|
||||
Erklaere, warum dieses Dokument fuer das Unternehmen erstellt wurde.
|
||||
Gehe auf die spezifische Situation des Unternehmens ein.
|
||||
Erwaehne die Branche, die Organisationsform und die IT-Strategie.`
|
||||
case 'transition':
|
||||
return `Schreibe eine kurze Ueberleitung zur naechsten Sektion "${sectionName}".
|
||||
Verknuepfe den vorherigen Abschnitt logisch mit dem folgenden.`
|
||||
case 'conclusion':
|
||||
return `Schreibe einen abschliessenden Absatz fuer die Sektion "${sectionName}".
|
||||
Fasse die wesentlichen Punkte zusammen und verweise auf die fortlaufende Pflege.`
|
||||
case 'appreciation':
|
||||
return `Schreibe einen wertschaetzenden Satz ueber die bestehenden Massnahmen.
|
||||
Verwende dabei die sprachlichen Tags aus dem Bewertungsergebnis.
|
||||
Keine neuen Fakten erfinden — nur das Profil wuerdigen.`
|
||||
default:
|
||||
return `Schreibe einen Textabschnitt fuer "${sectionName}".`
|
||||
}
|
||||
}
|
||||
|
||||
async function callOllama(systemPrompt: string, userPrompt: string): Promise<string> {
|
||||
const response = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: LLM_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
stream: false,
|
||||
think: false,
|
||||
options: { temperature: 0.15, num_predict: 4096, num_ctx: 8192 },
|
||||
format: 'json',
|
||||
}),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama error: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return result.message?.content || ''
|
||||
}
|
||||
|
||||
async function handleV2Draft(body: Record<string, unknown>): Promise<NextResponse> {
|
||||
const { documentType, draftContext, instructions } = body as {
|
||||
documentType: ScopeDocumentType
|
||||
draftContext: DraftContext
|
||||
instructions?: string
|
||||
}
|
||||
|
||||
// Step 1: Constraint Check (Hard Gate)
|
||||
const constraintCheck = constraintEnforcer.checkFromContext(documentType, draftContext)
|
||||
if (!constraintCheck.allowed) {
|
||||
return NextResponse.json({
|
||||
draft: null,
|
||||
constraintCheck,
|
||||
tokensUsed: 0,
|
||||
error: 'Constraint-Verletzung: ' + constraintCheck.violations.join('; '),
|
||||
}, { status: 403 })
|
||||
}
|
||||
|
||||
// Step 2: Derive Narrative Tags (deterministisch)
|
||||
const scores = extractScoresFromDraftContext(draftContext)
|
||||
const narrativeTags: NarrativeTags = deriveNarrativeTags(scores)
|
||||
|
||||
// Step 3: Build Allowed Facts
|
||||
const allowedFacts = buildAllowedFactsFromDraftContext(draftContext, narrativeTags)
|
||||
|
||||
// Step 4: PII Sanitization
|
||||
let sanitizationResult
|
||||
try {
|
||||
sanitizationResult = sanitizeAllowedFacts(allowedFacts)
|
||||
} catch (error) {
|
||||
if (error instanceof SanitizationError) {
|
||||
return NextResponse.json({
|
||||
error: `Sanitization Hard Abort: ${error.message} (Feld: ${error.field})`,
|
||||
draft: null,
|
||||
constraintCheck,
|
||||
tokensUsed: 0,
|
||||
}, { status: 422 })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const sanitizedFacts = sanitizationResult.facts
|
||||
|
||||
// Verify no remaining PII
|
||||
const piiWarnings = validateNoRemainingPII(sanitizedFacts)
|
||||
if (piiWarnings.length > 0) {
|
||||
console.warn('PII-Warnungen nach Sanitization:', piiWarnings)
|
||||
}
|
||||
|
||||
// Step 5: Build prompt components
|
||||
const factsString = allowedFactsToPromptString(sanitizedFacts)
|
||||
const tagsString = narrativeTagsToPromptString(narrativeTags)
|
||||
const termsString = terminologyToPromptString()
|
||||
const styleString = styleContractToPromptString()
|
||||
const disallowedString = disallowedTopicsToPromptString()
|
||||
|
||||
// Compute prompt hash for audit
|
||||
const promptHash = computeChecksumSync({ factsString, tagsString, termsString, styleString, disallowedString })
|
||||
|
||||
// Step 5b: RAG Legal Context (config-based)
|
||||
const v2RagCfg = DOCUMENT_RAG_CONFIG[documentType]
|
||||
const v2RagContext = await queryRAG(v2RagCfg.query, 3, v2RagCfg.collection)
|
||||
|
||||
// Step 6: Generate Prose Blocks (with cache + repair loop)
|
||||
const proseBlocks = DOCUMENT_PROSE_BLOCKS[documentType] || DOCUMENT_PROSE_BLOCKS.tom
|
||||
const generatedBlocks: ProseBlockOutput[] = []
|
||||
const repairAudits: RepairAudit[] = []
|
||||
let totalTokens = 0
|
||||
|
||||
for (const blockDef of proseBlocks) {
|
||||
// Check cache
|
||||
const cacheParams: CacheKeyParams = {
|
||||
allowedFacts: sanitizedFacts,
|
||||
templateVersion: TEMPLATE_VERSION,
|
||||
terminologyVersion: TERMINOLOGY_VERSION,
|
||||
narrativeTags,
|
||||
promptHash,
|
||||
blockType: blockDef.blockType,
|
||||
sectionName: blockDef.sectionName,
|
||||
}
|
||||
|
||||
const cached = proseCache.getSync(cacheParams)
|
||||
if (cached) {
|
||||
generatedBlocks.push(cached)
|
||||
repairAudits.push({
|
||||
repairAttempts: 0,
|
||||
validatorFailures: [],
|
||||
repairSuccessful: true,
|
||||
fallbackUsed: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Build prompts
|
||||
let systemPrompt = buildV2SystemPrompt(
|
||||
factsString, tagsString, termsString, styleString, disallowedString,
|
||||
sanitizedFacts.companyName,
|
||||
blockDef.blockId, blockDef.blockType, blockDef.sectionName,
|
||||
documentType, blockDef.targetWords
|
||||
)
|
||||
if (v2RagContext) {
|
||||
systemPrompt += `\n\nRECHTSKONTEXT (als Referenz, nicht woertlich uebernehmen):\n${v2RagContext}`
|
||||
}
|
||||
const userPrompt = buildBlockSpecificPrompt(
|
||||
blockDef.blockType, blockDef.sectionName, documentType
|
||||
) + (instructions ? `\n\nZusaetzliche Anweisungen: ${instructions}` : '')
|
||||
|
||||
// Call LLM + Repair Loop
|
||||
try {
|
||||
const rawOutput = await callOllama(systemPrompt, userPrompt)
|
||||
totalTokens += rawOutput.length / 4 // Rough token estimate
|
||||
|
||||
const { block, audit } = await executeRepairLoop(
|
||||
rawOutput,
|
||||
sanitizedFacts,
|
||||
narrativeTags,
|
||||
blockDef.blockId,
|
||||
blockDef.blockType,
|
||||
async (repairPrompt) => callOllama(systemPrompt, repairPrompt),
|
||||
documentType
|
||||
)
|
||||
|
||||
generatedBlocks.push(block)
|
||||
repairAudits.push(audit)
|
||||
|
||||
// Cache successful blocks (not fallbacks)
|
||||
if (!audit.fallbackUsed) {
|
||||
proseCache.setSync(cacheParams, block)
|
||||
}
|
||||
} catch (error) {
|
||||
// LLM unreachable → Fallback
|
||||
const { buildFallbackBlock } = await import('@/lib/sdk/drafting-engine/prose-validator')
|
||||
generatedBlocks.push(
|
||||
buildFallbackBlock(blockDef.blockId, blockDef.blockType, sanitizedFacts, documentType)
|
||||
)
|
||||
repairAudits.push({
|
||||
repairAttempts: 0,
|
||||
validatorFailures: [[(error as Error).message]],
|
||||
repairSuccessful: false,
|
||||
fallbackUsed: true,
|
||||
fallbackReason: `LLM-Fehler: ${(error as Error).message}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: Build v1-compatible draft sections from prose blocks + original prompt
|
||||
const draftPrompt = buildPromptForDocumentType(documentType, draftContext, instructions)
|
||||
|
||||
// Also generate data sections via legacy pipeline
|
||||
let dataSections: DraftSection[] = []
|
||||
try {
|
||||
const dataResponse = await callOllama(V1_SYSTEM_PROMPT, draftPrompt)
|
||||
const parsed = JSON.parse(dataResponse)
|
||||
dataSections = (parsed.sections || []).map((s: Record<string, unknown>, i: number) => ({
|
||||
id: String(s.id || `section-${i}`),
|
||||
title: String(s.title || ''),
|
||||
content: String(s.content || ''),
|
||||
schemaField: s.schemaField ? String(s.schemaField) : undefined,
|
||||
}))
|
||||
totalTokens += dataResponse.length / 4
|
||||
} catch {
|
||||
dataSections = []
|
||||
}
|
||||
|
||||
// Merge: Prose intro → Data sections → Prose transitions/conclusion
|
||||
const introBlock = generatedBlocks.find(b => b.blockType === 'introduction')
|
||||
const transitionBlocks = generatedBlocks.filter(b => b.blockType === 'transition')
|
||||
const appreciationBlocks = generatedBlocks.filter(b => b.blockType === 'appreciation')
|
||||
const conclusionBlock = generatedBlocks.find(b => b.blockType === 'conclusion')
|
||||
|
||||
const mergedSections: DraftSection[] = []
|
||||
|
||||
if (introBlock) {
|
||||
mergedSections.push({
|
||||
id: introBlock.blockId,
|
||||
title: 'Einleitung',
|
||||
content: introBlock.text,
|
||||
})
|
||||
}
|
||||
|
||||
for (let i = 0; i < dataSections.length; i++) {
|
||||
// Insert transition before data section (if available)
|
||||
if (i > 0 && transitionBlocks[i - 1]) {
|
||||
mergedSections.push({
|
||||
id: transitionBlocks[i - 1].blockId,
|
||||
title: '',
|
||||
content: transitionBlocks[i - 1].text,
|
||||
})
|
||||
}
|
||||
mergedSections.push(dataSections[i])
|
||||
}
|
||||
|
||||
for (const block of appreciationBlocks) {
|
||||
mergedSections.push({
|
||||
id: block.blockId,
|
||||
title: 'Wuerdigung',
|
||||
content: block.text,
|
||||
})
|
||||
}
|
||||
|
||||
if (conclusionBlock) {
|
||||
mergedSections.push({
|
||||
id: conclusionBlock.blockId,
|
||||
title: 'Fazit',
|
||||
content: conclusionBlock.text,
|
||||
})
|
||||
}
|
||||
|
||||
// If no data sections generated, use prose blocks as sections
|
||||
const finalSections = mergedSections.length > 0 ? mergedSections : generatedBlocks.map(b => ({
|
||||
id: b.blockId,
|
||||
title: b.blockType === 'introduction' ? 'Einleitung' :
|
||||
b.blockType === 'conclusion' ? 'Fazit' :
|
||||
b.blockType === 'appreciation' ? 'Wuerdigung' : 'Ueberleitung',
|
||||
content: b.text,
|
||||
}))
|
||||
|
||||
const draft: DraftRevision = {
|
||||
id: `draft-v2-${Date.now()}`,
|
||||
content: finalSections.map(s => s.title ? `## ${s.title}\n\n${s.content}` : s.content).join('\n\n'),
|
||||
sections: finalSections,
|
||||
createdAt: new Date().toISOString(),
|
||||
instruction: instructions,
|
||||
}
|
||||
|
||||
// Step 8: Build Audit Trail
|
||||
const auditTrail = {
|
||||
documentType,
|
||||
templateVersion: TEMPLATE_VERSION,
|
||||
terminologyVersion: TERMINOLOGY_VERSION,
|
||||
validatorVersion: VALIDATOR_VERSION,
|
||||
promptHash,
|
||||
llmModel: LLM_MODEL,
|
||||
llmTemperature: 0.15,
|
||||
llmProvider: 'ollama',
|
||||
narrativeTags,
|
||||
sanitization: sanitizationResult.audit,
|
||||
repairAudits,
|
||||
proseBlocks: generatedBlocks.map((b, i) => ({
|
||||
blockId: b.blockId,
|
||||
blockType: b.blockType,
|
||||
wordCount: b.text.split(/\s+/).filter(Boolean).length,
|
||||
fallbackUsed: repairAudits[i]?.fallbackUsed ?? false,
|
||||
repairAttempts: repairAudits[i]?.repairAttempts ?? 0,
|
||||
})),
|
||||
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,
|
||||
})
|
||||
}
|
||||
import { handleV1Draft, handleV2Draft } from './draft-helpers'
|
||||
|
||||
// ============================================================================
|
||||
// Route Handler
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}`)
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to fetch registration' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const body = await request.json()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to update registration' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to update status' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to fetch registrations' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const body = await request.json()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to create registration' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* Demo Data Clear API Endpoint
|
||||
*
|
||||
* Clears demo data from the storage (same mechanism as real customer data).
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
// Shared store reference (same as seed endpoint)
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var demoStateStore: Map<string, { state: unknown; version: number; updatedAt: Date }> | undefined
|
||||
}
|
||||
|
||||
if (!global.demoStateStore) {
|
||||
global.demoStateStore = new Map()
|
||||
}
|
||||
|
||||
const stateStore = global.demoStateStore
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { tenantId = 'demo-tenant' } = body
|
||||
|
||||
const existed = stateStore.has(tenantId)
|
||||
stateStore.delete(tenantId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: existed
|
||||
? `Demo data cleared for tenant ${tenantId}`
|
||||
: `No data found for tenant ${tenantId}`,
|
||||
tenantId,
|
||||
existed,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to clear demo data:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// Also support POST for clearing (for clients that don't support DELETE)
|
||||
return DELETE(request)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* Demo Data Seed API Endpoint
|
||||
*
|
||||
* This endpoint seeds demo data via the same storage mechanism as real customer data.
|
||||
* Demo data is NOT hardcoded - it goes through the normal API/database path.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { generateDemoState } from '@/lib/sdk/demo-data'
|
||||
|
||||
// In-memory store (same as state endpoint - will be replaced with PostgreSQL)
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var demoStateStore: Map<string, { state: unknown; version: number; updatedAt: Date }> | undefined
|
||||
}
|
||||
|
||||
if (!global.demoStateStore) {
|
||||
global.demoStateStore = new Map()
|
||||
}
|
||||
|
||||
const stateStore = global.demoStateStore
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { tenantId = 'demo-tenant', userId = 'demo-user' } = body
|
||||
|
||||
// Generate demo state using the seed data templates
|
||||
const demoState = generateDemoState(tenantId, userId)
|
||||
|
||||
// Store via the same mechanism as real data
|
||||
const storedState = {
|
||||
state: demoState,
|
||||
version: 1,
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
stateStore.set(tenantId, storedState)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Demo data seeded for tenant ${tenantId}`,
|
||||
tenantId,
|
||||
version: 1,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to seed demo data:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const tenantId = searchParams.get('tenantId') || 'demo-tenant'
|
||||
|
||||
const stored = stateStore.get(tenantId)
|
||||
|
||||
if (!stored) {
|
||||
return NextResponse.json({
|
||||
hasData: false,
|
||||
tenantId,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
hasData: true,
|
||||
tenantId,
|
||||
version: stored.version,
|
||||
updatedAt: stored.updatedAt,
|
||||
})
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
/**
|
||||
* Proxy: /api/sdk/v1/ucca/decision-tree/... → Go Backend /sdk/v1/ucca/decision-tree/...
|
||||
*/
|
||||
async function proxyRequest(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
const { path } = await params
|
||||
const subPath = path ? path.join('/') : ''
|
||||
const search = request.nextUrl.search || ''
|
||||
const targetUrl = `${SDK_URL}/sdk/v1/ucca/decision-tree/${subPath}${search}`
|
||||
|
||||
const tenantID = request.headers.get('X-Tenant-ID') || DEFAULT_TENANT
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'X-Tenant-ID': tenantID,
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: request.method,
|
||||
headers,
|
||||
}
|
||||
|
||||
if (request.method === 'POST' || request.method === 'PUT' || request.method === 'PATCH') {
|
||||
const body = await request.json()
|
||||
headers['Content-Type'] = 'application/json'
|
||||
fetchOptions.body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error(`Decision tree proxy error [${request.method} ${subPath}]:`, errorText)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data, { status: response.status })
|
||||
} catch (error) {
|
||||
console.error('Decision tree proxy connection error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to AI compliance backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = proxyRequest
|
||||
export const POST = proxyRequest
|
||||
export const DELETE = proxyRequest
|
||||
@@ -1,36 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/ucca/decision-tree → Go Backend GET /sdk/v1/ucca/decision-tree
|
||||
* Returns the decision tree definition (questions, structure)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const tenantID = request.headers.get('X-Tenant-ID') || DEFAULT_TENANT
|
||||
|
||||
try {
|
||||
const response = await fetch(`${SDK_URL}/sdk/v1/ucca/decision-tree`, {
|
||||
headers: { 'X-Tenant-ID': tenantID },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('Decision tree GET error:', errorText)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Decision tree proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to AI compliance backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Course, COURSE_CATEGORY_INFO } from '@/lib/sdk/academy/types'
|
||||
|
||||
interface CourseHeaderProps {
|
||||
course: Course
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
export function CourseHeader({ course, onDelete }: CourseHeaderProps) {
|
||||
const categoryInfo = COURSE_CATEGORY_INFO[course.category] || COURSE_CATEGORY_INFO['custom']
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/sdk/academy"
|
||||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg 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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
|
||||
{categoryInfo.label}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
course.status === 'published' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{course.status === 'published' ? 'Veroeffentlicht' : 'Entwurf'}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{course.title}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">{course.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="px-3 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import { Course, Lesson, Enrollment } from '@/lib/sdk/academy/types'
|
||||
|
||||
interface CourseStatsProps {
|
||||
course: Course
|
||||
sortedLessons: Lesson[]
|
||||
enrollments: Enrollment[]
|
||||
completedEnrollments: number
|
||||
}
|
||||
|
||||
export function CourseStats({ course, sortedLessons, enrollments, completedEnrollments }: CourseStatsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500">Lektionen</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{sortedLessons.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500">Dauer</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{course.durationMinutes} Min.</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500">Teilnehmer</div>
|
||||
<div className="text-2xl font-bold text-blue-600">{enrollments.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500">Abgeschlossen</div>
|
||||
<div className="text-2xl font-bold text-green-600">{completedEnrollments}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
type TabId = 'overview' | 'lessons' | 'enrollments' | 'videos'
|
||||
|
||||
interface CourseTabsProps {
|
||||
activeTab: TabId
|
||||
onTabChange: (tab: TabId) => void
|
||||
}
|
||||
|
||||
const TAB_LABELS: Record<TabId, string> = {
|
||||
overview: 'Uebersicht',
|
||||
lessons: 'Lektionen',
|
||||
enrollments: 'Einschreibungen',
|
||||
videos: 'Videos',
|
||||
}
|
||||
|
||||
export function CourseTabs({ activeTab, onTabChange }: CourseTabsProps) {
|
||||
return (
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-1 -mb-px">
|
||||
{(['overview', 'lessons', 'enrollments', 'videos'] as TabId[]).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => onTabChange(tab)}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab
|
||||
? 'border-purple-600 text-purple-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{TAB_LABELS[tab]}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import { Enrollment, ENROLLMENT_STATUS_INFO, isEnrollmentOverdue, getDaysUntilDeadline } from '@/lib/sdk/academy/types'
|
||||
|
||||
interface EnrollmentsTabProps {
|
||||
enrollments: Enrollment[]
|
||||
overdueEnrollments: number
|
||||
}
|
||||
|
||||
export function EnrollmentsTab({ enrollments, overdueEnrollments }: EnrollmentsTabProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{overdueEnrollments > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">
|
||||
{overdueEnrollments} ueberfaellige Einschreibung(en)
|
||||
</div>
|
||||
)}
|
||||
{enrollments.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<p className="text-gray-500">Noch keine Einschreibungen fuer diesen Kurs.</p>
|
||||
</div>
|
||||
) : (
|
||||
enrollments.map(enrollment => {
|
||||
const statusInfo = ENROLLMENT_STATUS_INFO[enrollment.status]
|
||||
const overdue = isEnrollmentOverdue(enrollment)
|
||||
const daysUntil = getDaysUntilDeadline(enrollment.deadline)
|
||||
return (
|
||||
<div key={enrollment.id} className={`bg-white rounded-xl border-2 p-5 ${overdue ? 'border-red-200' : 'border-gray-200'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${statusInfo?.bgColor} ${statusInfo?.color}`}>
|
||||
{statusInfo?.label}
|
||||
</span>
|
||||
{overdue && <span className="text-xs text-red-600">Ueberfaellig</span>}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900">{enrollment.userName}</div>
|
||||
<div className="text-sm text-gray-500">{enrollment.userEmail}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-gray-900">{enrollment.progress}%</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{enrollment.status === 'completed' ? 'Abgeschlossen' : `${daysUntil > 0 ? daysUntil + ' Tage verbleibend' : Math.abs(daysUntil) + ' Tage ueberfaellig'}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${enrollment.progress === 100 ? 'bg-green-500' : overdue ? 'bg-red-500' : 'bg-purple-500'}`}
|
||||
style={{ width: `${enrollment.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
239
admin-compliance/app/sdk/academy/[id]/_components/LessonsTab.tsx
Normal file
239
admin-compliance/app/sdk/academy/[id]/_components/LessonsTab.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
'use client'
|
||||
|
||||
import { Lesson, QuizQuestion } from '@/lib/sdk/academy/types'
|
||||
|
||||
interface LessonsTabProps {
|
||||
sortedLessons: Lesson[]
|
||||
selectedLesson: Lesson | null
|
||||
onSelectLesson: (lesson: Lesson) => void
|
||||
quizAnswers: Record<string, number>
|
||||
onQuizAnswer: (answers: Record<string, number>) => void
|
||||
quizResult: any
|
||||
isSubmittingQuiz: boolean
|
||||
onSubmitQuiz: () => void
|
||||
onResetQuiz: () => void
|
||||
isEditing: boolean
|
||||
editTitle: string
|
||||
editContent: string
|
||||
onEditTitle: (v: string) => void
|
||||
onEditContent: (v: string) => void
|
||||
isSaving: boolean
|
||||
saveMessage: { type: 'success' | 'error'; text: string } | null
|
||||
onStartEdit: () => void
|
||||
onCancelEdit: () => void
|
||||
onSaveLesson: () => void
|
||||
onApproveLesson: () => void
|
||||
}
|
||||
|
||||
export function LessonsTab({
|
||||
sortedLessons,
|
||||
selectedLesson,
|
||||
onSelectLesson,
|
||||
quizAnswers,
|
||||
onQuizAnswer,
|
||||
quizResult,
|
||||
isSubmittingQuiz,
|
||||
onSubmitQuiz,
|
||||
onResetQuiz,
|
||||
isEditing,
|
||||
editTitle,
|
||||
editContent,
|
||||
onEditTitle,
|
||||
onEditContent,
|
||||
isSaving,
|
||||
saveMessage,
|
||||
onStartEdit,
|
||||
onCancelEdit,
|
||||
onSaveLesson,
|
||||
onApproveLesson,
|
||||
}: LessonsTabProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Lesson Navigation */}
|
||||
<div className="col-span-1 bg-white rounded-xl border border-gray-200 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Lektionen</h3>
|
||||
<div className="space-y-1">
|
||||
{sortedLessons.map((lesson, i) => (
|
||||
<button
|
||||
key={lesson.id}
|
||||
onClick={() => onSelectLesson(lesson)}
|
||||
className={`w-full text-left p-3 rounded-lg text-sm transition-colors ${
|
||||
selectedLesson?.id === lesson.id
|
||||
? 'bg-purple-50 text-purple-700 border border-purple-200'
|
||||
: 'hover:bg-gray-50 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400 w-4">{i + 1}.</span>
|
||||
<span className="truncate">{lesson.title}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lesson Content */}
|
||||
<div className="col-span-2 bg-white rounded-xl border border-gray-200 p-6">
|
||||
{selectedLesson ? (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={e => onEditTitle(e.target.value)}
|
||||
className="text-xl font-semibold text-gray-900 border border-gray-300 rounded-lg px-3 py-1 flex-1 mr-3"
|
||||
/>
|
||||
) : (
|
||||
<h2 className="text-xl font-semibold text-gray-900">{selectedLesson.title}</h2>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-3 py-1 text-xs rounded-full ${
|
||||
selectedLesson.type === 'quiz' ? 'bg-yellow-100 text-yellow-700' :
|
||||
selectedLesson.type === 'video' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{selectedLesson.type === 'quiz' ? 'Quiz' : selectedLesson.type === 'video' ? 'Video' : 'Text'}
|
||||
</span>
|
||||
{selectedLesson.type !== 'quiz' && !isEditing && (
|
||||
<>
|
||||
<button onClick={onStartEdit} className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors">
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button onClick={onApproveLesson} disabled={isSaving} className="px-3 py-1 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50">
|
||||
Freigeben
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isEditing && (
|
||||
<>
|
||||
<button onClick={onCancelEdit} className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={onSaveLesson} disabled={isSaving} className="px-3 py-1 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50">
|
||||
{isSaving ? 'Speichert...' : 'Speichern'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveMessage && (
|
||||
<div className={`p-3 rounded-lg text-sm ${
|
||||
saveMessage.type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'
|
||||
}`}>
|
||||
{saveMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLesson.type === 'video' && selectedLesson.videoUrl && (
|
||||
<div className="aspect-video bg-gray-900 rounded-xl overflow-hidden">
|
||||
<video src={selectedLesson.videoUrl} controls className="w-full h-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing && (selectedLesson.type === 'text' || selectedLesson.type === 'video') && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-500">Inhalt (Markdown)</label>
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={e => onEditContent(e.target.value)}
|
||||
rows={20}
|
||||
className="w-full border border-gray-300 rounded-xl p-4 text-sm font-mono text-gray-800 leading-relaxed resize-y focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="Markdown-Inhalt der Lektion..."
|
||||
/>
|
||||
<p className="text-xs text-gray-400">Unterstuetzt: # Ueberschrift, ## Unterueberschrift, - Aufzaehlung, **fett**</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditing && (selectedLesson.type === 'text' || selectedLesson.type === 'video') && selectedLesson.contentMarkdown && (
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<div className="whitespace-pre-wrap text-gray-700 leading-relaxed">
|
||||
{selectedLesson.contentMarkdown.split('\n').map((line, i) => {
|
||||
if (line.startsWith('# ')) return <h1 key={i} className="text-2xl font-bold text-gray-900 mt-6 mb-3">{line.slice(2)}</h1>
|
||||
if (line.startsWith('## ')) return <h2 key={i} className="text-xl font-semibold text-gray-900 mt-5 mb-2">{line.slice(3)}</h2>
|
||||
if (line.startsWith('### ')) return <h3 key={i} className="text-lg font-medium text-gray-900 mt-4 mb-2">{line.slice(4)}</h3>
|
||||
if (line.startsWith('- **')) {
|
||||
const parts = line.slice(2).split('**')
|
||||
return <li key={i} className="ml-4 list-disc"><strong>{parts[1]}</strong>{parts[2] || ''}</li>
|
||||
}
|
||||
if (line.startsWith('- ')) return <li key={i} className="ml-4 list-disc">{line.slice(2)}</li>
|
||||
if (line.trim() === '') return <br key={i} />
|
||||
return <p key={i} className="mb-2">{line}</p>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLesson.type === 'quiz' && selectedLesson.quizQuestions && (
|
||||
<div className="space-y-6">
|
||||
{selectedLesson.quizQuestions.map((q: QuizQuestion, qi: number) => (
|
||||
<div key={q.id} className="bg-gray-50 rounded-xl p-5">
|
||||
<h4 className="font-medium text-gray-900 mb-3">Frage {qi + 1}: {q.question}</h4>
|
||||
<div className="space-y-2">
|
||||
{q.options.map((option: string, oi: number) => {
|
||||
const isSelected = quizAnswers[q.id] === oi
|
||||
const showResult = quizResult && !quizResult.error
|
||||
const isCorrect = showResult && quizResult.results?.[qi]?.correct
|
||||
const wasSelected = showResult && isSelected
|
||||
|
||||
let bgClass = 'bg-white border-gray-200 hover:border-purple-300'
|
||||
if (isSelected && !showResult) bgClass = 'bg-purple-50 border-purple-500'
|
||||
if (showResult && oi === q.correctOptionIndex) bgClass = 'bg-green-50 border-green-500'
|
||||
if (showResult && wasSelected && !isCorrect) bgClass = 'bg-red-50 border-red-500'
|
||||
|
||||
return (
|
||||
<button
|
||||
key={oi}
|
||||
onClick={() => !quizResult && onQuizAnswer({ ...quizAnswers, [q.id]: oi })}
|
||||
disabled={!!quizResult}
|
||||
className={`w-full text-left p-3 rounded-lg border-2 transition-all ${bgClass}`}
|
||||
>
|
||||
<span className="text-sm text-gray-700">{String.fromCharCode(65 + oi)}) {option}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{quizResult && !quizResult.error && quizResult.results?.[qi] && (
|
||||
<div className={`mt-3 p-3 rounded-lg text-sm ${
|
||||
quizResult.results[qi].correct ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
||||
}`}>
|
||||
{quizResult.results[qi].correct ? 'Richtig!' : 'Falsch.'} {q.explanation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!quizResult ? (
|
||||
<button
|
||||
onClick={onSubmitQuiz}
|
||||
disabled={isSubmittingQuiz || Object.keys(quizAnswers).length < (selectedLesson.quizQuestions?.length || 0)}
|
||||
className="w-full py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors disabled:opacity-50 font-medium"
|
||||
>
|
||||
{isSubmittingQuiz ? 'Wird ausgewertet...' : 'Quiz auswerten'}
|
||||
</button>
|
||||
) : quizResult.error ? (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">{quizResult.error}</div>
|
||||
) : (
|
||||
<div className={`rounded-xl p-6 text-center ${quizResult.passed ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
|
||||
<div className={`text-3xl font-bold ${quizResult.passed ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{quizResult.score}%
|
||||
</div>
|
||||
<div className={`text-sm mt-1 ${quizResult.passed ? 'text-green-700' : 'text-red-700'}`}>
|
||||
{quizResult.passed ? 'Bestanden!' : 'Nicht bestanden'} — {quizResult.correctAnswers}/{quizResult.totalQuestions} richtig
|
||||
</div>
|
||||
<button onClick={onResetQuiz} className="mt-4 px-4 py-2 text-sm bg-white rounded-lg border hover:bg-gray-50">
|
||||
Quiz wiederholen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-10">Waehlen Sie eine Lektion aus.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import { Course, Lesson } from '@/lib/sdk/academy/types'
|
||||
|
||||
interface OverviewTabProps {
|
||||
course: Course
|
||||
sortedLessons: Lesson[]
|
||||
}
|
||||
|
||||
export function OverviewTab({ course, sortedLessons }: OverviewTabProps) {
|
||||
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-4">Kurs-Details</h3>
|
||||
<dl className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div><dt className="text-gray-500">Bestehensgrenze</dt><dd className="font-medium text-gray-900">{course.passingScore}%</dd></div>
|
||||
<div><dt className="text-gray-500">Pflicht fuer</dt><dd className="font-medium text-gray-900">{course.requiredForRoles?.join(', ') || 'Alle'}</dd></div>
|
||||
<div><dt className="text-gray-500">Erstellt am</dt><dd className="font-medium text-gray-900">{new Date(course.createdAt).toLocaleDateString('de-DE')}</dd></div>
|
||||
<div><dt className="text-gray-500">Aktualisiert am</dt><dd className="font-medium text-gray-900">{new Date(course.updatedAt).toLocaleDateString('de-DE')}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Lektionen ({sortedLessons.length})</h3>
|
||||
<div className="space-y-2">
|
||||
{sortedLessons.map((lesson, i) => (
|
||||
<div key={lesson.id} className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50">
|
||||
<div className="w-8 h-8 rounded-full bg-purple-100 text-purple-600 flex items-center justify-center text-sm font-medium">
|
||||
{i + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">{lesson.title}</div>
|
||||
<div className="text-xs text-gray-500">{lesson.durationMinutes} Min. | {lesson.type === 'video' ? 'Video' : lesson.type === 'quiz' ? 'Quiz' : 'Text'}</div>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
lesson.type === 'quiz' ? 'bg-yellow-100 text-yellow-700' :
|
||||
lesson.type === 'video' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{lesson.type === 'quiz' ? 'Quiz' : lesson.type === 'video' ? 'Video' : 'Text'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
interface VideoStatus {
|
||||
status: string
|
||||
lessons?: Array<{ lessonId: string; status: string }>
|
||||
}
|
||||
|
||||
interface VideosTabProps {
|
||||
videoStatus: VideoStatus | null
|
||||
isGeneratingVideos: boolean
|
||||
onGenerateVideos: () => void
|
||||
onCheckVideoStatus: () => void
|
||||
}
|
||||
|
||||
export function VideosTab({ videoStatus, isGeneratingVideos, onGenerateVideos, onCheckVideoStatus }: VideosTabProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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">Video-Generierung</h3>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onCheckVideoStatus} className="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
Status pruefen
|
||||
</button>
|
||||
<button
|
||||
onClick={onGenerateVideos}
|
||||
disabled={isGeneratingVideos}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{isGeneratingVideos ? 'Wird gestartet...' : 'Videos generieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-4 text-sm text-blue-700">
|
||||
Videos werden mit ElevenLabs (Stimme) und HeyGen (Avatar) generiert.
|
||||
Konfigurieren Sie die API-Keys in den Umgebungsvariablen.
|
||||
</div>
|
||||
|
||||
{videoStatus && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Gesamtstatus:</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
videoStatus.status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||
videoStatus.status === 'processing' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{videoStatus.status}
|
||||
</span>
|
||||
</div>
|
||||
{videoStatus.lessons?.map((ls) => (
|
||||
<div key={ls.lessonId} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-sm text-gray-700">Lektion {ls.lessonId.slice(-4)}</span>
|
||||
<span className={`text-xs ${ls.status === 'completed' ? 'text-green-600' : 'text-gray-500'}`}>
|
||||
{ls.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!videoStatus && (
|
||||
<p className="text-sm text-gray-500 text-center py-8">
|
||||
Klicken Sie auf "Videos generieren" um den Prozess zu starten.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Course,
|
||||
Lesson,
|
||||
Enrollment,
|
||||
QuizQuestion,
|
||||
COURSE_CATEGORY_INFO,
|
||||
ENROLLMENT_STATUS_INFO,
|
||||
isEnrollmentOverdue,
|
||||
getDaysUntilDeadline
|
||||
} from '@/lib/sdk/academy/types'
|
||||
import {
|
||||
fetchCourse,
|
||||
@@ -20,8 +16,15 @@ import {
|
||||
submitQuiz,
|
||||
updateLesson,
|
||||
generateVideos,
|
||||
getVideoStatus
|
||||
getVideoStatus,
|
||||
} from '@/lib/sdk/academy/api'
|
||||
import { CourseHeader } from './_components/CourseHeader'
|
||||
import { CourseStats } from './_components/CourseStats'
|
||||
import { CourseTabs } from './_components/CourseTabs'
|
||||
import { OverviewTab } from './_components/OverviewTab'
|
||||
import { LessonsTab } from './_components/LessonsTab'
|
||||
import { EnrollmentsTab } from './_components/EnrollmentsTab'
|
||||
import { VideosTab } from './_components/VideosTab'
|
||||
|
||||
type TabId = 'overview' | 'lessons' | 'enrollments' | 'videos'
|
||||
|
||||
@@ -81,8 +84,7 @@ export default function CourseDetailPage() {
|
||||
const handleSubmitQuiz = async () => {
|
||||
if (!selectedLesson) return
|
||||
const questions = selectedLesson.quizQuestions || []
|
||||
const answers = questions.map((q: QuizQuestion) => quizAnswers[q.id] ?? -1)
|
||||
|
||||
const answers = questions.map((q: any) => quizAnswers[q.id] ?? -1)
|
||||
setIsSubmittingQuiz(true)
|
||||
try {
|
||||
const result = await submitQuiz(selectedLesson.id, { answers })
|
||||
@@ -113,10 +115,7 @@ export default function CourseDetailPage() {
|
||||
setIsSaving(true)
|
||||
setSaveMessage(null)
|
||||
try {
|
||||
await updateLesson(selectedLesson.id, {
|
||||
title: editTitle,
|
||||
content_url: editContent,
|
||||
})
|
||||
await updateLesson(selectedLesson.id, { title: editTitle, content_url: editContent })
|
||||
const updatedLesson = { ...selectedLesson, title: editTitle, contentMarkdown: editContent }
|
||||
setSelectedLesson(updatedLesson)
|
||||
if (course) {
|
||||
@@ -138,9 +137,7 @@ export default function CourseDetailPage() {
|
||||
setIsSaving(true)
|
||||
setSaveMessage(null)
|
||||
try {
|
||||
await updateLesson(selectedLesson.id, {
|
||||
description: 'approved_for_video',
|
||||
})
|
||||
await updateLesson(selectedLesson.id, { description: 'approved_for_video' })
|
||||
const updatedLesson = { ...selectedLesson }
|
||||
if (course) {
|
||||
const updatedLessons = course.lessons.map(l => l.id === updatedLesson.id ? updatedLesson : l)
|
||||
@@ -197,454 +194,61 @@ export default function CourseDetailPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const categoryInfo = COURSE_CATEGORY_INFO[course.category] || COURSE_CATEGORY_INFO['custom']
|
||||
const sortedLessons = [...(course.lessons || [])].sort((a, b) => a.order - b.order)
|
||||
const completedEnrollments = enrollments.filter(e => e.status === 'completed').length
|
||||
const overdueEnrollments = enrollments.filter(e => isEnrollmentOverdue(e)).length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/sdk/academy"
|
||||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg 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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
|
||||
{categoryInfo.label}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
course.status === 'published' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{course.status === 'published' ? 'Veroeffentlicht' : 'Entwurf'}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{course.title}</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">{course.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleDeleteCourse}
|
||||
className="px-3 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<CourseHeader course={course} onDelete={handleDeleteCourse} />
|
||||
<CourseStats
|
||||
course={course}
|
||||
sortedLessons={sortedLessons}
|
||||
enrollments={enrollments}
|
||||
completedEnrollments={completedEnrollments}
|
||||
/>
|
||||
<CourseTabs activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500">Lektionen</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{sortedLessons.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500">Dauer</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{course.durationMinutes} Min.</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500">Teilnehmer</div>
|
||||
<div className="text-2xl font-bold text-blue-600">{enrollments.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="text-sm text-gray-500">Abgeschlossen</div>
|
||||
<div className="text-2xl font-bold text-green-600">{completedEnrollments}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-1 -mb-px">
|
||||
{(['overview', 'lessons', 'enrollments', 'videos'] as TabId[]).map(tab => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab
|
||||
? 'border-purple-600 text-purple-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{{ overview: 'Uebersicht', lessons: 'Lektionen', enrollments: 'Einschreibungen', videos: 'Videos' }[tab]}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{activeTab === 'overview' && (
|
||||
<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-4">Kurs-Details</h3>
|
||||
<dl className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div><dt className="text-gray-500">Bestehensgrenze</dt><dd className="font-medium text-gray-900">{course.passingScore}%</dd></div>
|
||||
<div><dt className="text-gray-500">Pflicht fuer</dt><dd className="font-medium text-gray-900">{course.requiredForRoles?.join(', ') || 'Alle'}</dd></div>
|
||||
<div><dt className="text-gray-500">Erstellt am</dt><dd className="font-medium text-gray-900">{new Date(course.createdAt).toLocaleDateString('de-DE')}</dd></div>
|
||||
<div><dt className="text-gray-500">Aktualisiert am</dt><dd className="font-medium text-gray-900">{new Date(course.updatedAt).toLocaleDateString('de-DE')}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Lesson List Preview */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Lektionen ({sortedLessons.length})</h3>
|
||||
<div className="space-y-2">
|
||||
{sortedLessons.map((lesson, i) => (
|
||||
<div key={lesson.id} className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50">
|
||||
<div className="w-8 h-8 rounded-full bg-purple-100 text-purple-600 flex items-center justify-center text-sm font-medium">
|
||||
{i + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">{lesson.title}</div>
|
||||
<div className="text-xs text-gray-500">{lesson.durationMinutes} Min. | {lesson.type === 'video' ? 'Video' : lesson.type === 'quiz' ? 'Quiz' : 'Text'}</div>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
lesson.type === 'quiz' ? 'bg-yellow-100 text-yellow-700' :
|
||||
lesson.type === 'video' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{lesson.type === 'quiz' ? 'Quiz' : lesson.type === 'video' ? 'Video' : 'Text'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<OverviewTab course={course} sortedLessons={sortedLessons} />
|
||||
)}
|
||||
|
||||
{/* Lessons Tab - with content viewer and quiz player */}
|
||||
{activeTab === 'lessons' && (
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Lesson Navigation */}
|
||||
<div className="col-span-1 bg-white rounded-xl border border-gray-200 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3">Lektionen</h3>
|
||||
<div className="space-y-1">
|
||||
{sortedLessons.map((lesson, i) => (
|
||||
<button
|
||||
key={lesson.id}
|
||||
onClick={() => { setSelectedLesson(lesson); setQuizResult(null); setQuizAnswers({}) }}
|
||||
className={`w-full text-left p-3 rounded-lg text-sm transition-colors ${
|
||||
selectedLesson?.id === lesson.id
|
||||
? 'bg-purple-50 text-purple-700 border border-purple-200'
|
||||
: 'hover:bg-gray-50 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400 w-4">{i + 1}.</span>
|
||||
<span className="truncate">{lesson.title}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lesson Content */}
|
||||
<div className="col-span-2 bg-white rounded-xl border border-gray-200 p-6">
|
||||
{selectedLesson ? (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={e => setEditTitle(e.target.value)}
|
||||
className="text-xl font-semibold text-gray-900 border border-gray-300 rounded-lg px-3 py-1 flex-1 mr-3"
|
||||
/>
|
||||
) : (
|
||||
<h2 className="text-xl font-semibold text-gray-900">{selectedLesson.title}</h2>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-3 py-1 text-xs rounded-full ${
|
||||
selectedLesson.type === 'quiz' ? 'bg-yellow-100 text-yellow-700' :
|
||||
selectedLesson.type === 'video' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{selectedLesson.type === 'quiz' ? 'Quiz' : selectedLesson.type === 'video' ? 'Video' : 'Text'}
|
||||
</span>
|
||||
{selectedLesson.type !== 'quiz' && !isEditing && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleStartEdit}
|
||||
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApproveLesson}
|
||||
disabled={isSaving}
|
||||
className="px-3 py-1 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Freigeben
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isEditing && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveLesson}
|
||||
disabled={isSaving}
|
||||
className="px-3 py-1 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? 'Speichert...' : 'Speichern'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save/Approve Message */}
|
||||
{saveMessage && (
|
||||
<div className={`p-3 rounded-lg text-sm ${
|
||||
saveMessage.type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'
|
||||
}`}>
|
||||
{saveMessage.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video Player */}
|
||||
{selectedLesson.type === 'video' && selectedLesson.videoUrl && (
|
||||
<div className="aspect-video bg-gray-900 rounded-xl overflow-hidden">
|
||||
<video
|
||||
src={selectedLesson.videoUrl}
|
||||
controls
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text Content - Edit Mode */}
|
||||
{isEditing && (selectedLesson.type === 'text' || selectedLesson.type === 'video') && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-500">Inhalt (Markdown)</label>
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={e => setEditContent(e.target.value)}
|
||||
rows={20}
|
||||
className="w-full border border-gray-300 rounded-xl p-4 text-sm font-mono text-gray-800 leading-relaxed resize-y focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="Markdown-Inhalt der Lektion..."
|
||||
/>
|
||||
<p className="text-xs text-gray-400">Unterstuetzt: # Ueberschrift, ## Unterueberschrift, - Aufzaehlung, **fett**</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text Content - View Mode */}
|
||||
{!isEditing && (selectedLesson.type === 'text' || selectedLesson.type === 'video') && selectedLesson.contentMarkdown && (
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<div className="whitespace-pre-wrap text-gray-700 leading-relaxed">
|
||||
{selectedLesson.contentMarkdown.split('\n').map((line, i) => {
|
||||
if (line.startsWith('# ')) return <h1 key={i} className="text-2xl font-bold text-gray-900 mt-6 mb-3">{line.slice(2)}</h1>
|
||||
if (line.startsWith('## ')) return <h2 key={i} className="text-xl font-semibold text-gray-900 mt-5 mb-2">{line.slice(3)}</h2>
|
||||
if (line.startsWith('### ')) return <h3 key={i} className="text-lg font-medium text-gray-900 mt-4 mb-2">{line.slice(4)}</h3>
|
||||
if (line.startsWith('- **')) {
|
||||
const parts = line.slice(2).split('**')
|
||||
return <li key={i} className="ml-4 list-disc"><strong>{parts[1]}</strong>{parts[2] || ''}</li>
|
||||
}
|
||||
if (line.startsWith('- ')) return <li key={i} className="ml-4 list-disc">{line.slice(2)}</li>
|
||||
if (line.trim() === '') return <br key={i} />
|
||||
return <p key={i} className="mb-2">{line}</p>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quiz Player */}
|
||||
{selectedLesson.type === 'quiz' && selectedLesson.quizQuestions && (
|
||||
<div className="space-y-6">
|
||||
{selectedLesson.quizQuestions.map((q: QuizQuestion, qi: number) => (
|
||||
<div key={q.id} className="bg-gray-50 rounded-xl p-5">
|
||||
<h4 className="font-medium text-gray-900 mb-3">Frage {qi + 1}: {q.question}</h4>
|
||||
<div className="space-y-2">
|
||||
{q.options.map((option: string, oi: number) => {
|
||||
const isSelected = quizAnswers[q.id] === oi
|
||||
const showResult = quizResult && !quizResult.error
|
||||
const isCorrect = showResult && quizResult.results?.[qi]?.correct
|
||||
const wasSelected = showResult && isSelected
|
||||
|
||||
let bgClass = 'bg-white border-gray-200 hover:border-purple-300'
|
||||
if (isSelected && !showResult) bgClass = 'bg-purple-50 border-purple-500'
|
||||
if (showResult && oi === q.correctOptionIndex) bgClass = 'bg-green-50 border-green-500'
|
||||
if (showResult && wasSelected && !isCorrect) bgClass = 'bg-red-50 border-red-500'
|
||||
|
||||
return (
|
||||
<button
|
||||
key={oi}
|
||||
onClick={() => !quizResult && setQuizAnswers({ ...quizAnswers, [q.id]: oi })}
|
||||
disabled={!!quizResult}
|
||||
className={`w-full text-left p-3 rounded-lg border-2 transition-all ${bgClass}`}
|
||||
>
|
||||
<span className="text-sm text-gray-700">{String.fromCharCode(65 + oi)}) {option}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{quizResult && !quizResult.error && quizResult.results?.[qi] && (
|
||||
<div className={`mt-3 p-3 rounded-lg text-sm ${
|
||||
quizResult.results[qi].correct ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
||||
}`}>
|
||||
{quizResult.results[qi].correct ? 'Richtig!' : 'Falsch.'} {q.explanation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Quiz Submit / Result */}
|
||||
{!quizResult ? (
|
||||
<button
|
||||
onClick={handleSubmitQuiz}
|
||||
disabled={isSubmittingQuiz || Object.keys(quizAnswers).length < (selectedLesson.quizQuestions?.length || 0)}
|
||||
className="w-full py-3 bg-purple-600 text-white rounded-xl hover:bg-purple-700 transition-colors disabled:opacity-50 font-medium"
|
||||
>
|
||||
{isSubmittingQuiz ? 'Wird ausgewertet...' : 'Quiz auswerten'}
|
||||
</button>
|
||||
) : quizResult.error ? (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">{quizResult.error}</div>
|
||||
) : (
|
||||
<div className={`rounded-xl p-6 text-center ${quizResult.passed ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
|
||||
<div className={`text-3xl font-bold ${quizResult.passed ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{quizResult.score}%
|
||||
</div>
|
||||
<div className={`text-sm mt-1 ${quizResult.passed ? 'text-green-700' : 'text-red-700'}`}>
|
||||
{quizResult.passed ? 'Bestanden!' : 'Nicht bestanden'} — {quizResult.correctAnswers}/{quizResult.totalQuestions} richtig
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setQuizResult(null); setQuizAnswers({}) }}
|
||||
className="mt-4 px-4 py-2 text-sm bg-white rounded-lg border hover:bg-gray-50"
|
||||
>
|
||||
Quiz wiederholen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-10">Waehlen Sie eine Lektion aus.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<LessonsTab
|
||||
sortedLessons={sortedLessons}
|
||||
selectedLesson={selectedLesson}
|
||||
onSelectLesson={(lesson) => { setSelectedLesson(lesson); setQuizResult(null); setQuizAnswers({}) }}
|
||||
quizAnswers={quizAnswers}
|
||||
onQuizAnswer={setQuizAnswers}
|
||||
quizResult={quizResult}
|
||||
isSubmittingQuiz={isSubmittingQuiz}
|
||||
onSubmitQuiz={handleSubmitQuiz}
|
||||
onResetQuiz={() => { setQuizResult(null); setQuizAnswers({}) }}
|
||||
isEditing={isEditing}
|
||||
editTitle={editTitle}
|
||||
editContent={editContent}
|
||||
onEditTitle={setEditTitle}
|
||||
onEditContent={setEditContent}
|
||||
isSaving={isSaving}
|
||||
saveMessage={saveMessage}
|
||||
onStartEdit={handleStartEdit}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onSaveLesson={handleSaveLesson}
|
||||
onApproveLesson={handleApproveLesson}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Enrollments Tab */}
|
||||
{activeTab === 'enrollments' && (
|
||||
<div className="space-y-4">
|
||||
{overdueEnrollments > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700">
|
||||
{overdueEnrollments} ueberfaellige Einschreibung(en)
|
||||
</div>
|
||||
)}
|
||||
{enrollments.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<p className="text-gray-500">Noch keine Einschreibungen fuer diesen Kurs.</p>
|
||||
</div>
|
||||
) : (
|
||||
enrollments.map(enrollment => {
|
||||
const statusInfo = ENROLLMENT_STATUS_INFO[enrollment.status]
|
||||
const overdue = isEnrollmentOverdue(enrollment)
|
||||
const daysUntil = getDaysUntilDeadline(enrollment.deadline)
|
||||
return (
|
||||
<div key={enrollment.id} className={`bg-white rounded-xl border-2 p-5 ${overdue ? 'border-red-200' : 'border-gray-200'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${statusInfo?.bgColor} ${statusInfo?.color}`}>
|
||||
{statusInfo?.label}
|
||||
</span>
|
||||
{overdue && <span className="text-xs text-red-600">Ueberfaellig</span>}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900">{enrollment.userName}</div>
|
||||
<div className="text-sm text-gray-500">{enrollment.userEmail}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-gray-900">{enrollment.progress}%</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{enrollment.status === 'completed' ? 'Abgeschlossen' : `${daysUntil > 0 ? daysUntil + ' Tage verbleibend' : Math.abs(daysUntil) + ' Tage ueberfaellig'}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${enrollment.progress === 100 ? 'bg-green-500' : overdue ? 'bg-red-500' : 'bg-purple-500'}`}
|
||||
style={{ width: `${enrollment.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<EnrollmentsTab enrollments={enrollments} overdueEnrollments={overdueEnrollments} />
|
||||
)}
|
||||
|
||||
{/* Videos Tab */}
|
||||
{activeTab === 'videos' && (
|
||||
<div className="space-y-6">
|
||||
<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">Video-Generierung</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCheckVideoStatus}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Status pruefen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGenerateVideos}
|
||||
disabled={isGeneratingVideos}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{isGeneratingVideos ? 'Wird gestartet...' : 'Videos generieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-4 text-sm text-blue-700">
|
||||
Videos werden mit ElevenLabs (Stimme) und HeyGen (Avatar) generiert.
|
||||
Konfigurieren Sie die API-Keys in den Umgebungsvariablen.
|
||||
</div>
|
||||
|
||||
{videoStatus && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Gesamtstatus:</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
videoStatus.status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||
videoStatus.status === 'processing' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{videoStatus.status}
|
||||
</span>
|
||||
</div>
|
||||
{videoStatus.lessons?.map((ls: any) => (
|
||||
<div key={ls.lessonId} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-sm text-gray-700">Lektion {ls.lessonId.slice(-4)}</span>
|
||||
<span className={`text-xs ${ls.status === 'completed' ? 'text-green-600' : 'text-gray-500'}`}>
|
||||
{ls.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!videoStatus && (
|
||||
<p className="text-sm text-gray-500 text-center py-8">
|
||||
Klicken Sie auf "Videos generieren" um den Prozess zu starten.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<VideosTab
|
||||
videoStatus={videoStatus}
|
||||
isGeneratingVideos={isGeneratingVideos}
|
||||
onGenerateVideos={handleGenerateVideos}
|
||||
onCheckVideoStatus={handleCheckVideoStatus}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
139
admin-compliance/app/sdk/academy/_components/CertificatesTab.tsx
Normal file
139
admin-compliance/app/sdk/academy/_components/CertificatesTab.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { Certificate } from '@/lib/sdk/academy/types'
|
||||
|
||||
// =============================================================================
|
||||
// CERTIFICATE ROW
|
||||
// =============================================================================
|
||||
|
||||
function CertificateRow({ cert }: { cert: Certificate }) {
|
||||
const now = new Date()
|
||||
const validUntil = new Date(cert.validUntil)
|
||||
const daysLeft = Math.ceil((validUntil.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
const isExpired = daysLeft <= 0
|
||||
const isExpiringSoon = daysLeft > 0 && daysLeft <= 30
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{cert.userName}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{cert.courseName}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{new Date(cert.issuedAt).toLocaleDateString('de-DE')}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{validUntil.toLocaleDateString('de-DE')}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{isExpired ? (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">Abgelaufen</span>
|
||||
) : isExpiringSoon ? (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-orange-100 text-orange-700">Laeuft bald ab</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">Gueltig</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{cert.pdfUrl ? (
|
||||
<a
|
||||
href={cert.pdfUrl}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
PDF Download
|
||||
</a>
|
||||
) : (
|
||||
<span className="px-3 py-1 text-xs bg-gray-100 text-gray-400 rounded cursor-not-allowed">
|
||||
Nicht verfuegbar
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CERTIFICATES TAB
|
||||
// =============================================================================
|
||||
|
||||
export function CertificatesTab({
|
||||
certificates,
|
||||
certSearch,
|
||||
onSearchChange
|
||||
}: {
|
||||
certificates: Certificate[]
|
||||
certSearch: string
|
||||
onSearchChange: (s: string) => void
|
||||
}) {
|
||||
const now = new Date()
|
||||
const total = certificates.length
|
||||
const valid = certificates.filter(c => new Date(c.validUntil) > now).length
|
||||
const expired = certificates.filter(c => new Date(c.validUntil) <= now).length
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{total}</div>
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{valid}</div>
|
||||
<div className="text-sm text-gray-500">Gueltig</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-red-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-red-600">{expired}</div>
|
||||
<div className="text-sm text-gray-500">Abgelaufen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nach Mitarbeiter oder Kurs suchen..."
|
||||
value={certSearch}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{certificates.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-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Noch keine Zertifikate ausgestellt</h3>
|
||||
<p className="mt-2 text-gray-500">Zertifikate werden automatisch nach Kursabschluss generiert.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-700">Mitarbeiter</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-700">Kurs</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-700">Ausgestellt am</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-700">Gueltig bis</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-700">Status</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-700">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{certificates
|
||||
.filter(c =>
|
||||
!certSearch ||
|
||||
c.userName.toLowerCase().includes(certSearch.toLowerCase()) ||
|
||||
c.courseName.toLowerCase().includes(certSearch.toLowerCase())
|
||||
)
|
||||
.map(cert => <CertificateRow key={cert.id} cert={cert} />)
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
85
admin-compliance/app/sdk/academy/_components/CourseCard.tsx
Normal file
85
admin-compliance/app/sdk/academy/_components/CourseCard.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Course, COURSE_CATEGORY_INFO } from '@/lib/sdk/academy/types'
|
||||
|
||||
export function CourseCard({ course, enrollmentCount, onEdit }: { course: Course; enrollmentCount: number; onEdit?: (course: Course) => void }) {
|
||||
const categoryInfo = COURSE_CATEGORY_INFO[course.category] || COURSE_CATEGORY_INFO['custom']
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<Link href={`/sdk/academy/${course.id}`}>
|
||||
<div className="bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer border-gray-200 hover:border-purple-300">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
|
||||
{categoryInfo.label}
|
||||
</span>
|
||||
{course.status === 'published' && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">Veroeffentlicht</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">{course.title}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1 line-clamp-2">{course.description}</p>
|
||||
<div className="mt-3 flex items-center gap-4 text-sm text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" 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>
|
||||
{course.lessons.length} Lektionen
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" 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>
|
||||
{course.durationMinutes} Min.
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
{enrollmentCount} Teilnehmer
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Bestehensgrenze: {course.passingScore}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right ml-4 text-gray-500">
|
||||
<div className="text-sm font-medium">
|
||||
{course.requiredForRoles.includes('all') ? 'Pflicht fuer alle' : `${course.requiredForRoles.length} Rollen`}
|
||||
</div>
|
||||
<div className="text-xs mt-0.5">
|
||||
{new Date(course.updatedAt).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
Erstellt: {new Date(course.createdAt).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
|
||||
Details
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); onEdit(course) }}
|
||||
className="absolute top-3 right-3 p-1.5 bg-white rounded-lg shadow border border-gray-200 text-gray-400 hover:text-purple-600 hover:border-purple-300 opacity-0 group-hover:opacity-100 transition-all z-10"
|
||||
title="Kurs bearbeiten"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
129
admin-compliance/app/sdk/academy/_components/CourseEditModal.tsx
Normal file
129
admin-compliance/app/sdk/academy/_components/CourseEditModal.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Course, CourseCategory, COURSE_CATEGORY_INFO } from '@/lib/sdk/academy/types'
|
||||
import { updateCourse } from '@/lib/sdk/academy/api'
|
||||
|
||||
export function CourseEditModal({ course, onClose, onSaved }: {
|
||||
course: Course
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [title, setTitle] = useState(course.title)
|
||||
const [description, setDescription] = useState(course.description)
|
||||
const [category, setCategory] = useState<CourseCategory>(course.category)
|
||||
const [durationMinutes, setDurationMinutes] = useState(course.durationMinutes)
|
||||
const [passingScore, setPassingScore] = useState(course.passingScore ?? 70)
|
||||
const [status, setStatus] = useState<'draft' | 'published'>(course.status ?? 'draft')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await updateCourse(course.id, { title, description, category, durationMinutes, passingScore, status })
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Kurs bearbeiten</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(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 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value as CourseCategory)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
{Object.entries(COURSE_CATEGORY_INFO).map(([key, info]) => (
|
||||
<option key={key} value={key}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={e => setStatus(e.target.value as 'draft' | 'published')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="draft">Entwurf</option>
|
||||
<option value="published">Veroeffentlicht</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Dauer (Minuten)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={durationMinutes}
|
||||
onChange={e => setDurationMinutes(Number(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 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Bestehensgrenze (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={passingScore}
|
||||
onChange={e => setPassingScore(Number(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 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !title}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Aenderungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
133
admin-compliance/app/sdk/academy/_components/EnrollmentCard.tsx
Normal file
133
admin-compliance/app/sdk/academy/_components/EnrollmentCard.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
Enrollment,
|
||||
ENROLLMENT_STATUS_INFO,
|
||||
isEnrollmentOverdue,
|
||||
getDaysUntilDeadline
|
||||
} from '@/lib/sdk/academy/types'
|
||||
|
||||
export function EnrollmentCard({ enrollment, courseName, onEdit, onComplete, onDelete }: {
|
||||
enrollment: Enrollment
|
||||
courseName: string
|
||||
onEdit?: (enrollment: Enrollment) => void
|
||||
onComplete?: (id: string) => void
|
||||
onDelete?: (id: string) => void
|
||||
}) {
|
||||
const statusInfo = ENROLLMENT_STATUS_INFO[enrollment.status]
|
||||
const overdue = isEnrollmentOverdue(enrollment)
|
||||
const daysUntil = getDaysUntilDeadline(enrollment.deadline)
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
bg-white rounded-xl border-2 p-6
|
||||
${overdue ? 'border-red-300' :
|
||||
enrollment.status === 'completed' ? 'border-green-200' :
|
||||
enrollment.status === 'in_progress' ? 'border-yellow-200' :
|
||||
'border-gray-200'
|
||||
}
|
||||
`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Status Badge */}
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusInfo.bgColor} ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
{overdue && (
|
||||
<span className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded-full flex items-center gap-1">
|
||||
<svg className="w-3 h-3" 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>
|
||||
Ueberfaellig
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{enrollment.userName}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">{enrollment.userEmail}</p>
|
||||
<p className="text-sm text-gray-600 mt-1 font-medium">{courseName}</p>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-gray-500">Fortschritt</span>
|
||||
<span className="font-medium text-gray-700">{enrollment.progress}%</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
enrollment.progress === 100 ? 'bg-green-500' :
|
||||
overdue ? 'bg-red-500' :
|
||||
'bg-purple-500'
|
||||
}`}
|
||||
style={{ width: `${enrollment.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Deadline */}
|
||||
<div className={`text-right ml-4 ${
|
||||
overdue ? 'text-red-600' :
|
||||
daysUntil <= 7 ? 'text-orange-600' :
|
||||
'text-gray-500'
|
||||
}`}>
|
||||
<div className="text-sm font-medium">
|
||||
{enrollment.status === 'completed'
|
||||
? 'Abgeschlossen'
|
||||
: overdue
|
||||
? `${Math.abs(daysUntil)} Tage ueberfaellig`
|
||||
: `${daysUntil} Tage verbleibend`
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs mt-0.5">
|
||||
Frist: {new Date(enrollment.deadline).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
Gestartet: {new Date(enrollment.startedAt).toLocaleDateString('de-DE')}
|
||||
{enrollment.completedAt && (
|
||||
<span className="ml-3 text-green-600">
|
||||
Abgeschlossen: {new Date(enrollment.completedAt).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{enrollment.status === 'in_progress' && onComplete && (
|
||||
<button
|
||||
onClick={() => onComplete(enrollment.id)}
|
||||
className="px-3 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Abschliessen
|
||||
</button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={() => onEdit(enrollment)}
|
||||
className="px-3 py-1 text-xs bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => onDelete(enrollment.id)}
|
||||
className="px-3 py-1 text-xs bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Enrollment } from '@/lib/sdk/academy/types'
|
||||
import { updateEnrollment } from '@/lib/sdk/academy/api'
|
||||
|
||||
export function EnrollmentEditModal({ enrollment, onClose, onSaved }: {
|
||||
enrollment: Enrollment
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [deadline, setDeadline] = useState(enrollment.deadline ? enrollment.deadline.split('T')[0] : '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await updateEnrollment(enrollment.id, { deadline: new Date(deadline).toISOString() })
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Einschreibung bearbeiten</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Teilnehmer: <span className="font-medium text-gray-900">{enrollment.userName}</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Deadline</label>
|
||||
<input
|
||||
type="date"
|
||||
value={deadline}
|
||||
onChange={e => setDeadline(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 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !deadline}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
224
admin-compliance/app/sdk/academy/_components/PageSections.tsx
Normal file
224
admin-compliance/app/sdk/academy/_components/PageSections.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
// =============================================================================
|
||||
// HEADER ACTIONS
|
||||
// =============================================================================
|
||||
|
||||
export function HeaderActions({
|
||||
isGenerating,
|
||||
onGenerateAll
|
||||
}: {
|
||||
isGenerating: boolean
|
||||
onGenerateAll: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onGenerateAll}
|
||||
disabled={isGenerating}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<svg className="animate-spin w-5 h-5" 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>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
{isGenerating ? 'Generiere...' : 'Alle Kurse generieren'}
|
||||
</button>
|
||||
<Link
|
||||
href="/sdk/academy/new"
|
||||
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>
|
||||
Kurs erstellen
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GENERATION RESULT BAR
|
||||
// =============================================================================
|
||||
|
||||
export function GenerationResultBar({
|
||||
result
|
||||
}: {
|
||||
result: { generated: number; skipped: number; errors: string[] }
|
||||
}) {
|
||||
return (
|
||||
<div className={`p-4 rounded-lg border ${result.errors.length > 0 ? 'bg-yellow-50 border-yellow-200' : 'bg-green-50 border-green-200'}`}>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-green-700 font-medium">{result.generated} Kurse generiert</span>
|
||||
<span className="text-gray-500">{result.skipped} uebersprungen</span>
|
||||
{result.errors.length > 0 && (
|
||||
<span className="text-red-600">{result.errors.length} Fehler</span>
|
||||
)}
|
||||
</div>
|
||||
{result.errors.length > 0 && (
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
{result.errors.map((err, i) => <div key={i}>{err}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LOADING SPINNER
|
||||
// =============================================================================
|
||||
|
||||
export function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<svg className="animate-spin w-8 h-8 text-purple-600" 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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// OVERDUE ALERT
|
||||
// =============================================================================
|
||||
|
||||
export function OverdueAlert({ count, onShow }: { count: number; onShow: () => void }) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-red-800">
|
||||
Achtung: {count} ueberfaellige Schulung(en)
|
||||
</h4>
|
||||
<p className="text-sm text-red-600">
|
||||
Mitarbeiter haben Pflichtschulungen nicht fristgerecht abgeschlossen. Handeln Sie umgehend.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onShow}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
Anzeigen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INFO BOX
|
||||
// =============================================================================
|
||||
|
||||
export function InfoBox() {
|
||||
return (
|
||||
<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">Schulungspflicht nach Art. 39 DSGVO</h4>
|
||||
<p className="text-sm text-blue-600 mt-1">
|
||||
Gemaess Art. 39 Abs. 1 lit. b DSGVO gehoert die Sensibilisierung und Schulung
|
||||
der an den Verarbeitungsvorgaengen beteiligten Mitarbeiter zu den Aufgaben des
|
||||
Datenschutzbeauftragten. Nachweisbare Compliance-Schulungen sind Pflicht und
|
||||
sollten mindestens jaehrlich aufgefrischt werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EMPTY STATES
|
||||
// =============================================================================
|
||||
|
||||
export function EmptyCourses({
|
||||
selectedCategory,
|
||||
onClearFilters
|
||||
}: {
|
||||
selectedCategory: string
|
||||
onClearFilters: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-gray-400" 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>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Kurse gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">
|
||||
{selectedCategory !== 'all'
|
||||
? 'Passen Sie die Filter an oder'
|
||||
: 'Es sind noch keine Kurse vorhanden.'
|
||||
}
|
||||
</p>
|
||||
{selectedCategory !== 'all' ? (
|
||||
<button
|
||||
onClick={onClearFilters}
|
||||
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||||
>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/sdk/academy/new"
|
||||
className="mt-4 inline-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>
|
||||
Ersten Kurs erstellen
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyEnrollments({
|
||||
selectedStatus,
|
||||
onClearFilters
|
||||
}: {
|
||||
selectedStatus: string
|
||||
onClearFilters: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Einschreibungen gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">
|
||||
{selectedStatus !== 'all'
|
||||
? 'Passen Sie die Filter an.'
|
||||
: 'Es sind noch keine Mitarbeiter in Kurse eingeschrieben.'
|
||||
}
|
||||
</p>
|
||||
{selectedStatus !== 'all' && (
|
||||
<button
|
||||
onClick={onClearFilters}
|
||||
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||||
>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
admin-compliance/app/sdk/academy/_components/SettingsTab.tsx
Normal file
110
admin-compliance/app/sdk/academy/_components/SettingsTab.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
export function SettingsTab({ onSaved, saved }: { onSaved: () => void; saved: boolean }) {
|
||||
const SETTINGS_KEY = 'bp_academy_settings'
|
||||
|
||||
const loadSettings = () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(SETTINGS_KEY)
|
||||
if (raw) return JSON.parse(raw)
|
||||
} catch { /* ignore */ }
|
||||
return {}
|
||||
}
|
||||
|
||||
const defaults = { emailReminders: true, reminderDays: 7, defaultPassingScore: 70, defaultValidityDays: 365 }
|
||||
const saved_settings = loadSettings()
|
||||
const [emailReminders, setEmailReminders] = useState<boolean>(saved_settings.emailReminders ?? defaults.emailReminders)
|
||||
const [reminderDays, setReminderDays] = useState<number>(saved_settings.reminderDays ?? defaults.reminderDays)
|
||||
const [defaultPassingScore, setDefaultPassingScore] = useState<number>(saved_settings.defaultPassingScore ?? defaults.defaultPassingScore)
|
||||
const [defaultValidityDays, setDefaultValidityDays] = useState<number>(saved_settings.defaultValidityDays ?? defaults.defaultValidityDays)
|
||||
|
||||
const handleSave = () => {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ emailReminders, reminderDays, defaultPassingScore, defaultValidityDays }))
|
||||
onSaved()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
{/* Notifications */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-4">Benachrichtigungen</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">E-Mail-Erinnerung bei ueberfaelligen Kursen</div>
|
||||
<div className="text-xs text-gray-500">Mitarbeiter per E-Mail an ausstehende Schulungen erinnern</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEmailReminders(!emailReminders)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${emailReminders ? 'bg-purple-600' : 'bg-gray-200'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${emailReminders ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Tage vor Ablauf erinnern</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={90}
|
||||
value={reminderDays}
|
||||
onChange={e => setReminderDays(Number(e.target.value))}
|
||||
className="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Course Defaults */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-4">Standard-Einstellungen fuer neue Kurse</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Standard-Bestehensgrenze (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={defaultPassingScore}
|
||||
onChange={e => setDefaultPassingScore(Number(e.target.value))}
|
||||
className="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Gueltigkeitsdauer (Tage)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={defaultValidityDays}
|
||||
onChange={e => setDefaultValidityDays(Number(e.target.value))}
|
||||
className="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<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>
|
||||
<p className="text-sm text-blue-700">
|
||||
Zertifikate werden automatisch nach erfolgreichem Kursabschluss generiert. Die Gueltigkeitsdauer gilt ab dem Ausstellungsdatum.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className={`px-6 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
saved ? 'bg-green-600 text-white' : 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
{saved ? 'Gespeichert ✓' : 'Einstellungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
168
admin-compliance/app/sdk/academy/_components/shared.tsx
Normal file
168
admin-compliance/app/sdk/academy/_components/shared.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
CourseCategory,
|
||||
EnrollmentStatus,
|
||||
COURSE_CATEGORY_INFO,
|
||||
ENROLLMENT_STATUS_INFO
|
||||
} from '@/lib/sdk/academy/types'
|
||||
import { Tab, TabId } from '../_types'
|
||||
|
||||
// =============================================================================
|
||||
// TAB NAVIGATION
|
||||
// =============================================================================
|
||||
|
||||
export function TabNavigation({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange
|
||||
}: {
|
||||
tabs: Tab[]
|
||||
activeTab: TabId
|
||||
onTabChange: (tab: TabId) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-1 -mb-px" aria-label="Tabs">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`
|
||||
px-4 py-3 text-sm font-medium border-b-2 transition-colors
|
||||
${activeTab === tab.id
|
||||
? 'border-purple-600 text-purple-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{tab.label}
|
||||
{tab.count !== undefined && tab.count > 0 && (
|
||||
<span className={`
|
||||
px-2 py-0.5 text-xs rounded-full
|
||||
${tab.countColor || 'bg-gray-100 text-gray-600'}
|
||||
`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STAT CARD
|
||||
// =============================================================================
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
color = 'gray',
|
||||
icon,
|
||||
trend
|
||||
}: {
|
||||
label: string
|
||||
value: number | string
|
||||
color?: 'gray' | 'blue' | 'yellow' | 'red' | 'green' | 'purple'
|
||||
icon?: React.ReactNode
|
||||
trend?: { value: number; label: string }
|
||||
}) {
|
||||
const colorClasses = {
|
||||
gray: 'border-gray-200 text-gray-900',
|
||||
blue: 'border-blue-200 text-blue-600',
|
||||
yellow: 'border-yellow-200 text-yellow-600',
|
||||
red: 'border-red-200 text-red-600',
|
||||
green: 'border-green-200 text-green-600',
|
||||
purple: 'border-purple-200 text-purple-600'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border ${colorClasses[color]} p-6`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className={`text-sm ${color === 'gray' ? 'text-gray-500' : `text-${color}-600`}`}>
|
||||
{label}
|
||||
</div>
|
||||
<div className={`text-3xl font-bold mt-1 ${colorClasses[color].split(' ')[1]}`}>
|
||||
{value}
|
||||
</div>
|
||||
{trend && (
|
||||
<div className={`text-xs mt-1 ${trend.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{trend.value >= 0 ? '+' : ''}{trend.value} {trend.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{icon && (
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center bg-${color}-50`}>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FILTER BAR
|
||||
// =============================================================================
|
||||
|
||||
export function FilterBar({
|
||||
selectedCategory,
|
||||
selectedStatus,
|
||||
onCategoryChange,
|
||||
onStatusChange,
|
||||
onClear
|
||||
}: {
|
||||
selectedCategory: CourseCategory | 'all'
|
||||
selectedStatus: EnrollmentStatus | 'all'
|
||||
onCategoryChange: (category: CourseCategory | 'all') => void
|
||||
onStatusChange: (status: EnrollmentStatus | 'all') => void
|
||||
onClear: () => void
|
||||
}) {
|
||||
const hasFilters = selectedCategory !== 'all' || selectedStatus !== 'all'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
|
||||
{/* Category Filter */}
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => onCategoryChange(e.target.value as CourseCategory | 'all')}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="all">Alle Kategorien</option>
|
||||
{Object.entries(COURSE_CATEGORY_INFO).map(([cat, info]) => (
|
||||
<option key={cat} value={cat}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Enrollment Status Filter */}
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => onStatusChange(e.target.value as EnrollmentStatus | 'all')}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="all">Alle Status</option>
|
||||
{Object.entries(ENROLLMENT_STATUS_INFO).map(([status, info]) => (
|
||||
<option key={status} value={status}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
admin-compliance/app/sdk/academy/_types.ts
Normal file
12
admin-compliance/app/sdk/academy/_types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export type TabId = 'overview' | 'courses' | 'enrollments' | 'certificates' | 'settings'
|
||||
|
||||
export interface Tab {
|
||||
id: TabId
|
||||
label: string
|
||||
count?: number
|
||||
countColor?: string
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface Props {
|
||||
currentStep: number
|
||||
isSubmitting: boolean
|
||||
isEditMode: boolean
|
||||
titleEmpty: boolean
|
||||
onBack: () => void
|
||||
onNext: () => void
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
export function NavigationButtons({ currentStep, isSubmitting, isEditMode, titleEmpty, onBack, onNext, onSubmit }: Props) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={onBack}
|
||||
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={onNext}
|
||||
disabled={currentStep === 1 && titleEmpty}
|
||||
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={onSubmit}
|
||||
disabled={isSubmitting || titleEmpty}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { AssessmentResultCard } from '@/components/sdk/use-case-assessment/AssessmentResultCard'
|
||||
|
||||
interface Props {
|
||||
result: unknown
|
||||
onGoToAssessment: (id: string) => void
|
||||
onGoToOverview: () => void
|
||||
}
|
||||
|
||||
export function ResultView({ result, onGoToAssessment, onGoToOverview }: Props) {
|
||||
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={() => onGoToAssessment(r.assessment!.id)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
Zum Assessment
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onGoToOverview}
|
||||
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 unknown as Parameters<typeof AssessmentResultCard>[0]['result']} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { StepProps } from '../_types'
|
||||
import { AI_USE_CATEGORIES } from '../_data'
|
||||
|
||||
interface Props extends StepProps {
|
||||
profileIndustry: string | string[] | undefined
|
||||
}
|
||||
|
||||
export function Step1Basics({ form, updateForm, profileIndustry }: Props) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Grundlegende Informationen</h2>
|
||||
|
||||
{/* Branche aus Profil (nur Anzeige) */}
|
||||
{profileIndustry && (Array.isArray(profileIndustry) ? profileIndustry.length > 0 : true) && (
|
||||
<div className="bg-gray-50 rounded-lg border border-gray-200 px-4 py-3">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">Branche (aus Unternehmensprofil)</span>
|
||||
<p className="text-sm text-gray-900 mt-0.5">
|
||||
{Array.isArray(profileIndustry) ? profileIndustry.join(', ') : profileIndustry}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel des Anwendungsfalls</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>
|
||||
|
||||
{/* KI-Anwendungskategorie als Kacheln */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
In welchem Bereich kommt KI zum Einsatz?
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 mb-3">Waehlen Sie die passende Kategorie fuer Ihren Anwendungsfall.</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{AI_USE_CATEGORIES.map(cat => (
|
||||
<button
|
||||
key={cat.value}
|
||||
type="button"
|
||||
onClick={() => updateForm({ category: cat.value })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
form.category === cat.value
|
||||
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{cat.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{cat.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-tight">{cat.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { StepProps } from '../_types'
|
||||
import { DATA_CATEGORY_GROUPS } from '../_data-categories'
|
||||
import { toggleInArray } from '../_data'
|
||||
|
||||
export function Step2DataCategories({ form, updateForm }: StepProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Welche Daten werden verarbeitet?</h2>
|
||||
<p className="text-sm text-gray-500">Waehlen Sie alle Datenkategorien, die in diesem Use Case verarbeitet werden.</p>
|
||||
|
||||
{DATA_CATEGORY_GROUPS.map(group => (
|
||||
<div key={group.group}>
|
||||
<h3 className={`text-sm font-semibold mb-2 ${group.art9 ? 'text-orange-700' : 'text-gray-700'}`}>
|
||||
{group.art9 && '⚠️ '}{group.group}
|
||||
</h3>
|
||||
{group.art9 && (
|
||||
<p className="text-xs text-orange-600 mb-2">Besonders schutzwuerdig — erhoehte Anforderungen an Rechtsgrundlage und TOM</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 mb-4">
|
||||
{group.items.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => updateForm({ data_categories: toggleInArray(form.data_categories, item.value) })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
form.data_categories.includes(item.value)
|
||||
? group.art9
|
||||
? 'border-orange-500 bg-orange-50 ring-1 ring-orange-300'
|
||||
: 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 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">
|
||||
Falls Ihre Datenkategorie oben nicht aufgefuehrt ist, koennen Sie sie hier ergaenzen.
|
||||
</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={() => updateForm({ custom_data_types: form.custom_data_types.filter((_, i) => i !== idx) })}
|
||||
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>
|
||||
|
||||
{form.data_categories.length > 0 && (
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg px-4 py-3 text-sm text-purple-800">
|
||||
<span className="font-medium">{form.data_categories.length}</span> Datenkategorie{form.data_categories.length !== 1 ? 'n' : ''} ausgewaehlt
|
||||
{form.data_categories.some(c => DATA_CATEGORY_GROUPS.find(g => g.art9)?.items.some(i => i.value === c)) && (
|
||||
<span className="ml-2 text-orange-700 font-medium">— inkl. besonderer Kategorien (Art. 9)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { StepProps } from '../_types'
|
||||
import { PURPOSE_TILES } from '../_tiles'
|
||||
import { toggleInArray } from '../_data'
|
||||
|
||||
export function Step3Purposes({ form, updateForm }: StepProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Zweck der Verarbeitung</h2>
|
||||
<p className="text-sm text-gray-500">Waehlen Sie alle zutreffenden Verarbeitungszwecke. Die passende Rechtsgrundlage wird vom SDK automatisch ermittelt.</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{PURPOSE_TILES.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => updateForm({ purposes: toggleInArray(form.purposes, item.value) })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
form.purposes.includes(item.value)
|
||||
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{form.purposes.includes('profiling') && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
|
||||
<div className="font-medium mb-1">Hinweis: Profiling</div>
|
||||
<p>Profiling unterliegt besonderen Anforderungen nach Art. 22 DSGVO. Betroffene haben das Recht auf Information und Widerspruch.</p>
|
||||
</div>
|
||||
)}
|
||||
{form.purposes.includes('automated_decision') && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-800">
|
||||
<div className="font-medium mb-1">Achtung: Automatisierte Entscheidung</div>
|
||||
<p>Art. 22 DSGVO: Vollautomatisierte Entscheidungen mit rechtlicher Wirkung erfordern besondere Schutzmassnahmen, Informationspflichten und das Recht auf menschliche Ueberpruefung.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { StepProps } from '../_types'
|
||||
import { AUTOMATION_TILES } from '../_tiles'
|
||||
|
||||
export function Step4Automation({ form, updateForm }: StepProps) {
|
||||
return (
|
||||
<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>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{AUTOMATION_TILES.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => updateForm({ automation: item.value })}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
form.automation === item.value
|
||||
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="text-2xl">{item.icon}</span>
|
||||
<span className="text-sm font-semibold text-gray-900">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 ml-11">{item.desc}</p>
|
||||
<p className="text-xs text-gray-400 ml-11 mt-1">Beispiele: {item.examples}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { StepProps } from '../_types'
|
||||
import { HOSTING_PROVIDER_TILES, HOSTING_REGION_TILES, MODEL_USAGE_TILES } from '../_tiles'
|
||||
import { toggleInArray } from '../_data'
|
||||
|
||||
export function Step5Hosting({ form, updateForm }: StepProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Technische Details</h2>
|
||||
|
||||
{/* Hosting Provider */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">Hosting-Anbieter</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{HOSTING_PROVIDER_TILES.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => updateForm({ hosting_provider: item.value })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
form.hosting_provider === item.value
|
||||
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hosting Region */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">Hosting-Region</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{HOSTING_REGION_TILES.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => updateForm({ hosting_region: item.value })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
form.hosting_region === item.value
|
||||
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Usage */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-1">Wie wird das KI-Modell genutzt?</h3>
|
||||
<p className="text-sm text-gray-500 mb-3">Waehlen Sie alle zutreffenden Optionen.</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{MODEL_USAGE_TILES.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => updateForm({ model_usage: toggleInArray(form.model_usage, item.value) })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
form.model_usage.includes(item.value)
|
||||
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</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. Beispiel: Spam-Filter.</div>
|
||||
<div><span className="font-semibold">DL (Deep Learning)</span> — ML mit neuronalen Netzen. Beispiel: Bilderkennung, Spracherkennung.</div>
|
||||
<div><span className="font-semibold">NLP (Natural Language Processing)</span> — KI versteht Sprache. Beispiel: ChatGPT, DeepL.</div>
|
||||
<div><span className="font-semibold">LLM (Large Language Model)</span> — Grosses Sprachmodell. Beispiel: GPT-4, Claude, Llama.</div>
|
||||
<div><span className="font-semibold">RAG</span> — LLM erhaelt Kontext aus eigener Datenbank. Vorteil: Aktuelle, firmenspezifische Antworten.</div>
|
||||
<div><span className="font-semibold">Fine-Tuning</span> — Bestehendes Modell mit eigenen Daten weitertrainieren. Achtung: Daten werden Teil des Modells.</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { StepProps } from '../_types'
|
||||
import { TRANSFER_TARGET_TILES, TRANSFER_MECHANISM_TILES } from '../_tiles'
|
||||
import { toggleInArray } from '../_data'
|
||||
|
||||
export function Step6Transfer({ form, updateForm }: StepProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Internationaler Datentransfer</h2>
|
||||
<p className="text-sm text-gray-500">Wohin werden die Daten uebermittelt? Waehlen Sie alle zutreffenden Ziellaender/-regionen.</p>
|
||||
|
||||
{/* Transfer Targets */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">Datentransfer-Ziele</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{TRANSFER_TARGET_TILES.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => updateForm({ transfer_targets: toggleInArray(form.transfer_targets, item.value) })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
form.transfer_targets.includes(item.value)
|
||||
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transfer Mechanism — only if not "no_transfer" only */}
|
||||
{form.transfer_targets.length > 0 && !form.transfer_targets.every(t => t === 'no_transfer') && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">Transfer-Mechanismus</h3>
|
||||
<p className="text-sm text-gray-500 mb-3">Welche Schutzgarantie nutzen Sie fuer den Drittlandtransfer?</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{TRANSFER_MECHANISM_TILES.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => updateForm({ transfer_mechanism: item.value })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
form.transfer_mechanism === item.value
|
||||
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Specific countries text input */}
|
||||
{form.transfer_targets.some(t => !['no_transfer'].includes(t)) && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Konkrete Ziellaender (optional)</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, Schweiz, Japan"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Kommagetrennte Laendernamen oder -kuerzel</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { StepProps } from '../_types'
|
||||
import { RETENTION_TILES } from '../_tiles'
|
||||
|
||||
export function Step7Retention({ form, updateForm }: StepProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Datenhaltung & Aufbewahrung</h2>
|
||||
<p className="text-sm text-gray-500">Wie lange sollen die Daten gespeichert werden?</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{RETENTION_TILES.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => updateForm({ retention_period: item.value })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
form.retention_period === item.value
|
||||
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Zweck der Aufbewahrung (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={form.retention_purpose}
|
||||
onChange={e => updateForm({ retention_purpose: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="z.B. Vertragliche Pflichten, gesetzliche Aufbewahrungsfristen..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.retention_period === 'indefinite' && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
|
||||
<div className="font-medium mb-1">Hinweis: Unbefristete Speicherung</div>
|
||||
<p>Die DSGVO fordert Datenminimierung und Speicherbegrenzung (Art. 5 Abs. 1e). Unbefristete Speicherung muss besonders gut begruendet sein.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { StepProps } from '../_types'
|
||||
import { CONTRACT_TILES } from '../_tiles'
|
||||
import { toggleInArray } from '../_data'
|
||||
|
||||
export function Step8Contracts({ form, updateForm }: StepProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Vertraege & Compliance-Dokumentation</h2>
|
||||
<p className="text-sm text-gray-500">Welche Compliance-Dokumente liegen bereits vor? (Mehrfachauswahl moeglich)</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{CONTRACT_TILES.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => updateForm({ contracts: toggleInArray(form.contracts, item.value) })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
form.contracts.includes(item.value)
|
||||
? item.value === 'none'
|
||||
? 'border-amber-500 bg-amber-50 ring-1 ring-amber-300'
|
||||
: 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Subprozessoren (optional)</label>
|
||||
<textarea
|
||||
value={form.subprocessors}
|
||||
onChange={e => updateForm({ subprocessors: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="z.B. OpenAI (USA, SCC), Hetzner Cloud (DE)..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { WIZARD_STEPS } from '../_data'
|
||||
|
||||
interface Props {
|
||||
currentStep: number
|
||||
onStepClick: (id: number) => void
|
||||
}
|
||||
|
||||
export function StepIndicator({ currentStep, onStepClick }: Props) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{WIZARD_STEPS.map((step, idx) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<button
|
||||
onClick={() => onStepClick(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>
|
||||
)
|
||||
}
|
||||
112
admin-compliance/app/sdk/advisory-board/_data-categories.ts
Normal file
112
admin-compliance/app/sdk/advisory-board/_data-categories.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
// =============================================================================
|
||||
// DATA CATEGORIES (Step 2) — grouped tile selection
|
||||
// =============================================================================
|
||||
|
||||
export const DATA_CATEGORY_GROUPS = [
|
||||
{
|
||||
group: 'Stamm- & Kontaktdaten',
|
||||
items: [
|
||||
{ value: 'basic_identity', label: 'Name & Identitaet', icon: '👤', desc: 'Vor-/Nachname, Geburtsdatum, Geschlecht' },
|
||||
{ value: 'contact_data', label: 'Kontaktdaten', icon: '📧', desc: 'E-Mail, Telefon, Fax' },
|
||||
{ value: 'address_data', label: 'Adressdaten', icon: '🏠', desc: 'Wohn-/Meldeadresse, PLZ, Lieferadresse' },
|
||||
{ value: 'government_ids', label: 'Ausweisdaten', icon: '🪪', desc: 'Personalausweis-Nr., Reisepass, Fuehrerschein' },
|
||||
{ value: 'customer_ids', label: 'Kundennummern', icon: '🏷️', desc: 'Kunden-ID, Vertrags-Nr., Mitgliedsnummer' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Besondere Kategorien (Art. 9 DSGVO)',
|
||||
art9: true,
|
||||
items: [
|
||||
{ value: 'health_data', label: 'Gesundheitsdaten', icon: '🏥', desc: 'Diagnosen, Medikation, AU, Pflegegrad' },
|
||||
{ value: 'biometric_data', label: 'Biometrische Daten', icon: '🔐', desc: 'Fingerabdruck, Gesichtserkennung, Iris-Scan' },
|
||||
{ value: 'genetic_data', label: 'Genetische Daten', icon: '🧬', desc: 'DNA-Profil, Genomsequenzen, Erbkrankheitstests' },
|
||||
{ value: 'racial_ethnic', label: 'Ethnische Herkunft', icon: '🌍', desc: 'Rassische/ethnische Zugehoerigkeit' },
|
||||
{ value: 'political_opinions', label: 'Politische Meinungen', icon: '🗳️', desc: 'Politische Ueberzeugungen, Parteizugehoerigkeit' },
|
||||
{ value: 'religious_beliefs', label: 'Religion', icon: '🕊️', desc: 'Religionszugehoerigkeit, Weltanschauung' },
|
||||
{ value: 'trade_union', label: 'Gewerkschaft', icon: '🤝', desc: 'Gewerkschaftsmitgliedschaft' },
|
||||
{ value: 'sexual_orientation', label: 'Sexuelle Orientierung', icon: '🏳️🌈', desc: 'Sexualleben und Orientierung' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Finanz- & Steuerdaten',
|
||||
items: [
|
||||
{ value: 'bank_account', label: 'Bankverbindung', icon: '🏦', desc: 'IBAN, BIC, Kontonummer' },
|
||||
{ value: 'payment_card', label: 'Zahlungskarten', icon: '💳', desc: 'Kreditkarten-Nr., CVV (PCI-DSS)' },
|
||||
{ value: 'transaction_data', label: 'Transaktionsdaten', icon: '🧾', desc: 'Zahlungshistorie, Ueberweisungen, Kaufhistorie' },
|
||||
{ value: 'credit_score', label: 'Bonitaet / Schufa', icon: '📈', desc: 'Kreditwuerdigkeit, Schuldenhistorie' },
|
||||
{ value: 'income_salary', label: 'Einkommen & Gehalt', icon: '💰', desc: 'Bruttogehalt, Nettolohn, Boni' },
|
||||
{ value: 'tax_ids', label: 'Steuer-IDs', icon: '📋', desc: 'Steuer-ID, Steuernummer, USt-IdNr.' },
|
||||
{ value: 'insurance_data', label: 'Versicherungsdaten', icon: '☂️', desc: 'Versicherungsnummern, Policen, Schadenmeldungen' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Fahrzeug- & Mobilitaetsdaten',
|
||||
items: [
|
||||
{ value: 'vehicle_ids', label: 'Fahrzeug-IDs (VIN)', icon: '🚗', desc: 'Fahrgestellnummer (VIN/FIN), Fahrzeugschein' },
|
||||
{ value: 'license_plates', label: 'Kennzeichen', icon: '🔢', desc: 'Amtliches Kennzeichen, Wunschkennzeichen' },
|
||||
{ value: 'gps_tracking', label: 'GPS & Routen', icon: '📍', desc: 'Echtzeitposition, Fahrtenprotokolle' },
|
||||
{ value: 'telematics', label: 'Telematikdaten', icon: '📡', desc: 'Fahrverhalten, Geschwindigkeit, Motordiagnose' },
|
||||
{ value: 'fleet_data', label: 'Fuhrpark / Logistik', icon: '🚛', desc: 'Einsatzzeiten, Kilometerstand, Fahrerzuweisung' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Technische Identifikatoren',
|
||||
items: [
|
||||
{ value: 'ip_address', label: 'IP-Adresse', icon: '🌐', desc: 'IPv4/IPv6 (EuGH: personenbezogen)' },
|
||||
{ value: 'device_ids', label: 'Geraete-IDs', icon: '📱', desc: 'IMEI, UUID, Advertising-ID, Seriennummer' },
|
||||
{ value: 'cookies_tracking', label: 'Cookies & Tracking', icon: '🍪', desc: 'Session-/Persistent Cookies, Pixel-Tags' },
|
||||
{ value: 'browser_fingerprint', label: 'Browser-Fingerprint', icon: '🔎', desc: 'Browser-Typ, OS, Plugins, Canvas-Fingerprint' },
|
||||
{ value: 'mac_address', label: 'MAC-Adresse', icon: '📶', desc: 'Netzwerkadapter-Kennung, WLAN-Praesenz' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Verhaltens- & Nutzungsdaten',
|
||||
items: [
|
||||
{ value: 'clickstream', label: 'Klick- & Nutzungspfade', icon: '🖱️', desc: 'Klickpfade, Scrolltiefe, Verweildauer, Heatmaps' },
|
||||
{ value: 'purchase_history', label: 'Kaufverhalten', icon: '🛒', desc: 'Bestellhistorie, Warenkorb, Wunschlisten' },
|
||||
{ value: 'app_usage', label: 'App-Nutzung', icon: '📲', desc: 'Genutzte Apps, Nutzungsdauer, In-App-Aktivitaeten' },
|
||||
{ value: 'profiling_scores', label: 'Profiling / Scoring', icon: '📊', desc: 'KI-generierte Profile, Segmente, Affinitaetsscores' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Kommunikation & Medien',
|
||||
items: [
|
||||
{ value: 'email_content', label: 'E-Mail-Inhalte', icon: '✉️', desc: 'E-Mail-Texte, Anhaenge, Metadaten' },
|
||||
{ value: 'chat_messages', label: 'Chat & Messaging', icon: '💬', desc: 'Textnachrichten, Messenger, Teams, Slack' },
|
||||
{ value: 'call_recordings', label: 'Telefonaufzeichnungen', icon: '📞', desc: 'Gespraeche, Transkripte, Anrufmetadaten' },
|
||||
{ value: 'video_conference', label: 'Videokonferenzen', icon: '📹', desc: 'Meeting-Aufzeichnungen, Teilnehmerlisten' },
|
||||
{ value: 'photographs', label: 'Fotos & Bilder', icon: '📷', desc: 'Portraitfotos, Profilbilder, Produktfotos' },
|
||||
{ value: 'cctv_surveillance', label: 'Videoueberwachung', icon: '📹', desc: 'CCTV-Aufnahmen, Zutrittskontrolle' },
|
||||
{ value: 'voice_recordings', label: 'Sprachaufnahmen', icon: '🎙️', desc: 'Voicemails, Sprachmemos, Diktate' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'HR & Beschaeftigung',
|
||||
items: [
|
||||
{ value: 'employment_data', label: 'Beschaeftigungsdaten', icon: '💼', desc: 'Arbeitgeber, Berufsbezeichnung, Vertragsart' },
|
||||
{ value: 'performance_data', label: 'Leistungsbeurteilungen', icon: '🏆', desc: 'Zielerreichung, Feedback, Abmahnungen' },
|
||||
{ value: 'work_time', label: 'Arbeitszeit', icon: '⏰', desc: 'Zeiterfassung, Ueberstunden, Schichtplaene' },
|
||||
{ value: 'candidate_data', label: 'Bewerberdaten', icon: '📝', desc: 'Lebenslaeufe, Interviews, Assessment-Ergebnisse' },
|
||||
{ value: 'social_security', label: 'Sozialversicherungs-Nr.', icon: '🛡️', desc: 'RVNR (Art. 9 — kodiert Geburtsdatum/Geschlecht)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'IoT & Sensordaten',
|
||||
items: [
|
||||
{ value: 'industrial_sensor', label: 'Industriesensoren', icon: '🏭', desc: 'Maschinendaten, Fehlerprotokolle, Produktionsmesswerte' },
|
||||
{ value: 'wearable_data', label: 'Wearable-Daten', icon: '⌚', desc: 'Herzfrequenz, Schritte, Schlaf (Art. 9 — Gesundheit)' },
|
||||
{ value: 'smart_home', label: 'Smart-Home', icon: '🏡', desc: 'Heizung, Licht, Bewegungsmelder, Nutzungszeiten' },
|
||||
{ value: 'energy_data', label: 'Energieverbrauch', icon: '🔌', desc: 'Smart-Meter, Verbrauchsprofil (enthuellt Verhalten)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Sonstige Kategorien',
|
||||
items: [
|
||||
{ value: 'children_data', label: 'Kinderdaten (unter 16)', icon: '👶', desc: 'Besonderer Schutz, Eltern-Einwilligung erforderlich' },
|
||||
{ value: 'criminal_data', label: 'Strafrechtliche Daten', icon: '⚖️', desc: 'Vorstrafen, Ermittlungsverfahren (Art. 10 DSGVO)' },
|
||||
{ value: 'location_data', label: 'Standortdaten', icon: '📍', desc: 'GPS, Mobilfunk, WLAN-Ortung, Bewegungsprofile' },
|
||||
{ value: 'social_media', label: 'Social-Media-Daten', icon: '📱', desc: 'Profile, Posts, Follower, Interaktionen' },
|
||||
{ value: 'auth_credentials', label: 'Login & Zugangsdaten', icon: '🔑', desc: 'Passwoerter, 2FA, Session-Tokens, Zugriffsprotokolle' },
|
||||
],
|
||||
},
|
||||
]
|
||||
62
admin-compliance/app/sdk/advisory-board/_data.ts
Normal file
62
admin-compliance/app/sdk/advisory-board/_data.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// =============================================================================
|
||||
// WIZARD STEPS CONFIG
|
||||
// =============================================================================
|
||||
|
||||
export const WIZARD_STEPS = [
|
||||
{ id: 1, title: 'Grundlegendes', description: 'Titel, Beschreibung und KI-Kategorie' },
|
||||
{ id: 2, title: 'Datenkategorien', description: 'Welche Daten werden verarbeitet?' },
|
||||
{ id: 3, title: 'Verarbeitungszweck', description: 'Zweck der Datenverarbeitung' },
|
||||
{ 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' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// KI-Anwendungskategorien als Auswahlkacheln
|
||||
// =============================================================================
|
||||
|
||||
export const AI_USE_CATEGORIES = [
|
||||
{ value: 'content_generation', label: 'Content-Erstellung', icon: '✍️', desc: 'Texte, Berichte, E-Mails, Dokumentation automatisch erstellen' },
|
||||
{ value: 'image_generation', label: 'Bilder erstellen', icon: '🎨', desc: 'KI-generierte Bilder, Grafiken, Produktfotos' },
|
||||
{ value: 'marketing_material', label: 'Marketingmaterial', icon: '📢', desc: 'Werbetexte, Social Media Posts, Newsletter generieren' },
|
||||
{ value: 'customer_service', label: 'Kundenservice / Chatbot', icon: '💬', desc: 'Automatisierte Kundenanfragen, FAQ-Bots, Support-Tickets' },
|
||||
{ value: 'crm_analytics', label: 'CRM & Kundenanalyse', icon: '👥', desc: 'Kundensegmentierung, Churn-Vorhersage, Lead-Scoring' },
|
||||
{ value: 'hr_recruiting', label: 'Bewerberauswahl / HR', icon: '🧑💼', desc: 'CV-Screening, Matching, Mitarbeiteranalysen' },
|
||||
{ value: 'financial_analysis', label: 'Finanzdaten analysieren', icon: '📊', desc: 'Buchhaltung, Forecasting, Betrugserkennung, Risikobewertung' },
|
||||
{ value: 'predictive_maintenance', label: 'Predictive Maintenance', icon: '🔧', desc: 'Vorausschauende Wartung, Ausfallvorhersage, IoT-Sensoranalyse' },
|
||||
{ value: 'production_analytics', label: 'Produktionsdatenauswertung', icon: '🏭', desc: 'Qualitaetskontrolle, Prozessoptimierung, OEE-Analyse' },
|
||||
{ value: 'document_analysis', label: 'Dokumentenanalyse', icon: '📄', desc: 'Vertraege, Rechnungen, PDFs automatisch auswerten und klassifizieren' },
|
||||
{ value: 'code_development', label: 'Softwareentwicklung', icon: '💻', desc: 'Code-Generierung, Code-Review, Test-Erstellung, Dokumentation' },
|
||||
{ value: 'translation', label: 'Uebersetzung', icon: '🌍', desc: 'Automatische Uebersetzung von Texten, Dokumenten, Webinhalten' },
|
||||
{ value: 'search_knowledge', label: 'Wissensmanagement / Suche', icon: '🔍', desc: 'Interne Wissensdatenbank, RAG-basierte Suche, FAQ-Systeme' },
|
||||
{ value: 'data_extraction', label: 'Datenextraktion', icon: '⛏️', desc: 'OCR, Formularerkennung, strukturierte Daten aus Freitext' },
|
||||
{ value: 'risk_compliance', label: 'Risiko & Compliance', icon: '⚖️', desc: 'Compliance-Pruefung, Risikobewertung, Audit-Unterstuetzung' },
|
||||
{ value: 'supply_chain', label: 'Lieferkette & Logistik', icon: '🚛', desc: 'Bedarfsprognose, Routenoptimierung, Bestandsmanagement' },
|
||||
{ value: 'medical_health', label: 'Medizin & Gesundheit', icon: '🏥', desc: 'Diagnoseunterstuetzung, Bildanalyse, Patientendaten' },
|
||||
{ value: 'security_monitoring', label: 'Sicherheit & Monitoring', icon: '🛡️', desc: 'Anomalieerkennung, Bedrohungsanalyse, Zugriffskontrolle' },
|
||||
{ value: 'personalization', label: 'Personalisierung', icon: '🎯', desc: 'Produktempfehlungen, dynamische Preisgestaltung, A/B-Testing' },
|
||||
{ value: 'voice_speech', label: 'Sprache & Audio', icon: '🎙️', desc: 'Spracherkennung, Text-to-Speech, Meeting-Transkription' },
|
||||
{ value: 'other', label: 'Sonstiges', icon: '➕', desc: 'Anderer KI-Anwendungsfall' },
|
||||
]
|
||||
|
||||
// Map Profil-Branche to domain value for backend compatibility
|
||||
export function industryToDomain(industries: string[]): string {
|
||||
if (!industries || industries.length === 0) return 'general'
|
||||
const first = industries[0].toLowerCase()
|
||||
if (first.includes('gesundheit') || first.includes('pharma')) return 'healthcare'
|
||||
if (first.includes('finanz') || first.includes('versicherung')) return 'finance'
|
||||
if (first.includes('bildung')) return 'education'
|
||||
if (first.includes('handel') || first.includes('commerce')) return 'retail'
|
||||
if (first.includes('it') || first.includes('technologie')) return 'it_services'
|
||||
if (first.includes('beratung') || first.includes('consulting')) return 'consulting'
|
||||
if (first.includes('produktion') || first.includes('industrie') || first.includes('maschinenbau')) return 'manufacturing'
|
||||
if (first.includes('marketing') || first.includes('agentur')) return 'marketing'
|
||||
if (first.includes('recht')) return 'legal'
|
||||
return 'general'
|
||||
}
|
||||
|
||||
export function toggleInArray(arr: string[], value: string): string[] {
|
||||
return arr.includes(value) ? arr.filter(v => v !== value) : [...arr, value]
|
||||
}
|
||||
110
admin-compliance/app/sdk/advisory-board/_tiles.ts
Normal file
110
admin-compliance/app/sdk/advisory-board/_tiles.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
// =============================================================================
|
||||
// PROCESSING PURPOSES (Step 3) — tile selection
|
||||
// =============================================================================
|
||||
|
||||
export const PURPOSE_TILES = [
|
||||
{ value: 'service_delivery', label: 'Serviceerbringung', icon: '⚙️', desc: 'Kernfunktion des Produkts oder Services' },
|
||||
{ value: 'analytics', label: 'Analyse & BI', icon: '📊', desc: 'Statistische Auswertung, Business Intelligence, Reporting' },
|
||||
{ value: 'marketing', label: 'Marketing & Werbung', icon: '📢', desc: 'Werbung, Personalisierung, Targeting, Newsletter' },
|
||||
{ value: 'profiling', label: 'Profiling', icon: '🎯', desc: 'Automatisierte Analyse personenbezogener Aspekte' },
|
||||
{ value: 'automated_decision', label: 'Automatisierte Entscheidung', icon: '🤖', desc: 'Art. 22 DSGVO — Entscheidung ohne menschliches Zutun' },
|
||||
{ value: 'customer_support', label: 'Kundensupport', icon: '🎧', desc: 'Anfragenbearbeitung, Ticketsystem, Chatbot' },
|
||||
{ value: 'quality_control', label: 'Qualitaetskontrolle', icon: '✅', desc: 'Produktpruefung, Fehleranalyse, Prozessoptimierung' },
|
||||
{ value: 'hr_management', label: 'Personalverwaltung', icon: '👥', desc: 'Recruiting, Onboarding, Mitarbeiterentwicklung' },
|
||||
{ value: 'fraud_detection', label: 'Betrugserkennung', icon: '🕵️', desc: 'Anomalieerkennung, Transaktionsueberwachung' },
|
||||
{ value: 'research', label: 'Forschung & Entwicklung', icon: '🔬', desc: 'Wissenschaftliche Auswertung, Produktentwicklung' },
|
||||
{ value: 'compliance_audit', label: 'Compliance & Audit', icon: '📜', desc: 'Regulatorische Pruefung, Dokumentation, Audit-Trail' },
|
||||
{ value: 'communication', label: 'Kommunikation', icon: '💬', desc: 'Interne/externe Kommunikation, Uebersetzung' },
|
||||
{ value: 'content_creation', label: 'Content-Erstellung', icon: '✍️', desc: 'Text-, Bild-, Video-Generierung' },
|
||||
{ value: 'predictive', label: 'Vorhersage & Prognose', icon: '🔮', desc: 'Demand Forecasting, Predictive Analytics, Wartungsvorhersage' },
|
||||
{ value: 'security', label: 'IT-Sicherheit', icon: '🛡️', desc: 'Bedrohungserkennung, Zugriffskontrolle, Monitoring' },
|
||||
{ value: 'archiving', label: 'Archivierung', icon: '🗄️', desc: 'Gesetzliche Aufbewahrung, Dokumentenarchiv' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// AUTOMATION LEVELS (Step 4) — single-select tiles
|
||||
// =============================================================================
|
||||
|
||||
export const AUTOMATION_TILES = [
|
||||
{ value: 'assistive', label: 'Assistiv (Mensch entscheidet)', icon: '🧑💻', desc: 'KI liefert Vorschlaege, Mensch trifft Entscheidung', examples: 'Rechtschreibkorrektur, Suchvorschlaege, Zusammenfassungen' },
|
||||
{ value: 'semi_automated', label: 'Teilautomatisiert (Mensch prueft)', icon: '🤝', desc: 'KI erstellt Ergebnisse, Mensch prueft und bestaetigt', examples: 'E-Mail-Entwuerfe mit Freigabe, KI-Vertraege mit juristischer Pruefung' },
|
||||
{ value: 'fully_automated', label: 'Vollautomatisiert (KI entscheidet)', icon: '🤖', desc: 'KI trifft Entscheidungen eigenstaendig', examples: 'Automatische Kreditentscheidungen, autonome Chatbot-Antworten' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// HOSTING & MODEL (Step 5) — tiles
|
||||
// =============================================================================
|
||||
|
||||
export const HOSTING_PROVIDER_TILES = [
|
||||
{ value: 'self_hosted', label: 'Eigenes Hosting', icon: '🏢', desc: 'On-Premise oder eigene Server' },
|
||||
{ value: 'hetzner', label: 'Hetzner (DE)', icon: '🇩🇪', desc: 'Deutsche Cloud-Infrastruktur' },
|
||||
{ value: 'aws', label: 'AWS', icon: '☁️', desc: 'Amazon Web Services' },
|
||||
{ value: 'azure', label: 'Microsoft Azure', icon: '🔷', desc: 'Microsoft Cloud' },
|
||||
{ value: 'gcp', label: 'Google Cloud', icon: '🔵', desc: 'Google Cloud Platform' },
|
||||
{ value: 'other', label: 'Anderer Anbieter', icon: '🌐', desc: 'Sonstiger Cloud-Anbieter' },
|
||||
]
|
||||
|
||||
export const HOSTING_REGION_TILES = [
|
||||
{ value: 'de', label: 'Deutschland', icon: '🇩🇪', desc: 'Rechenzentrum in Deutschland' },
|
||||
{ value: 'eu', label: 'EU / EWR', icon: '🇪🇺', desc: 'Innerhalb der Europaeischen Union' },
|
||||
{ value: 'us', label: 'USA', icon: '🇺🇸', desc: 'Vereinigte Staaten' },
|
||||
{ value: 'other', label: 'Andere Region', icon: '🌍', desc: 'Drittland ausserhalb EU/USA' },
|
||||
]
|
||||
|
||||
export const MODEL_USAGE_TILES = [
|
||||
{ value: 'inference', label: 'Inferenz', icon: '⚡', desc: 'Fertiges Modell direkt nutzen (z.B. ChatGPT, Claude, DeepL)' },
|
||||
{ value: 'rag', label: 'RAG', icon: '📚', desc: 'Modell erhaelt Kontext aus eigenen Dokumenten' },
|
||||
{ value: 'finetune', label: 'Fine-Tuning', icon: '🎛️', desc: 'Bestehendes Modell mit eigenen Daten nachtrainieren' },
|
||||
{ value: 'training', label: 'Eigenes Modell trainieren', icon: '🧠', desc: 'Komplett eigenes KI-Modell von Grund auf' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// DATA TRANSFER (Step 6) — tiles
|
||||
// =============================================================================
|
||||
|
||||
export const TRANSFER_TARGET_TILES = [
|
||||
{ value: 'no_transfer', label: 'Kein Drittlandtransfer', icon: '🇪🇺', desc: 'Daten verbleiben in der EU/EWR' },
|
||||
{ value: 'usa', label: 'USA', icon: '🇺🇸', desc: 'Datentransfer in die USA' },
|
||||
{ value: 'uk', label: 'Grossbritannien', icon: '🇬🇧', desc: 'Datentransfer nach UK (Angemessenheitsbeschluss)' },
|
||||
{ value: 'switzerland', label: 'Schweiz', icon: '🇨🇭', desc: 'Datentransfer in die Schweiz (Angemessenheitsbeschluss)' },
|
||||
{ value: 'other_adequate', label: 'Anderes Land (Angemessenheit)', icon: '✅', desc: 'Land mit Angemessenheitsbeschluss der EU' },
|
||||
{ value: 'other_third', label: 'Sonstiges Drittland', icon: '🌍', desc: 'Land ohne Angemessenheitsbeschluss' },
|
||||
]
|
||||
|
||||
export const TRANSFER_MECHANISM_TILES = [
|
||||
{ value: 'not_needed', label: 'Nicht erforderlich', icon: '✅', desc: 'Kein Drittlandtransfer oder Angemessenheit' },
|
||||
{ value: 'scc', label: 'Standardvertragsklauseln', icon: '📝', desc: 'SCC nach Art. 46 Abs. 2c DSGVO' },
|
||||
{ value: 'bcr', label: 'Binding Corporate Rules', icon: '🏛️', desc: 'BCR nach Art. 47 DSGVO' },
|
||||
{ value: 'adequacy', label: 'Angemessenheitsbeschluss', icon: '🤝', desc: 'EU-Kommissionsbeschluss (z.B. EU-US DPF)' },
|
||||
{ value: 'derogation', label: 'Ausnahme (Art. 49)', icon: '⚠️', desc: 'Einwilligung oder zwingende Interessen' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// RETENTION (Step 7) — tiles
|
||||
// =============================================================================
|
||||
|
||||
export const RETENTION_TILES = [
|
||||
{ value: 'session', label: 'Nur waehrend Session', icon: '⏱️', desc: 'Daten werden nach Sitzungsende geloescht' },
|
||||
{ value: '30_days', label: '30 Tage', icon: '📅', desc: 'Kurzfristige Aufbewahrung' },
|
||||
{ value: '90_days', label: '90 Tage', icon: '📅', desc: 'Standardaufbewahrung' },
|
||||
{ value: '1_year', label: '1 Jahr', icon: '📆', desc: 'Jaehrliche Aufbewahrung' },
|
||||
{ value: '3_years', label: '3 Jahre', icon: '📆', desc: 'Mittelfristige Aufbewahrung' },
|
||||
{ value: '6_years', label: '6 Jahre', icon: '📆', desc: 'Handelsrechtliche Aufbewahrungsfrist' },
|
||||
{ value: '10_years', label: '10 Jahre', icon: '📆', desc: 'Steuerrechtliche Aufbewahrungsfrist' },
|
||||
{ value: 'indefinite', label: 'Unbefristet', icon: '♾️', desc: 'Keine zeitliche Begrenzung' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// CONTRACTS (Step 8) — tiles
|
||||
// =============================================================================
|
||||
|
||||
export const CONTRACT_TILES = [
|
||||
{ value: 'has_dpa', label: 'AVV / DPA vorhanden', icon: '📄', desc: 'Auftragsverarbeitungsvertrag nach Art. 28 DSGVO' },
|
||||
{ value: 'has_aia_doc', label: 'AI Act Dokumentation', icon: '🤖', desc: 'Risikoklassifizierung und technische Doku nach EU AI Act' },
|
||||
{ value: 'has_dsfa', label: 'DSFA durchgefuehrt', icon: '📋', desc: 'Datenschutz-Folgenabschaetzung nach Art. 35 DSGVO' },
|
||||
{ value: 'has_tia', label: 'TIA durchgefuehrt', icon: '🌍', desc: 'Transfer Impact Assessment fuer Drittlandtransfers' },
|
||||
{ value: 'has_tom', label: 'TOM dokumentiert', icon: '🔒', desc: 'Technisch-organisatorische Massnahmen nach Art. 32 DSGVO' },
|
||||
{ value: 'has_vvt', label: 'Im VVT erfasst', icon: '📚', desc: 'Im Verzeichnis von Verarbeitungstaetigkeiten eingetragen' },
|
||||
{ value: 'has_consent', label: 'Einwilligungen eingeholt', icon: '✅', desc: 'Nutzereinwilligungen vorhanden und dokumentiert' },
|
||||
{ value: 'none', label: 'Noch keine Dokumente', icon: '⚠️', desc: 'Compliance-Dokumentation steht noch aus' },
|
||||
]
|
||||
25
admin-compliance/app/sdk/advisory-board/_types.ts
Normal file
25
admin-compliance/app/sdk/advisory-board/_types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface AdvisoryForm {
|
||||
title: string
|
||||
use_case_text: string
|
||||
domain: string
|
||||
category: string
|
||||
data_categories: string[]
|
||||
custom_data_types: string[]
|
||||
purposes: string[]
|
||||
automation: string
|
||||
hosting_provider: string
|
||||
hosting_region: string
|
||||
model_usage: string[]
|
||||
transfer_targets: string[]
|
||||
transfer_countries: string[]
|
||||
transfer_mechanism: string
|
||||
retention_period: string
|
||||
retention_purpose: string
|
||||
contracts: string[]
|
||||
subprocessors: string
|
||||
}
|
||||
|
||||
export interface StepProps {
|
||||
form: AdvisoryForm
|
||||
updateForm: (updates: Partial<AdvisoryForm>) => void
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
export function ArchitectureTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 text-lg mb-4">Systemarchitektur</h3>
|
||||
|
||||
<div className="bg-slate-900 text-green-400 p-6 rounded-lg font-mono text-sm overflow-x-auto">
|
||||
<pre>{`
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (Next.js) │
|
||||
│ admin-v2:3000/sdk/advisory-board │
|
||||
└───────────────────────────────────┬─────────────────────────────────┘
|
||||
│ HTTPS
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ AI Compliance SDK (Go) │
|
||||
│ Port 8090 │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Policy Engine │ │
|
||||
│ │ ┌───────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ YAML-basierte Regeln (ucca_policy_v1.yaml) │ │ │
|
||||
│ │ │ ~45 Regeln in 7 Kategorien │ │ │
|
||||
│ │ │ Deterministisch - Kein LLM in Entscheidungslogik │ │ │
|
||||
│ │ └───────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ │
|
||||
│ │ │ Controls │ │ Patterns │ │ Examples │ │ │
|
||||
│ │ │ Library │ │ Library │ │ Library │ │ │
|
||||
│ │ └────────────────┘ └────────────────┘ └────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ LLM Integration │ │ Legal RAG │──────┐ │
|
||||
│ │ (nur Explain) │ │ Client │ │ │
|
||||
│ └──────────────────┘ └──────────────────┘ │ │
|
||||
└─────────────────────────────┬────────────────────┼──────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Datenschicht │
|
||||
│ ┌────────────────────┐ ┌────────────────────┐ │
|
||||
│ │ PostgreSQL │ │ Qdrant │ │
|
||||
│ │ (Assessments, │ │ (Legal Corpus, │ │
|
||||
│ │ Escalations) │ │ 2,274 Chunks) │ │
|
||||
│ └────────────────────┘ └────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h4 className="font-medium text-blue-800 mb-2">Datenfluss</h4>
|
||||
<ol className="text-sm text-blue-700 list-decimal list-inside space-y-1">
|
||||
<li>Benutzer beschreibt Use Case im Frontend</li>
|
||||
<li>Policy Engine evaluiert gegen alle Regeln</li>
|
||||
<li>Ergebnis mit Controls + Patterns zurueck</li>
|
||||
<li>Optional: LLM erklaert das Ergebnis</li>
|
||||
<li>Bei Risiko: Automatische Eskalation</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<h4 className="font-medium text-green-800 mb-2">Sicherheitsmerkmale</h4>
|
||||
<ul className="text-sm text-green-700 list-disc list-inside space-y-1">
|
||||
<li>TLS 1.3 Verschluesselung</li>
|
||||
<li>RBAC mit Tenant-Isolation</li>
|
||||
<li>JWT-basierte Authentifizierung</li>
|
||||
<li>Audit-Trail aller Aktionen</li>
|
||||
<li>Keine Rohtext-Speicherung (nur Hash)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 text-lg mb-4">Eskalations-Workflow</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Level</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Ausloeser</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Pruefer</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">SLA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-slate-100 bg-green-50">
|
||||
<td className="py-2 px-3 font-medium text-green-700">E0</td>
|
||||
<td className="py-2 px-3 text-slate-600">Nur INFO-Regeln, Risiko < 20</td>
|
||||
<td className="py-2 px-3 text-slate-600">Automatisch</td>
|
||||
<td className="py-2 px-3 text-slate-600">-</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100 bg-yellow-50">
|
||||
<td className="py-2 px-3 font-medium text-yellow-700">E1</td>
|
||||
<td className="py-2 px-3 text-slate-600">WARN-Regeln, Risiko 20-40</td>
|
||||
<td className="py-2 px-3 text-slate-600">Team-Lead</td>
|
||||
<td className="py-2 px-3 text-slate-600">24h / 72h</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100 bg-orange-50">
|
||||
<td className="py-2 px-3 font-medium text-orange-700">E2</td>
|
||||
<td className="py-2 px-3 text-slate-600">Art. 9 Daten, DSFA empfohlen, Risiko 40-60</td>
|
||||
<td className="py-2 px-3 text-slate-600">DSB</td>
|
||||
<td className="py-2 px-3 text-slate-600">8h / 48h</td>
|
||||
</tr>
|
||||
<tr className="bg-red-50">
|
||||
<td className="py-2 px-3 font-medium text-red-700">E3</td>
|
||||
<td className="py-2 px-3 text-slate-600">BLOCK-Regeln, Art. 22, Risiko > 60</td>
|
||||
<td className="py-2 px-3 text-slate-600">DSB + Legal</td>
|
||||
<td className="py-2 px-3 text-slate-600">4h / 24h</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
export function AuditorTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 text-lg mb-4">
|
||||
Dokumentation fuer externe Auditoren
|
||||
</h3>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Diese Dokumentation erfuellt die Anforderungen nach Art. 30 DSGVO (Verzeichnis von
|
||||
Verarbeitungstaetigkeiten) und dient als Grundlage fuer Audits nach Art. 32 DSGVO.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<h4 className="font-medium text-slate-800 mb-2">1. Zweck des Systems</h4>
|
||||
<p className="text-sm text-slate-600">
|
||||
UCCA ist ein Compliance-Pruefwerkzeug zur Bewertung geplanter KI-Anwendungsfaelle
|
||||
hinsichtlich ihrer datenschutzrechtlichen Zulaessigkeit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<h4 className="font-medium text-slate-800 mb-2">2. Rechtsgrundlage</h4>
|
||||
<ul className="text-sm text-slate-600 list-disc list-inside space-y-1">
|
||||
<li><strong>Art. 6 Abs. 1 lit. c DSGVO</strong> - Erfuellung rechtlicher Verpflichtungen</li>
|
||||
<li><strong>Art. 6 Abs. 1 lit. f DSGVO</strong> - Berechtigte Interessen (Compliance-Management)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<h4 className="font-medium text-slate-800 mb-2">3. Verarbeitete Datenkategorien</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm mt-2">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-2 px-2 font-medium text-slate-600">Kategorie</th>
|
||||
<th className="text-left py-2 px-2 font-medium text-slate-600">Speicherung</th>
|
||||
<th className="text-left py-2 px-2 font-medium text-slate-600">Aufbewahrung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-slate-600">
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-2 px-2">Use-Case-Beschreibung</td>
|
||||
<td className="py-2 px-2">Nur Hash (SHA-256)</td>
|
||||
<td className="py-2 px-2">10 Jahre</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-2 px-2">Bewertungsergebnis</td>
|
||||
<td className="py-2 px-2">Vollstaendig</td>
|
||||
<td className="py-2 px-2">10 Jahre</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-2 px-2">Audit-Trail</td>
|
||||
<td className="py-2 px-2">Vollstaendig</td>
|
||||
<td className="py-2 px-2">10 Jahre</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-2">Eskalations-Historie</td>
|
||||
<td className="py-2 px-2">Vollstaendig</td>
|
||||
<td className="py-2 px-2">10 Jahre</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<h4 className="font-medium text-slate-800 mb-2">4. Keine autonomen KI-Entscheidungen</h4>
|
||||
<p className="text-sm text-slate-600">
|
||||
Das System trifft <strong>KEINE automatisierten Einzelentscheidungen</strong> im Sinne
|
||||
von Art. 22 DSGVO, da:
|
||||
</p>
|
||||
<ul className="text-sm text-slate-600 list-disc list-inside mt-2 space-y-1">
|
||||
<li>Regelauswertung ist keine rechtlich bindende Entscheidung</li>
|
||||
<li>Alle kritischen Faelle werden menschlich geprueft (E1-E3)</li>
|
||||
<li>BLOCK-Entscheidungen erfordern immer menschliche Freigabe</li>
|
||||
<li>Betroffene haben Anfechtungsmoeglichkeit ueber Eskalation</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<h4 className="font-medium text-green-800 mb-2">5. Technische und Organisatorische Massnahmen</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<strong className="text-green-700">Vertraulichkeit</strong>
|
||||
<ul className="text-green-700 list-disc list-inside mt-1">
|
||||
<li>RBAC mit Tenant-Isolation</li>
|
||||
<li>TLS 1.3 Verschluesselung</li>
|
||||
<li>AES-256 at rest</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-green-700">Integritaet</strong>
|
||||
<ul className="text-green-700 list-disc list-inside mt-1">
|
||||
<li>Unveraenderlicher Audit-Trail</li>
|
||||
<li>Policy-Versionierung</li>
|
||||
<li>Input-Validierung</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export function LegalCorpusTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 text-lg mb-4">Legal RAG Corpus</h3>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Das System verwendet einen semantischen Suchindex mit 2.274 Chunks aus 19 EU-Regulierungen
|
||||
fuer rechtsgrundlagenbasierte Erklaerungen.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h4 className="font-medium text-blue-800 mb-2">Indexierte Regulierungen</h4>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li>DSGVO - Datenschutz-Grundverordnung</li>
|
||||
<li>AI Act - EU KI-Verordnung</li>
|
||||
<li>NIS2 - Cybersicherheits-Richtlinie</li>
|
||||
<li>CRA - Cyber Resilience Act</li>
|
||||
<li>Data Act - Datengesetz</li>
|
||||
<li>DSA/DMA - Digital Services/Markets Act</li>
|
||||
<li>DPF - EU-US Data Privacy Framework</li>
|
||||
<li>BSI-TR-03161 - Digitale Identitaeten</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<h4 className="font-medium text-green-800 mb-2">RAG-Funktionalitaet</h4>
|
||||
<ul className="text-sm text-green-700 space-y-1">
|
||||
<li>Hybride Suche (Dense + BM25)</li>
|
||||
<li>Semantisches Chunking</li>
|
||||
<li>Cross-Encoder Reranking</li>
|
||||
<li>Artikel-Referenz-Extraktion</li>
|
||||
<li>Mehrsprachig (DE/EN)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 mb-4">Verwendung im System</h3>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{
|
||||
step: 1,
|
||||
title: 'Benutzer fordert Erklaerung an',
|
||||
text: 'Nach der Bewertung kann eine LLM-basierte Erklaerung generiert werden.',
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
title: 'Legal RAG Client sucht relevante Artikel',
|
||||
text: 'Basierend auf den ausgeloesten Regeln werden passende Gesetzestexte gefunden.',
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
title: 'LLM generiert Erklaerung mit Rechtsgrundlage',
|
||||
text: 'Die Erklaerung referenziert konkrete Artikel aus DSGVO, AI Act etc.',
|
||||
},
|
||||
].map(({ step, title, text }) => (
|
||||
<div key={step} className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
{step}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">{title}</div>
|
||||
<div className="text-sm text-slate-600">{text}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Rule, Pattern, Control } from './types'
|
||||
|
||||
export function OverviewTab({ rules, patterns, controls }: {
|
||||
rules: Rule[]
|
||||
patterns: Pattern[]
|
||||
controls: Control[]
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-slate-800 mb-2">Deterministische Regeln</h3>
|
||||
<div className="text-3xl font-bold text-purple-600">{rules.length}</div>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
Alle Entscheidungen basieren auf transparenten, nachvollziehbaren Regeln.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-slate-800 mb-2">Architektur-Patterns</h3>
|
||||
<div className="text-3xl font-bold text-green-600">{patterns.length}</div>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
Best-Practice-Loesungen fuer datenschutzkonforme KI-Systeme.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-slate-800 mb-2">Compliance-Kontrollen</h3>
|
||||
<div className="text-3xl font-bold text-blue-600">{controls.length}</div>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
Technische und organisatorische Massnahmen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-purple-50 to-blue-50 rounded-xl border border-purple-200 p-6">
|
||||
<h3 className="font-semibold text-purple-800 text-lg mb-4">Was ist UCCA?</h3>
|
||||
<div className="prose prose-sm max-w-none text-slate-700">
|
||||
<p>
|
||||
<strong>UCCA (Use-Case Compliance & Feasibility Advisor)</strong> ist ein deterministisches
|
||||
Compliance-Pruefwerkzeug, das Organisationen bei der Bewertung geplanter KI-Anwendungsfaelle
|
||||
hinsichtlich ihrer datenschutzrechtlichen Zulaessigkeit unterstuetzt.
|
||||
</p>
|
||||
<h4 className="text-purple-700 mt-4">Kernprinzipien</h4>
|
||||
<ul className="space-y-2">
|
||||
<li><strong>Determinismus:</strong> Alle Entscheidungen basieren auf transparenten Regeln. Die KI trifft KEINE autonomen Entscheidungen.</li>
|
||||
<li><strong>Transparenz:</strong> Alle Regeln, Kontrollen und Patterns sind einsehbar.</li>
|
||||
<li><strong>Human-in-the-Loop:</strong> Kritische Entscheidungen erfordern immer menschliche Pruefung durch DSB oder Legal.</li>
|
||||
<li><strong>Rechtsgrundlage:</strong> Jede Regel referenziert konkrete DSGVO-Artikel.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-amber-800 mb-3">Wichtiger Hinweis zur KI-Nutzung</h3>
|
||||
<p className="text-amber-700">
|
||||
Das System verwendet KI (LLM) <strong>ausschliesslich zur Erklaerung</strong> bereits
|
||||
getroffener Regelentscheidungen. Die eigentliche Compliance-Bewertung erfolgt
|
||||
<strong> rein deterministisch</strong> durch die Policy Engine. BLOCK-Entscheidungen
|
||||
koennen NICHT durch KI ueberschrieben werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Rule } from './types'
|
||||
|
||||
export function RulesTab({ rules, policyVersion, loading }: {
|
||||
rules: Rule[]
|
||||
policyVersion: string
|
||||
loading: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-800 text-lg">Regel-Katalog</h3>
|
||||
<p className="text-sm text-slate-500">Policy Version: {policyVersion}</p>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">{rules.length} Regeln insgesamt</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-slate-500">Lade Regeln...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{Array.from(new Set(rules.map(r => r.category))).map(category => (
|
||||
<div key={category} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200">
|
||||
<h4 className="font-medium text-slate-800">{category}</h4>
|
||||
<p className="text-xs text-slate-500">
|
||||
{rules.filter(r => r.category === category).length} Regeln
|
||||
</p>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{rules.filter(r => r.category === category).map(rule => (
|
||||
<div key={rule.code} className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-slate-500">{rule.code}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
rule.severity === 'BLOCK' ? 'bg-red-100 text-red-700' :
|
||||
rule.severity === 'WARN' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{rule.severity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-medium text-slate-800 mt-1">{rule.title}</div>
|
||||
<div className="text-sm text-slate-600 mt-1">{rule.description}</div>
|
||||
{rule.gdpr_ref && (
|
||||
<div className="text-xs text-slate-500 mt-2">{rule.gdpr_ref}</div>
|
||||
)}
|
||||
</div>
|
||||
{rule.risk_add && (
|
||||
<div className="text-sm font-medium text-red-600">+{rule.risk_add}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export type DocTab = 'overview' | 'architecture' | 'auditor' | 'rules' | 'legal-corpus'
|
||||
|
||||
export interface Rule {
|
||||
code: string
|
||||
category: string
|
||||
title: string
|
||||
description: string
|
||||
severity: string
|
||||
gdpr_ref: string
|
||||
rationale?: string
|
||||
risk_add?: number
|
||||
}
|
||||
|
||||
export interface Pattern {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
benefit?: string
|
||||
effort?: string
|
||||
risk_reduction?: number
|
||||
}
|
||||
|
||||
export interface Control {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
gdpr_ref?: string
|
||||
effort?: string
|
||||
}
|
||||
@@ -9,50 +9,22 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type DocTab = 'overview' | 'architecture' | 'auditor' | 'rules' | 'legal-corpus'
|
||||
|
||||
interface Rule {
|
||||
code: string
|
||||
category: string
|
||||
title: string
|
||||
description: string
|
||||
severity: string
|
||||
gdpr_ref: string
|
||||
rationale?: string
|
||||
risk_add?: number
|
||||
}
|
||||
|
||||
interface Pattern {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
benefit?: string
|
||||
effort?: string
|
||||
risk_reduction?: number
|
||||
}
|
||||
|
||||
interface Control {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
gdpr_ref?: string
|
||||
effort?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API Configuration
|
||||
// ============================================================================
|
||||
import { DocTab, Rule, Pattern, Control } from './_components/types'
|
||||
import { OverviewTab } from './_components/OverviewTab'
|
||||
import { ArchitectureTab } from './_components/ArchitectureTab'
|
||||
import { AuditorTab } from './_components/AuditorTab'
|
||||
import { RulesTab } from './_components/RulesTab'
|
||||
import { LegalCorpusTab } from './_components/LegalCorpusTab'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'https://macmini:8090'
|
||||
|
||||
// ============================================================================
|
||||
// Main Component
|
||||
// ============================================================================
|
||||
const tabs: { id: DocTab; label: string }[] = [
|
||||
{ id: 'overview', label: 'Uebersicht' },
|
||||
{ id: 'architecture', label: 'Architektur' },
|
||||
{ id: 'auditor', label: 'Fuer Auditoren' },
|
||||
{ id: 'rules', label: 'Regel-Katalog' },
|
||||
{ id: 'legal-corpus', label: 'Legal RAG' },
|
||||
]
|
||||
|
||||
export default function DocumentationPage() {
|
||||
const [activeTab, setActiveTab] = useState<DocTab>('overview')
|
||||
@@ -100,454 +72,6 @@ export default function DocumentationPage() {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
// ============================================================================
|
||||
// Tab Content Renderers
|
||||
// ============================================================================
|
||||
|
||||
const renderOverview = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-slate-800 mb-2">Deterministische Regeln</h3>
|
||||
<div className="text-3xl font-bold text-purple-600">{rules.length}</div>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
Alle Entscheidungen basieren auf transparenten, nachvollziehbaren Regeln.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-slate-800 mb-2">Architektur-Patterns</h3>
|
||||
<div className="text-3xl font-bold text-green-600">{patterns.length}</div>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
Best-Practice-Loesungen fuer datenschutzkonforme KI-Systeme.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-slate-800 mb-2">Compliance-Kontrollen</h3>
|
||||
<div className="text-3xl font-bold text-blue-600">{controls.length}</div>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
Technische und organisatorische Massnahmen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-purple-50 to-blue-50 rounded-xl border border-purple-200 p-6">
|
||||
<h3 className="font-semibold text-purple-800 text-lg mb-4">Was ist UCCA?</h3>
|
||||
<div className="prose prose-sm max-w-none text-slate-700">
|
||||
<p>
|
||||
<strong>UCCA (Use-Case Compliance & Feasibility Advisor)</strong> ist ein deterministisches
|
||||
Compliance-Pruefwerkzeug, das Organisationen bei der Bewertung geplanter KI-Anwendungsfaelle
|
||||
hinsichtlich ihrer datenschutzrechtlichen Zulaessigkeit unterstuetzt.
|
||||
</p>
|
||||
<h4 className="text-purple-700 mt-4">Kernprinzipien</h4>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<strong>Determinismus:</strong> Alle Entscheidungen basieren auf transparenten Regeln.
|
||||
Die KI trifft KEINE autonomen Entscheidungen.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Transparenz:</strong> Alle Regeln, Kontrollen und Patterns sind einsehbar.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Human-in-the-Loop:</strong> Kritische Entscheidungen erfordern immer
|
||||
menschliche Pruefung durch DSB oder Legal.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Rechtsgrundlage:</strong> Jede Regel referenziert konkrete DSGVO-Artikel.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-amber-800 mb-3">
|
||||
Wichtiger Hinweis zur KI-Nutzung
|
||||
</h3>
|
||||
<p className="text-amber-700">
|
||||
Das System verwendet KI (LLM) <strong>ausschliesslich zur Erklaerung</strong> bereits
|
||||
getroffener Regelentscheidungen. Die eigentliche Compliance-Bewertung erfolgt
|
||||
<strong> rein deterministisch</strong> durch die Policy Engine. BLOCK-Entscheidungen
|
||||
koennen NICHT durch KI ueberschrieben werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderArchitecture = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 text-lg mb-4">Systemarchitektur</h3>
|
||||
|
||||
<div className="bg-slate-900 text-green-400 p-6 rounded-lg font-mono text-sm overflow-x-auto">
|
||||
<pre>{`
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (Next.js) │
|
||||
│ admin-v2:3000/sdk/advisory-board │
|
||||
└───────────────────────────────────┬─────────────────────────────────┘
|
||||
│ HTTPS
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ AI Compliance SDK (Go) │
|
||||
│ Port 8090 │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Policy Engine │ │
|
||||
│ │ ┌───────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ YAML-basierte Regeln (ucca_policy_v1.yaml) │ │ │
|
||||
│ │ │ ~45 Regeln in 7 Kategorien │ │ │
|
||||
│ │ │ Deterministisch - Kein LLM in Entscheidungslogik │ │ │
|
||||
│ │ └───────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ │
|
||||
│ │ │ Controls │ │ Patterns │ │ Examples │ │ │
|
||||
│ │ │ Library │ │ Library │ │ Library │ │ │
|
||||
│ │ └────────────────┘ └────────────────┘ └────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ LLM Integration │ │ Legal RAG │──────┐ │
|
||||
│ │ (nur Explain) │ │ Client │ │ │
|
||||
│ └──────────────────┘ └──────────────────┘ │ │
|
||||
└─────────────────────────────┬────────────────────┼──────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ Datenschicht │
|
||||
│ ┌────────────────────┐ ┌────────────────────┐ │
|
||||
│ │ PostgreSQL │ │ Qdrant │ │
|
||||
│ │ (Assessments, │ │ (Legal Corpus, │ │
|
||||
│ │ Escalations) │ │ 2,274 Chunks) │ │
|
||||
│ └────────────────────┘ └────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
`}</pre>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h4 className="font-medium text-blue-800 mb-2">Datenfluss</h4>
|
||||
<ol className="text-sm text-blue-700 list-decimal list-inside space-y-1">
|
||||
<li>Benutzer beschreibt Use Case im Frontend</li>
|
||||
<li>Policy Engine evaluiert gegen alle Regeln</li>
|
||||
<li>Ergebnis mit Controls + Patterns zurueck</li>
|
||||
<li>Optional: LLM erklaert das Ergebnis</li>
|
||||
<li>Bei Risiko: Automatische Eskalation</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<h4 className="font-medium text-green-800 mb-2">Sicherheitsmerkmale</h4>
|
||||
<ul className="text-sm text-green-700 list-disc list-inside space-y-1">
|
||||
<li>TLS 1.3 Verschluesselung</li>
|
||||
<li>RBAC mit Tenant-Isolation</li>
|
||||
<li>JWT-basierte Authentifizierung</li>
|
||||
<li>Audit-Trail aller Aktionen</li>
|
||||
<li>Keine Rohtext-Speicherung (nur Hash)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 text-lg mb-4">Eskalations-Workflow</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Level</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Ausloeser</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Pruefer</th>
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">SLA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-slate-100 bg-green-50">
|
||||
<td className="py-2 px-3 font-medium text-green-700">E0</td>
|
||||
<td className="py-2 px-3 text-slate-600">Nur INFO-Regeln, Risiko < 20</td>
|
||||
<td className="py-2 px-3 text-slate-600">Automatisch</td>
|
||||
<td className="py-2 px-3 text-slate-600">-</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100 bg-yellow-50">
|
||||
<td className="py-2 px-3 font-medium text-yellow-700">E1</td>
|
||||
<td className="py-2 px-3 text-slate-600">WARN-Regeln, Risiko 20-40</td>
|
||||
<td className="py-2 px-3 text-slate-600">Team-Lead</td>
|
||||
<td className="py-2 px-3 text-slate-600">24h / 72h</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100 bg-orange-50">
|
||||
<td className="py-2 px-3 font-medium text-orange-700">E2</td>
|
||||
<td className="py-2 px-3 text-slate-600">Art. 9 Daten, DSFA empfohlen, Risiko 40-60</td>
|
||||
<td className="py-2 px-3 text-slate-600">DSB</td>
|
||||
<td className="py-2 px-3 text-slate-600">8h / 48h</td>
|
||||
</tr>
|
||||
<tr className="bg-red-50">
|
||||
<td className="py-2 px-3 font-medium text-red-700">E3</td>
|
||||
<td className="py-2 px-3 text-slate-600">BLOCK-Regeln, Art. 22, Risiko > 60</td>
|
||||
<td className="py-2 px-3 text-slate-600">DSB + Legal</td>
|
||||
<td className="py-2 px-3 text-slate-600">4h / 24h</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderAuditorInfo = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 text-lg mb-4">
|
||||
Dokumentation fuer externe Auditoren
|
||||
</h3>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Diese Dokumentation erfuellt die Anforderungen nach Art. 30 DSGVO (Verzeichnis von
|
||||
Verarbeitungstaetigkeiten) und dient als Grundlage fuer Audits nach Art. 32 DSGVO.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<h4 className="font-medium text-slate-800 mb-2">1. Zweck des Systems</h4>
|
||||
<p className="text-sm text-slate-600">
|
||||
UCCA ist ein Compliance-Pruefwerkzeug zur Bewertung geplanter KI-Anwendungsfaelle
|
||||
hinsichtlich ihrer datenschutzrechtlichen Zulaessigkeit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<h4 className="font-medium text-slate-800 mb-2">2. Rechtsgrundlage</h4>
|
||||
<ul className="text-sm text-slate-600 list-disc list-inside space-y-1">
|
||||
<li><strong>Art. 6 Abs. 1 lit. c DSGVO</strong> - Erfuellung rechtlicher Verpflichtungen</li>
|
||||
<li><strong>Art. 6 Abs. 1 lit. f DSGVO</strong> - Berechtigte Interessen (Compliance-Management)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<h4 className="font-medium text-slate-800 mb-2">3. Verarbeitete Datenkategorien</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm mt-2">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-2 px-2 font-medium text-slate-600">Kategorie</th>
|
||||
<th className="text-left py-2 px-2 font-medium text-slate-600">Speicherung</th>
|
||||
<th className="text-left py-2 px-2 font-medium text-slate-600">Aufbewahrung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-slate-600">
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-2 px-2">Use-Case-Beschreibung</td>
|
||||
<td className="py-2 px-2">Nur Hash (SHA-256)</td>
|
||||
<td className="py-2 px-2">10 Jahre</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-2 px-2">Bewertungsergebnis</td>
|
||||
<td className="py-2 px-2">Vollstaendig</td>
|
||||
<td className="py-2 px-2">10 Jahre</td>
|
||||
</tr>
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-2 px-2">Audit-Trail</td>
|
||||
<td className="py-2 px-2">Vollstaendig</td>
|
||||
<td className="py-2 px-2">10 Jahre</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-2">Eskalations-Historie</td>
|
||||
<td className="py-2 px-2">Vollstaendig</td>
|
||||
<td className="py-2 px-2">10 Jahre</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<h4 className="font-medium text-slate-800 mb-2">4. Keine autonomen KI-Entscheidungen</h4>
|
||||
<p className="text-sm text-slate-600">
|
||||
Das System trifft <strong>KEINE automatisierten Einzelentscheidungen</strong> im Sinne
|
||||
von Art. 22 DSGVO, da:
|
||||
</p>
|
||||
<ul className="text-sm text-slate-600 list-disc list-inside mt-2 space-y-1">
|
||||
<li>Regelauswertung ist keine rechtlich bindende Entscheidung</li>
|
||||
<li>Alle kritischen Faelle werden menschlich geprueft (E1-E3)</li>
|
||||
<li>BLOCK-Entscheidungen erfordern immer menschliche Freigabe</li>
|
||||
<li>Betroffene haben Anfechtungsmoeglichkeit ueber Eskalation</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<h4 className="font-medium text-green-800 mb-2">5. Technische und Organisatorische Massnahmen</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<strong className="text-green-700">Vertraulichkeit</strong>
|
||||
<ul className="text-green-700 list-disc list-inside mt-1">
|
||||
<li>RBAC mit Tenant-Isolation</li>
|
||||
<li>TLS 1.3 Verschluesselung</li>
|
||||
<li>AES-256 at rest</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-green-700">Integritaet</strong>
|
||||
<ul className="text-green-700 list-disc list-inside mt-1">
|
||||
<li>Unveraenderlicher Audit-Trail</li>
|
||||
<li>Policy-Versionierung</li>
|
||||
<li>Input-Validierung</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderRulesTab = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-800 text-lg">Regel-Katalog</h3>
|
||||
<p className="text-sm text-slate-500">Policy Version: {policyVersion}</p>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{rules.length} Regeln insgesamt
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-slate-500">Lade Regeln...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{Array.from(new Set(rules.map(r => r.category))).map(category => (
|
||||
<div key={category} className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 border-b border-slate-200">
|
||||
<h4 className="font-medium text-slate-800">{category}</h4>
|
||||
<p className="text-xs text-slate-500">
|
||||
{rules.filter(r => r.category === category).length} Regeln
|
||||
</p>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{rules.filter(r => r.category === category).map(rule => (
|
||||
<div key={rule.code} className="p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm text-slate-500">{rule.code}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
rule.severity === 'BLOCK' ? 'bg-red-100 text-red-700' :
|
||||
rule.severity === 'WARN' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{rule.severity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-medium text-slate-800 mt-1">{rule.title}</div>
|
||||
<div className="text-sm text-slate-600 mt-1">{rule.description}</div>
|
||||
{rule.gdpr_ref && (
|
||||
<div className="text-xs text-slate-500 mt-2">{rule.gdpr_ref}</div>
|
||||
)}
|
||||
</div>
|
||||
{rule.risk_add && (
|
||||
<div className="text-sm font-medium text-red-600">
|
||||
+{rule.risk_add}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderLegalCorpus = () => (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 text-lg mb-4">Legal RAG Corpus</h3>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Das System verwendet einen semantischen Suchindex mit 2.274 Chunks aus 19 EU-Regulierungen
|
||||
fuer rechtsgrundlagenbasierte Erklaerungen.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<h4 className="font-medium text-blue-800 mb-2">Indexierte Regulierungen</h4>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li>DSGVO - Datenschutz-Grundverordnung</li>
|
||||
<li>AI Act - EU KI-Verordnung</li>
|
||||
<li>NIS2 - Cybersicherheits-Richtlinie</li>
|
||||
<li>CRA - Cyber Resilience Act</li>
|
||||
<li>Data Act - Datengesetz</li>
|
||||
<li>DSA/DMA - Digital Services/Markets Act</li>
|
||||
<li>DPF - EU-US Data Privacy Framework</li>
|
||||
<li>BSI-TR-03161 - Digitale Identitaeten</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<h4 className="font-medium text-green-800 mb-2">RAG-Funktionalitaet</h4>
|
||||
<ul className="text-sm text-green-700 space-y-1">
|
||||
<li>Hybride Suche (Dense + BM25)</li>
|
||||
<li>Semantisches Chunking</li>
|
||||
<li>Cross-Encoder Reranking</li>
|
||||
<li>Artikel-Referenz-Extraktion</li>
|
||||
<li>Mehrsprachig (DE/EN)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-800 mb-4">Verwendung im System</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">Benutzer fordert Erklaerung an</div>
|
||||
<div className="text-sm text-slate-600">
|
||||
Nach der Bewertung kann eine LLM-basierte Erklaerung generiert werden.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">Legal RAG Client sucht relevante Artikel</div>
|
||||
<div className="text-sm text-slate-600">
|
||||
Basierend auf den ausgeloesten Regeln werden passende Gesetzestexte gefunden.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">LLM generiert Erklaerung mit Rechtsgrundlage</div>
|
||||
<div className="text-sm text-slate-600">
|
||||
Die Erklaerung referenziert konkrete Artikel aus DSGVO, AI Act etc.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Tabs Configuration
|
||||
// ============================================================================
|
||||
|
||||
const tabs: { id: DocTab; label: string }[] = [
|
||||
{ id: 'overview', label: 'Uebersicht' },
|
||||
{ id: 'architecture', label: 'Architektur' },
|
||||
{ id: 'auditor', label: 'Fuer Auditoren' },
|
||||
{ id: 'rules', label: 'Regel-Katalog' },
|
||||
{ id: 'legal-corpus', label: 'Legal RAG' },
|
||||
]
|
||||
|
||||
// ============================================================================
|
||||
// Main Render
|
||||
// ============================================================================
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -584,11 +108,11 @@ export default function DocumentationPage() {
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{activeTab === 'overview' && renderOverview()}
|
||||
{activeTab === 'architecture' && renderArchitecture()}
|
||||
{activeTab === 'auditor' && renderAuditorInfo()}
|
||||
{activeTab === 'rules' && renderRulesTab()}
|
||||
{activeTab === 'legal-corpus' && renderLegalCorpus()}
|
||||
{activeTab === 'overview' && <OverviewTab rules={rules} patterns={patterns} controls={controls} />}
|
||||
{activeTab === 'architecture' && <ArchitectureTab />}
|
||||
{activeTab === 'auditor' && <AuditorTab />}
|
||||
{activeTab === 'rules' && <RulesTab rules={rules} policyVersion={policyVersion} loading={loading} />}
|
||||
{activeTab === 'legal-corpus' && <LegalCorpusTab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function AgentSessionsPage() {
|
||||
return (
|
||||
<div className="p-8 max-w-5xl">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<Link href="/sdk/agents" className="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Agent-Sessions</h1>
|
||||
<p className="text-gray-500 mt-1">Chat-Verlaeufe und Session-Management</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-12 text-center">
|
||||
<svg className="w-20 h-20 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<h2 className="text-xl font-medium text-gray-900 mb-2">Sessions-Tracking</h2>
|
||||
<p className="text-gray-500 max-w-md mx-auto">
|
||||
Das Session-Tracking fuer Compliance-Agenten wird in einer zukuenftigen Version implementiert.
|
||||
Hier werden Chat-Verlaeufe, Antwortqualitaet und Nutzer-Feedback angezeigt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function AgentStatisticsPage() {
|
||||
return (
|
||||
<div className="p-8 max-w-5xl">
|
||||
<div className="flex items-center gap-4 mb-8">
|
||||
<Link href="/sdk/agents" className="text-gray-400 hover:text-gray-600 transition-colors">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Agent-Statistiken</h1>
|
||||
<p className="text-gray-500 mt-1">Performance-Metriken und Nutzungsanalysen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-12 text-center">
|
||||
<svg className="w-20 h-20 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
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>
|
||||
<h2 className="text-xl font-medium text-gray-900 mb-2">Agent-Statistiken</h2>
|
||||
<p className="text-gray-500 max-w-md mx-auto">
|
||||
Detaillierte Statistiken wie Antwortzeiten, Erfolgsraten, haeufigste Themen und
|
||||
RAG-Trefferquoten werden in einer zukuenftigen Version implementiert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
116
admin-compliance/app/sdk/ai-act/_components/AISystemCard.tsx
Normal file
116
admin-compliance/app/sdk/ai-act/_components/AISystemCard.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import { AISystem } from './types'
|
||||
|
||||
interface AISystemCardProps {
|
||||
system: AISystem
|
||||
onAssess: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
assessing: boolean
|
||||
}
|
||||
|
||||
const classificationColors: Record<AISystem['classification'], string> = {
|
||||
prohibited: 'bg-red-100 text-red-700 border-red-200',
|
||||
'high-risk': 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
'limited-risk': 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||
'minimal-risk': 'bg-green-100 text-green-700 border-green-200',
|
||||
unclassified: 'bg-gray-100 text-gray-500 border-gray-200',
|
||||
}
|
||||
|
||||
const classificationLabels: Record<AISystem['classification'], string> = {
|
||||
prohibited: 'Verboten',
|
||||
'high-risk': 'Hochrisiko',
|
||||
'limited-risk': 'Begrenztes Risiko',
|
||||
'minimal-risk': 'Minimales Risiko',
|
||||
unclassified: 'Nicht klassifiziert',
|
||||
}
|
||||
|
||||
const statusColors: Record<AISystem['status'], string> = {
|
||||
draft: 'bg-gray-100 text-gray-500',
|
||||
classified: 'bg-blue-100 text-blue-700',
|
||||
compliant: 'bg-green-100 text-green-700',
|
||||
'non-compliant': 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
const statusLabels: Record<AISystem['status'], string> = {
|
||||
draft: 'Entwurf',
|
||||
classified: 'Klassifiziert',
|
||||
compliant: 'Konform',
|
||||
'non-compliant': 'Nicht konform',
|
||||
}
|
||||
|
||||
export function AISystemCard({ system, onAssess, onEdit, onDelete, assessing }: AISystemCardProps) {
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
system.classification === 'high-risk' ? 'border-orange-200' :
|
||||
system.classification === 'prohibited' ? 'border-red-200' : 'border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${classificationColors[system.classification]}`}>
|
||||
{classificationLabels[system.classification]}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[system.status]}`}>
|
||||
{statusLabels[system.status]}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{system.name}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{system.description}</p>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
<span>Sektor: {system.sector}</span>
|
||||
{system.assessmentDate && (
|
||||
<span className="ml-4">Klassifiziert: {system.assessmentDate.toLocaleDateString('de-DE')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{system.obligations.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Pflichten nach AI Act:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{system.obligations.map(obl => (
|
||||
<span key={obl} className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded">
|
||||
{obl}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{system.assessmentResult && (
|
||||
<div className="mt-3 p-3 bg-blue-50 rounded-lg">
|
||||
<p className="text-xs font-medium text-blue-700">KI-Risikobewertung abgeschlossen</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
onClick={onAssess}
|
||||
disabled={assessing}
|
||||
className="flex-1 px-4 py-2 text-sm bg-purple-50 text-purple-700 rounded-lg hover:bg-purple-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{assessing ? (
|
||||
<span className="flex items-center justify-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>
|
||||
Bewertung laeuft...
|
||||
</span>
|
||||
) : (
|
||||
system.classification === 'unclassified' ? 'Klassifizierung starten' : 'Risikobewertung starten'
|
||||
)}
|
||||
</button>
|
||||
<button onClick={onEdit} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button onClick={onDelete} className="px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
102
admin-compliance/app/sdk/ai-act/_components/AddSystemForm.tsx
Normal file
102
admin-compliance/app/sdk/ai-act/_components/AddSystemForm.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { AISystem } from './types'
|
||||
|
||||
interface AddSystemFormProps {
|
||||
onSubmit: (system: Omit<AISystem, 'id' | 'assessmentDate' | 'assessmentResult'>) => void
|
||||
onCancel: () => void
|
||||
initialData?: AISystem | null
|
||||
}
|
||||
|
||||
export function AddSystemForm({ onSubmit, onCancel, initialData }: AddSystemFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: initialData?.name || '',
|
||||
description: initialData?.description || '',
|
||||
purpose: initialData?.purpose || '',
|
||||
sector: initialData?.sector || '',
|
||||
classification: (initialData?.classification || 'unclassified') as AISystem['classification'],
|
||||
status: (initialData?.status || 'draft') as AISystem['status'],
|
||||
obligations: initialData?.obligations || [] as string[],
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
{initialData ? 'KI-System bearbeiten' : 'Neues KI-System registrieren'}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="z.B. Dokumenten-Scanner"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={e => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Beschreiben Sie das KI-System..."
|
||||
rows={2}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Einsatzzweck</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.purpose}
|
||||
onChange={e => setFormData({ ...formData, purpose: e.target.value })}
|
||||
placeholder="z.B. Texterkennung"
|
||||
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">Sektor</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.sector}
|
||||
onChange={e => setFormData({ ...formData, sector: e.target.value })}
|
||||
placeholder="z.B. Verwaltung"
|
||||
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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Vorklassifizierung</label>
|
||||
<select
|
||||
value={formData.classification}
|
||||
onChange={e => setFormData({ ...formData, classification: e.target.value as AISystem['classification'] })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="unclassified">Noch nicht klassifiziert</option>
|
||||
<option value="minimal-risk">Minimales Risiko</option>
|
||||
<option value="limited-risk">Begrenztes Risiko</option>
|
||||
<option value="high-risk">Hochrisiko</option>
|
||||
<option value="prohibited">Verboten</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-end gap-3">
|
||||
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSubmit(formData)}
|
||||
disabled={!formData.name}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
formData.name ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{initialData ? 'Speichern' : 'Registrieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
export function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="h-5 w-24 bg-gray-200 rounded-full" />
|
||||
<div className="h-5 w-20 bg-gray-200 rounded-full" />
|
||||
</div>
|
||||
<div className="h-6 w-3/4 bg-gray-200 rounded mb-2" />
|
||||
<div className="h-4 w-full bg-gray-100 rounded mb-4" />
|
||||
<div className="h-4 w-1/2 bg-gray-100 rounded" />
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-8 flex-1 bg-gray-200 rounded-lg" />
|
||||
<div className="h-8 w-24 bg-gray-200 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
admin-compliance/app/sdk/ai-act/_components/RiskPyramid.tsx
Normal file
39
admin-compliance/app/sdk/ai-act/_components/RiskPyramid.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { AISystem } from './types'
|
||||
|
||||
interface RiskPyramidProps {
|
||||
systems: AISystem[]
|
||||
}
|
||||
|
||||
export function RiskPyramid({ systems }: RiskPyramidProps) {
|
||||
const counts = {
|
||||
prohibited: systems.filter(s => s.classification === 'prohibited').length,
|
||||
'high-risk': systems.filter(s => s.classification === 'high-risk').length,
|
||||
'limited-risk': systems.filter(s => s.classification === 'limited-risk').length,
|
||||
'minimal-risk': systems.filter(s => s.classification === 'minimal-risk').length,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">AI Act Risikopyramide</h3>
|
||||
<div className="flex flex-col items-center space-y-1">
|
||||
<div className="w-24 h-12 bg-red-500 text-white flex items-center justify-center rounded-t-lg text-sm font-medium">
|
||||
Verboten ({counts.prohibited})
|
||||
</div>
|
||||
<div className="w-40 h-12 bg-orange-500 text-white flex items-center justify-center text-sm font-medium">
|
||||
Hochrisiko ({counts['high-risk']})
|
||||
</div>
|
||||
<div className="w-56 h-12 bg-yellow-500 text-white flex items-center justify-center text-sm font-medium">
|
||||
Begrenztes Risiko ({counts['limited-risk']})
|
||||
</div>
|
||||
<div className="w-72 h-12 bg-green-500 text-white flex items-center justify-center rounded-b-lg text-sm font-medium">
|
||||
Minimales Risiko ({counts['minimal-risk']})
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-center text-sm text-gray-500">
|
||||
{systems.filter(s => s.classification === 'unclassified').length} System(e) noch nicht klassifiziert
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
admin-compliance/app/sdk/ai-act/_components/types.ts
Normal file
12
admin-compliance/app/sdk/ai-act/_components/types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export interface AISystem {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
classification: 'prohibited' | 'high-risk' | 'limited-risk' | 'minimal-risk' | 'unclassified'
|
||||
purpose: string
|
||||
sector: string
|
||||
status: 'draft' | 'classified' | 'compliant' | 'non-compliant'
|
||||
obligations: string[]
|
||||
assessmentDate: Date | null
|
||||
assessmentResult: Record<string, unknown> | null
|
||||
}
|
||||
@@ -3,484 +3,14 @@
|
||||
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
|
||||
// =============================================================================
|
||||
|
||||
interface AISystem {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
classification: 'prohibited' | 'high-risk' | 'limited-risk' | 'minimal-risk' | 'unclassified'
|
||||
purpose: string
|
||||
sector: string
|
||||
status: 'draft' | 'classified' | 'compliant' | 'non-compliant'
|
||||
obligations: string[]
|
||||
assessmentDate: Date | null
|
||||
assessmentResult: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
type TabId = 'overview' | 'decision-tree' | 'results'
|
||||
|
||||
// =============================================================================
|
||||
// LOADING SKELETON
|
||||
// =============================================================================
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="h-5 w-24 bg-gray-200 rounded-full" />
|
||||
<div className="h-5 w-20 bg-gray-200 rounded-full" />
|
||||
</div>
|
||||
<div className="h-6 w-3/4 bg-gray-200 rounded mb-2" />
|
||||
<div className="h-4 w-full bg-gray-100 rounded mb-4" />
|
||||
<div className="h-4 w-1/2 bg-gray-100 rounded" />
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-8 flex-1 bg-gray-200 rounded-lg" />
|
||||
<div className="h-8 w-24 bg-gray-200 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function RiskPyramid({ systems }: { systems: AISystem[] }) {
|
||||
const counts = {
|
||||
prohibited: systems.filter(s => s.classification === 'prohibited').length,
|
||||
'high-risk': systems.filter(s => s.classification === 'high-risk').length,
|
||||
'limited-risk': systems.filter(s => s.classification === 'limited-risk').length,
|
||||
'minimal-risk': systems.filter(s => s.classification === 'minimal-risk').length,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">AI Act Risikopyramide</h3>
|
||||
<div className="flex flex-col items-center space-y-1">
|
||||
<div className="w-24 h-12 bg-red-500 text-white flex items-center justify-center rounded-t-lg text-sm font-medium">
|
||||
Verboten ({counts.prohibited})
|
||||
</div>
|
||||
<div className="w-40 h-12 bg-orange-500 text-white flex items-center justify-center text-sm font-medium">
|
||||
Hochrisiko ({counts['high-risk']})
|
||||
</div>
|
||||
<div className="w-56 h-12 bg-yellow-500 text-white flex items-center justify-center text-sm font-medium">
|
||||
Begrenztes Risiko ({counts['limited-risk']})
|
||||
</div>
|
||||
<div className="w-72 h-12 bg-green-500 text-white flex items-center justify-center rounded-b-lg text-sm font-medium">
|
||||
Minimales Risiko ({counts['minimal-risk']})
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-center text-sm text-gray-500">
|
||||
{systems.filter(s => s.classification === 'unclassified').length} System(e) noch nicht klassifiziert
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddSystemForm({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
initialData,
|
||||
}: {
|
||||
onSubmit: (system: Omit<AISystem, 'id' | 'assessmentDate' | 'assessmentResult'>) => void
|
||||
onCancel: () => void
|
||||
initialData?: AISystem | null
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: initialData?.name || '',
|
||||
description: initialData?.description || '',
|
||||
purpose: initialData?.purpose || '',
|
||||
sector: initialData?.sector || '',
|
||||
classification: (initialData?.classification || 'unclassified') as AISystem['classification'],
|
||||
status: (initialData?.status || 'draft') as AISystem['status'],
|
||||
obligations: initialData?.obligations || [] as string[],
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{initialData ? 'KI-System bearbeiten' : 'Neues KI-System registrieren'}</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="z.B. Dokumenten-Scanner"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={e => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Beschreiben Sie das KI-System..."
|
||||
rows={2}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Einsatzzweck</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.purpose}
|
||||
onChange={e => setFormData({ ...formData, purpose: e.target.value })}
|
||||
placeholder="z.B. Texterkennung"
|
||||
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">Sektor</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.sector}
|
||||
onChange={e => setFormData({ ...formData, sector: e.target.value })}
|
||||
placeholder="z.B. Verwaltung"
|
||||
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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Vorklassifizierung</label>
|
||||
<select
|
||||
value={formData.classification}
|
||||
onChange={e => setFormData({ ...formData, classification: e.target.value as AISystem['classification'] })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="unclassified">Noch nicht klassifiziert</option>
|
||||
<option value="minimal-risk">Minimales Risiko</option>
|
||||
<option value="limited-risk">Begrenztes Risiko</option>
|
||||
<option value="high-risk">Hochrisiko</option>
|
||||
<option value="prohibited">Verboten</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-end gap-3">
|
||||
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSubmit(formData)}
|
||||
disabled={!formData.name}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
formData.name ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{initialData ? 'Speichern' : 'Registrieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AISystemCard({
|
||||
system,
|
||||
onAssess,
|
||||
onEdit,
|
||||
onDelete,
|
||||
assessing,
|
||||
}: {
|
||||
system: AISystem
|
||||
onAssess: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
assessing: boolean
|
||||
}) {
|
||||
const classificationColors = {
|
||||
prohibited: 'bg-red-100 text-red-700 border-red-200',
|
||||
'high-risk': 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
'limited-risk': 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||
'minimal-risk': 'bg-green-100 text-green-700 border-green-200',
|
||||
unclassified: 'bg-gray-100 text-gray-500 border-gray-200',
|
||||
}
|
||||
|
||||
const classificationLabels = {
|
||||
prohibited: 'Verboten',
|
||||
'high-risk': 'Hochrisiko',
|
||||
'limited-risk': 'Begrenztes Risiko',
|
||||
'minimal-risk': 'Minimales Risiko',
|
||||
unclassified: 'Nicht klassifiziert',
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
draft: 'bg-gray-100 text-gray-500',
|
||||
classified: 'bg-blue-100 text-blue-700',
|
||||
compliant: 'bg-green-100 text-green-700',
|
||||
'non-compliant': 'bg-red-100 text-red-700',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
draft: 'Entwurf',
|
||||
classified: 'Klassifiziert',
|
||||
compliant: 'Konform',
|
||||
'non-compliant': 'Nicht konform',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
system.classification === 'high-risk' ? 'border-orange-200' :
|
||||
system.classification === 'prohibited' ? 'border-red-200' : 'border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${classificationColors[system.classification]}`}>
|
||||
{classificationLabels[system.classification]}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[system.status]}`}>
|
||||
{statusLabels[system.status]}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{system.name}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{system.description}</p>
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
<span>Sektor: {system.sector}</span>
|
||||
{system.assessmentDate && (
|
||||
<span className="ml-4">Klassifiziert: {system.assessmentDate.toLocaleDateString('de-DE')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{system.obligations.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Pflichten nach AI Act:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{system.obligations.map(obl => (
|
||||
<span key={obl} className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded">
|
||||
{obl}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{system.assessmentResult && (
|
||||
<div className="mt-3 p-3 bg-blue-50 rounded-lg">
|
||||
<p className="text-xs font-medium text-blue-700">KI-Risikobewertung abgeschlossen</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
onClick={onAssess}
|
||||
disabled={assessing}
|
||||
className="flex-1 px-4 py-2 text-sm bg-purple-50 text-purple-700 rounded-lg hover:bg-purple-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{assessing ? (
|
||||
<span className="flex items-center justify-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>
|
||||
Bewertung laeuft...
|
||||
</span>
|
||||
) : (
|
||||
system.classification === 'unclassified' ? 'Klassifizierung starten' : 'Risikobewertung starten'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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
|
||||
// =============================================================================
|
||||
import { AISystem } from './_components/types'
|
||||
import { LoadingSkeleton } from './_components/LoadingSkeleton'
|
||||
import { RiskPyramid } from './_components/RiskPyramid'
|
||||
import { AddSystemForm } from './_components/AddSystemForm'
|
||||
import { AISystemCard } from './_components/AISystemCard'
|
||||
|
||||
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)
|
||||
@@ -489,7 +19,6 @@ export default function AIActPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Fetch systems from backend on mount
|
||||
useEffect(() => {
|
||||
const fetchSystems = async () => {
|
||||
setLoading(true)
|
||||
@@ -528,29 +57,21 @@ export default function AIActPage() {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
purpose: data.purpose,
|
||||
sector: data.sector,
|
||||
classification: data.classification,
|
||||
status: data.status,
|
||||
obligations: data.obligations,
|
||||
name: data.name, description: data.description, purpose: data.purpose,
|
||||
sector: data.sector, classification: data.classification,
|
||||
status: data.status, obligations: data.obligations,
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
const updated = await res.json()
|
||||
setSystems(prev => prev.map(s =>
|
||||
s.id === editingSystem.id
|
||||
? { ...s, ...data, id: updated.id || editingSystem.id }
|
||||
: s
|
||||
s.id === editingSystem.id ? { ...s, ...data, id: updated.id || editingSystem.id } : s
|
||||
))
|
||||
} else {
|
||||
setError('Speichern fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
setSystems(prev => prev.map(s =>
|
||||
s.id === editingSystem.id ? { ...s, ...data } : s
|
||||
))
|
||||
setSystems(prev => prev.map(s => s.id === editingSystem.id ? { ...s, ...data } : s))
|
||||
}
|
||||
setEditingSystem(null)
|
||||
} else {
|
||||
@@ -559,20 +80,15 @@ export default function AIActPage() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
purpose: data.purpose,
|
||||
sector: data.sector,
|
||||
classification: data.classification,
|
||||
status: data.status,
|
||||
obligations: data.obligations,
|
||||
name: data.name, description: data.description, purpose: data.purpose,
|
||||
sector: data.sector, classification: data.classification,
|
||||
status: data.status, obligations: data.obligations,
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
const created = await res.json()
|
||||
const newSystem: AISystem = {
|
||||
...data,
|
||||
id: created.id,
|
||||
...data, id: created.id,
|
||||
assessmentDate: created.assessment_date ? new Date(created.assessment_date) : null,
|
||||
assessmentResult: created.assessment_result || null,
|
||||
}
|
||||
@@ -582,8 +98,7 @@ export default function AIActPage() {
|
||||
}
|
||||
} catch {
|
||||
const newSystem: AISystem = {
|
||||
...data,
|
||||
id: `ai-${Date.now()}`,
|
||||
...data, id: `ai-${Date.now()}`,
|
||||
assessmentDate: data.classification !== 'unclassified' ? new Date() : null,
|
||||
assessmentResult: null,
|
||||
}
|
||||
@@ -615,16 +130,13 @@ export default function AIActPage() {
|
||||
const handleAssess = async (systemId: string) => {
|
||||
setAssessingId(systemId)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/ai/systems/${systemId}/assess`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const result = await res.json()
|
||||
|
||||
setSystems(prev => prev.map(s =>
|
||||
s.id === systemId
|
||||
? {
|
||||
@@ -655,12 +167,10 @@ export default function AIActPage() {
|
||||
const highRiskCount = systems.filter(s => s.classification === 'high-risk').length
|
||||
const compliantCount = systems.filter(s => s.status === 'compliant').length
|
||||
const unclassifiedCount = systems.filter(s => s.classification === 'unclassified').length
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['ai-act']
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header */}
|
||||
<StepHeader
|
||||
stepId="ai-act"
|
||||
title={stepInfo.title}
|
||||
@@ -668,38 +178,17 @@ export default function AIActPage() {
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
{activeTab === 'overview' && (
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
KI-System registrieren
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
KI-System registrieren
|
||||
</button>
|
||||
</StepHeader>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg w-fit">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-white text-purple-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
@@ -707,105 +196,82 @@ export default function AIActPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Overview */}
|
||||
{activeTab === 'overview' && (
|
||||
<>
|
||||
{/* Add/Edit System Form */}
|
||||
{showAddForm && (
|
||||
<AddSystemForm
|
||||
onSubmit={handleAddSystem}
|
||||
onCancel={() => { setShowAddForm(false); setEditingSystem(null) }}
|
||||
initialData={editingSystem}
|
||||
{showAddForm && (
|
||||
<AddSystemForm
|
||||
onSubmit={handleAddSystem}
|
||||
onCancel={() => { setShowAddForm(false); setEditingSystem(null) }}
|
||||
initialData={editingSystem}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
<RiskPyramid systems={systems} />
|
||||
|
||||
<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 && <LoadingSkeleton />}
|
||||
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{filteredSystems.map(system => (
|
||||
<AISystemCard
|
||||
key={system.id}
|
||||
system={system}
|
||||
onAssess={() => handleAssess(system.id)}
|
||||
onEdit={() => handleEdit(system)}
|
||||
onDelete={() => handleDelete(system.id)}
|
||||
assessing={assessingId === system.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">KI-Systeme gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{systems.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||
<div className="text-sm text-orange-600">Hochrisiko</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{highRiskCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Konform</div>
|
||||
<div className="text-3xl font-bold text-green-600">{compliantCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Nicht klassifiziert</div>
|
||||
<div className="text-3xl font-bold text-gray-500">{unclassifiedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Pyramid */}
|
||||
<RiskPyramid systems={systems} />
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'high-risk', 'limited-risk', 'minimal-risk', 'unclassified', 'compliant', 'non-compliant'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'high-risk' ? 'Hochrisiko' :
|
||||
f === 'limited-risk' ? 'Begrenztes Risiko' :
|
||||
f === 'minimal-risk' ? 'Minimales Risiko' :
|
||||
f === 'unclassified' ? 'Nicht klassifiziert' :
|
||||
f === 'compliant' ? 'Konform' : 'Nicht konform'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && <LoadingSkeleton />}
|
||||
|
||||
{/* AI Systems List */}
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{filteredSystems.map(system => (
|
||||
<AISystemCard
|
||||
key={system.id}
|
||||
system={system}
|
||||
onAssess={() => handleAssess(system.id)}
|
||||
onEdit={() => handleEdit(system)}
|
||||
onDelete={() => handleDelete(system.id)}
|
||||
assessing={assessingId === system.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filteredSystems.length === 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine KI-Systeme gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder registrieren Sie ein neues KI-System.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Decision Tree */}
|
||||
{activeTab === 'decision-tree' && (
|
||||
<DecisionTreeWizard />
|
||||
)}
|
||||
|
||||
{/* Tab: Results */}
|
||||
{activeTab === 'results' && (
|
||||
<SavedResultsTab />
|
||||
{!loading && filteredSystems.length === 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine KI-Systeme gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder registrieren Sie ein neues KI-System.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,491 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
interface Registration {
|
||||
id: string
|
||||
system_name: string
|
||||
system_version: string
|
||||
risk_classification: string
|
||||
gpai_classification: string
|
||||
registration_status: string
|
||||
eu_database_id: string
|
||||
provider_name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||
draft: { bg: 'bg-gray-100', text: 'text-gray-700', label: 'Entwurf' },
|
||||
ready: { bg: 'bg-blue-100', text: 'text-blue-700', label: 'Bereit' },
|
||||
submitted: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Eingereicht' },
|
||||
registered: { bg: 'bg-green-100', text: 'text-green-700', label: 'Registriert' },
|
||||
update_required: { bg: 'bg-orange-100', text: 'text-orange-700', label: 'Update noetig' },
|
||||
withdrawn: { bg: 'bg-red-100', text: 'text-red-700', label: 'Zurueckgezogen' },
|
||||
}
|
||||
|
||||
const RISK_STYLES: Record<string, { bg: string; text: string }> = {
|
||||
high_risk: { bg: 'bg-red-100', text: 'text-red-700' },
|
||||
limited_risk: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
|
||||
minimal_risk: { bg: 'bg-green-100', text: 'text-green-700' },
|
||||
not_classified: { bg: 'bg-gray-100', text: 'text-gray-500' },
|
||||
}
|
||||
|
||||
const INITIAL_FORM = {
|
||||
system_name: '',
|
||||
system_version: '1.0',
|
||||
system_description: '',
|
||||
intended_purpose: '',
|
||||
provider_name: '',
|
||||
provider_legal_form: '',
|
||||
provider_address: '',
|
||||
provider_country: 'DE',
|
||||
eu_representative_name: '',
|
||||
eu_representative_contact: '',
|
||||
risk_classification: 'not_classified',
|
||||
annex_iii_category: '',
|
||||
gpai_classification: 'none',
|
||||
conformity_assessment_type: 'internal',
|
||||
notified_body_name: '',
|
||||
notified_body_id: '',
|
||||
ce_marking: false,
|
||||
training_data_summary: '',
|
||||
}
|
||||
|
||||
export default function AIRegistrationPage() {
|
||||
const [registrations, setRegistrations] = useState<Registration[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showWizard, setShowWizard] = useState(false)
|
||||
const [wizardStep, setWizardStep] = useState(1)
|
||||
const [form, setForm] = useState({ ...INITIAL_FORM })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => { loadRegistrations() }, [])
|
||||
|
||||
async function loadRegistrations() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const resp = await fetch('/api/sdk/v1/ai-registration')
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
setRegistrations(data.registrations || [])
|
||||
}
|
||||
} catch {
|
||||
setError('Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const resp = await fetch('/api/sdk/v1/ai-registration', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
if (resp.ok) {
|
||||
setShowWizard(false)
|
||||
setForm({ ...INITIAL_FORM })
|
||||
setWizardStep(1)
|
||||
loadRegistrations()
|
||||
} else {
|
||||
const data = await resp.json()
|
||||
setError(data.error || 'Fehler beim Erstellen')
|
||||
}
|
||||
} catch {
|
||||
setError('Netzwerkfehler')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/sdk/v1/ai-registration/${id}`)
|
||||
if (resp.ok) {
|
||||
const reg = await resp.json()
|
||||
// Build export JSON client-side
|
||||
const exportData = {
|
||||
schema_version: '1.0',
|
||||
submission_type: 'ai_system_registration',
|
||||
regulation: 'EU AI Act (EU) 2024/1689',
|
||||
article: 'Art. 49',
|
||||
provider: { name: reg.provider_name, address: reg.provider_address, country: reg.provider_country },
|
||||
system: { name: reg.system_name, version: reg.system_version, description: reg.system_description, purpose: reg.intended_purpose },
|
||||
classification: { risk_level: reg.risk_classification, annex_iii: reg.annex_iii_category, gpai: reg.gpai_classification },
|
||||
conformity: { type: reg.conformity_assessment_type, ce_marking: reg.ce_marking },
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `eu_ai_registration_${reg.system_name.replace(/\s+/g, '_')}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
} catch {
|
||||
setError('Export fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(id: string, status: string) {
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/ai-registration/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
loadRegistrations()
|
||||
} catch {
|
||||
setError('Status-Aenderung fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
const updateForm = (updates: Partial<typeof form>) => setForm(prev => ({ ...prev, ...updates }))
|
||||
|
||||
const STEPS = [
|
||||
{ id: 1, title: 'Anbieter', desc: 'Unternehmensangaben' },
|
||||
{ id: 2, title: 'System', desc: 'KI-System Details' },
|
||||
{ id: 3, title: 'Klassifikation', desc: 'Risikoeinstufung' },
|
||||
{ id: 4, title: 'Konformitaet', desc: 'CE & Notified Body' },
|
||||
{ id: 5, title: 'Trainingsdaten', desc: 'Datenzusammenfassung' },
|
||||
{ id: 6, title: 'Pruefung', desc: 'Zusammenfassung & Export' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">EU AI Database Registrierung</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Art. 49 KI-Verordnung (EU) 2024/1689 — Registrierung von Hochrisiko-KI-Systemen</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowWizard(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
+ Neue Registrierung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-2 underline">Schliessen</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||
{['draft', 'ready', 'submitted', 'registered'].map(status => {
|
||||
const count = registrations.filter(r => r.registration_status === status).length
|
||||
const style = STATUS_STYLES[status]
|
||||
return (
|
||||
<div key={status} className={`p-4 rounded-xl border ${style.bg}`}>
|
||||
<div className={`text-2xl font-bold ${style.text}`}>{count}</div>
|
||||
<div className="text-sm text-gray-600">{style.label}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Registrations List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Lade...</div>
|
||||
) : registrations.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<p className="text-lg mb-2">Noch keine Registrierungen</p>
|
||||
<p className="text-sm">Erstelle eine neue Registrierung fuer dein Hochrisiko-KI-System.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{registrations.map(reg => {
|
||||
const status = STATUS_STYLES[reg.registration_status] || STATUS_STYLES.draft
|
||||
const risk = RISK_STYLES[reg.risk_classification] || RISK_STYLES.not_classified
|
||||
return (
|
||||
<div key={reg.id} className="bg-white rounded-xl border border-gray-200 p-6 hover:border-purple-300 transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{reg.system_name}</h3>
|
||||
<span className="text-sm text-gray-400">v{reg.system_version}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${status.bg} ${status.text}`}>{status.label}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${risk.bg} ${risk.text}`}>{reg.risk_classification.replace('_', ' ')}</span>
|
||||
{reg.gpai_classification !== 'none' && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700">GPAI: {reg.gpai_classification}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{reg.provider_name && <span>{reg.provider_name} · </span>}
|
||||
{reg.eu_database_id && <span>EU-ID: {reg.eu_database_id} · </span>}
|
||||
<span>{new Date(reg.created_at).toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => handleExport(reg.id)} className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
JSON Export
|
||||
</button>
|
||||
{reg.registration_status === 'draft' && (
|
||||
<button onClick={() => handleStatusChange(reg.id, 'ready')} className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
Bereit markieren
|
||||
</button>
|
||||
)}
|
||||
{reg.registration_status === 'ready' && (
|
||||
<button onClick={() => handleStatusChange(reg.id, 'submitted')} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700">
|
||||
Als eingereicht markieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wizard Modal */}
|
||||
{showWizard && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900">Neue EU AI Registrierung</h2>
|
||||
<button onClick={() => { setShowWizard(false); setWizardStep(1) }} className="text-gray-400 hover:text-gray-600 text-2xl">×</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>
|
||||
)
|
||||
}
|
||||
164
admin-compliance/app/sdk/architecture/_components/ArchCanvas.tsx
Normal file
164
admin-compliance/app/sdk/architecture/_components/ArchCanvas.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import ReactFlow, {
|
||||
Node,
|
||||
Controls,
|
||||
Background,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
BackgroundVariant,
|
||||
Panel,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
import { ARCH_SERVICES, LAYERS, type ArchService } from '../architecture-data'
|
||||
import { LAYER_ORDER, type LayerFilter } from '../_layout'
|
||||
import { useArchGraph } from '../_hooks/useArchGraph'
|
||||
import DetailPanel from './DetailPanel'
|
||||
|
||||
export default function ArchCanvas({
|
||||
layerFilter,
|
||||
showDb,
|
||||
showRag,
|
||||
showApis,
|
||||
selectedService,
|
||||
setSelectedService,
|
||||
}: {
|
||||
layerFilter: LayerFilter
|
||||
showDb: boolean
|
||||
showRag: boolean
|
||||
showApis: boolean
|
||||
selectedService: ArchService | null
|
||||
setSelectedService: React.Dispatch<React.SetStateAction<ArchService | null>>
|
||||
}) {
|
||||
const { nodes: initialNodes, edges: initialEdges } = useArchGraph({
|
||||
layerFilter,
|
||||
showDb,
|
||||
showRag,
|
||||
showApis,
|
||||
selectedService,
|
||||
})
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([])
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(initialNodes)
|
||||
setEdges(initialEdges)
|
||||
}, [initialNodes, initialEdges, setNodes, setEdges])
|
||||
|
||||
const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
|
||||
const service = ARCH_SERVICES.find(s => s.id === node.id)
|
||||
if (service) {
|
||||
setSelectedService(prev => (prev?.id === service.id ? null : service))
|
||||
}
|
||||
}, [setSelectedService])
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
setSelectedService(null)
|
||||
}, [setSelectedService])
|
||||
|
||||
return (
|
||||
<div className="flex bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden" style={{ height: '700px' }}>
|
||||
{/* Canvas */}
|
||||
<div className="flex-1 relative">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.15 }}
|
||||
attributionPosition="bottom-left"
|
||||
>
|
||||
<Controls />
|
||||
<MiniMap
|
||||
nodeColor={node => {
|
||||
if (node.id.startsWith('db-')) return '#94a3b8'
|
||||
if (node.id.startsWith('rag-')) return '#22c55e'
|
||||
if (node.id.startsWith('api-')) return '#c4b5fd'
|
||||
const svc = ARCH_SERVICES.find(s => s.id === node.id)
|
||||
return svc ? LAYERS[svc.layer].colorBorder : '#94a3b8'
|
||||
}}
|
||||
maskColor="rgba(0,0,0,0.08)"
|
||||
/>
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
|
||||
{/* Legende */}
|
||||
<Panel
|
||||
position="bottom-right"
|
||||
className="bg-white/95 p-3 rounded-lg shadow-lg text-xs"
|
||||
>
|
||||
<div className="font-medium text-slate-700 mb-2">Legende</div>
|
||||
<div className="space-y-1">
|
||||
{LAYER_ORDER.map(layerId => {
|
||||
const layer = LAYERS[layerId]
|
||||
return (
|
||||
<div key={layerId} className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-3 h-3 rounded"
|
||||
style={{
|
||||
background: layer.colorBg,
|
||||
border: `1px solid ${layer.colorBorder}`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-slate-600">{layer.name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="border-t border-slate-200 my-1.5 pt-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-slate-200 border border-slate-400" />
|
||||
<span className="text-slate-500">DB-Tabelle</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="w-3 h-3 rounded bg-green-100 border border-green-500" />
|
||||
<span className="text-slate-500">RAG-Collection</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="w-3 h-3 rounded bg-violet-100 border border-violet-400" />
|
||||
<span className="text-slate-500">API-Endpunkt</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Swim-Lane Labels */}
|
||||
{layerFilter === 'alle' && (
|
||||
<Panel position="top-left" className="pointer-events-none">
|
||||
<div className="space-y-1">
|
||||
{LAYER_ORDER.map(layerId => {
|
||||
const layer = LAYERS[layerId]
|
||||
return (
|
||||
<div
|
||||
key={layerId}
|
||||
className="px-3 py-1 rounded text-xs font-medium opacity-50"
|
||||
style={{
|
||||
background: layer.colorBg,
|
||||
color: layer.colorText,
|
||||
border: `1px solid ${layer.colorBorder}`,
|
||||
}}
|
||||
>
|
||||
{layer.name}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* Detail Panel */}
|
||||
{selectedService && (
|
||||
<DetailPanel
|
||||
service={selectedService}
|
||||
onClose={() => setSelectedService(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
export default function ArchHeader({
|
||||
stats,
|
||||
}: {
|
||||
stats: {
|
||||
services: number
|
||||
dbTables: number
|
||||
ragCollections: number
|
||||
edges: number
|
||||
}
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-slate-700 flex items-center justify-center text-white">
|
||||
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">
|
||||
Architektur-Uebersicht
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
{stats.services} Services | {stats.dbTables} DB-Tabellen | {stats.ragCollections} RAG-Collections | {stats.edges} Verbindungen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
'use client'
|
||||
|
||||
import { ARCH_SERVICES, LAYERS, type ArchService } from '../architecture-data'
|
||||
|
||||
export default function DetailPanel({
|
||||
service,
|
||||
onClose,
|
||||
}: {
|
||||
service: ArchService
|
||||
onClose: () => void
|
||||
}) {
|
||||
const layer = LAYERS[service.layer]
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-white border-l border-slate-200 overflow-y-auto">
|
||||
<div className="sticky top-0 bg-white z-10 border-b border-slate-200">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<h3 className="font-bold text-slate-900">{service.name}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-slate-600 text-lg leading-none"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-4 pb-3 flex items-center gap-2">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={{ background: layer.colorBg, color: layer.colorText }}
|
||||
>
|
||||
{layer.name}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">{service.tech}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Beschreibung */}
|
||||
<p className="text-sm text-slate-700 leading-relaxed">{service.description}</p>
|
||||
<p className="text-xs text-slate-500 leading-relaxed mt-1">{service.descriptionLong}</p>
|
||||
|
||||
{/* Tech + Port + Container */}
|
||||
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">Tech</span>
|
||||
<span className="font-medium text-slate-800">{service.tech}</span>
|
||||
</div>
|
||||
{service.port && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">Port</span>
|
||||
<code className="text-xs bg-slate-200 px-1.5 py-0.5 rounded">{service.port}</code>
|
||||
</div>
|
||||
)}
|
||||
{service.url && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">URL</span>
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:underline truncate max-w-[180px]"
|
||||
>
|
||||
{service.url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">Container</span>
|
||||
<code className="text-xs bg-slate-200 px-1.5 py-0.5 rounded truncate max-w-[180px]">{service.container}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DB Tables */}
|
||||
{service.dbTables.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
|
||||
DB-Tabellen ({service.dbTables.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.dbTables.map(table => (
|
||||
<div
|
||||
key={table}
|
||||
className="text-sm bg-slate-100 rounded px-2 py-1"
|
||||
>
|
||||
<code className="text-slate-700 text-xs">{table}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RAG Collections */}
|
||||
{service.ragCollections.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
|
||||
RAG-Collections ({service.ragCollections.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.ragCollections.map(rag => (
|
||||
<div
|
||||
key={rag}
|
||||
className="text-sm bg-green-50 rounded px-2 py-1"
|
||||
>
|
||||
<code className="text-green-700 text-xs">{rag}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Endpoints */}
|
||||
{service.apiEndpoints.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
|
||||
API-Endpunkte ({service.apiEndpoints.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.apiEndpoints.map(ep => (
|
||||
<div
|
||||
key={ep}
|
||||
className="text-sm bg-violet-50 rounded px-2 py-1"
|
||||
>
|
||||
<code className="text-violet-700 text-xs">{ep}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dependencies */}
|
||||
{service.dependsOn.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
|
||||
Abhaengigkeiten
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.dependsOn.map(depId => {
|
||||
const dep = ARCH_SERVICES.find(s => s.id === depId)
|
||||
return (
|
||||
<div
|
||||
key={depId}
|
||||
className="text-sm text-slate-600 bg-slate-50 rounded px-2 py-1"
|
||||
>
|
||||
{dep?.name || depId}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Open URL */}
|
||||
{service.url && (
|
||||
<button
|
||||
onClick={() => window.open(service.url!, '_blank')}
|
||||
className="w-full mt-2 px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Service oeffnen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { ARCH_SERVICES, LAYERS, type ArchService } from '../architecture-data'
|
||||
import { type LayerFilter } from '../_layout'
|
||||
|
||||
export default function ServiceTable({
|
||||
layerFilter,
|
||||
expandedServices,
|
||||
onToggleExpanded,
|
||||
onMarkInGraph,
|
||||
}: {
|
||||
layerFilter: LayerFilter
|
||||
expandedServices: Set<string>
|
||||
onToggleExpanded: (id: string) => void
|
||||
onMarkInGraph: (service: ArchService) => void
|
||||
}) {
|
||||
const filtered = ARCH_SERVICES.filter(
|
||||
s => layerFilter === 'alle' || s.layer === layerFilter
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 border-b">
|
||||
<h3 className="font-medium text-slate-700">
|
||||
Alle Services ({
|
||||
layerFilter === 'alle'
|
||||
? ARCH_SERVICES.length
|
||||
: `${ARCH_SERVICES.filter(s => s.layer === layerFilter).length} / ${ARCH_SERVICES.length}`
|
||||
})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="divide-y max-h-[600px] overflow-y-auto">
|
||||
{filtered.map(service => {
|
||||
const layer = LAYERS[service.layer]
|
||||
const isExpanded = expandedServices.has(service.id)
|
||||
|
||||
return (
|
||||
<div key={service.id}>
|
||||
{/* Row Header */}
|
||||
<button
|
||||
onClick={() => onToggleExpanded(service.id)}
|
||||
className={`w-full flex items-center gap-3 p-3 text-left transition-colors ${
|
||||
isExpanded
|
||||
? 'bg-purple-50'
|
||||
: 'hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{/* Chevron */}
|
||||
<svg
|
||||
className={`w-4 h-4 text-slate-400 shrink-0 transition-transform duration-200 ${
|
||||
isExpanded ? 'rotate-90' : ''
|
||||
}`}
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[10px] font-bold shrink-0"
|
||||
style={{ background: layer.colorBg, color: layer.colorText }}
|
||||
>
|
||||
{service.port ? `:${service.port}` : '--'}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-800 text-sm">
|
||||
{service.name}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 flex items-center gap-2 mt-0.5">
|
||||
<span className="text-slate-400">{service.tech}</span>
|
||||
{service.dbTables.length > 0 && (
|
||||
<span className="text-slate-400">
|
||||
DB: {service.dbTables.length}
|
||||
</span>
|
||||
)}
|
||||
{service.ragCollections.length > 0 && (
|
||||
<span className="text-green-600">
|
||||
RAG: {service.ragCollections.length}
|
||||
</span>
|
||||
)}
|
||||
{service.apiEndpoints.length > 0 && (
|
||||
<span className="text-violet-600">
|
||||
API: {service.apiEndpoints.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<code className="text-[10px] text-slate-400 shrink-0 hidden sm:block">
|
||||
{service.container}
|
||||
</code>
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-[10px] font-medium shrink-0"
|
||||
style={{ background: layer.colorBg, color: layer.colorText }}
|
||||
>
|
||||
{layer.name}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded Detail */}
|
||||
{isExpanded && <ExpandedRow service={service} onMarkInGraph={onMarkInGraph} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExpandedRow({
|
||||
service,
|
||||
onMarkInGraph,
|
||||
}: {
|
||||
service: ArchService
|
||||
onMarkInGraph: (service: ArchService) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 pb-4 pt-1 bg-slate-50/50 border-t border-slate-100">
|
||||
{/* Beschreibung */}
|
||||
<p className="text-sm text-slate-700 leading-relaxed">
|
||||
{service.description}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 leading-relaxed mt-1 mb-3">
|
||||
{service.descriptionLong}
|
||||
</p>
|
||||
|
||||
{/* Info Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-3">
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
|
||||
<div className="text-[10px] font-semibold text-slate-400 uppercase">Tech</div>
|
||||
<div className="text-sm font-medium text-slate-800 mt-0.5">{service.tech}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
|
||||
<div className="text-[10px] font-semibold text-slate-400 uppercase">Port</div>
|
||||
<div className="text-sm font-medium text-slate-800 mt-0.5">
|
||||
{service.port ? `:${service.port}` : 'Intern'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
|
||||
<div className="text-[10px] font-semibold text-slate-400 uppercase">Container</div>
|
||||
<div className="text-xs font-mono text-slate-700 mt-0.5 truncate">{service.container}</div>
|
||||
</div>
|
||||
{service.url && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
|
||||
<div className="text-[10px] font-semibold text-slate-400 uppercase">URL</div>
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:underline mt-0.5 block truncate"
|
||||
>
|
||||
{service.url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{service.dbTables.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3">
|
||||
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
|
||||
DB-Tabellen ({service.dbTables.length})
|
||||
</h4>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{service.dbTables.map(table => (
|
||||
<div key={table} className="bg-slate-50 rounded px-2 py-1">
|
||||
<code className="text-xs text-slate-700">{table}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{service.ragCollections.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3">
|
||||
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
|
||||
RAG-Collections ({service.ragCollections.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.ragCollections.map(rag => (
|
||||
<div key={rag} className="bg-green-50 rounded px-2 py-1">
|
||||
<code className="text-xs text-green-700">{rag}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{service.apiEndpoints.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3">
|
||||
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
|
||||
API-Endpunkte ({service.apiEndpoints.length})
|
||||
</h4>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{service.apiEndpoints.map(ep => (
|
||||
<div key={ep} className="bg-violet-50 rounded px-2 py-1">
|
||||
<code className="text-xs text-violet-700">{ep}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{service.dependsOn.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3">
|
||||
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
|
||||
Abhaengigkeiten ({service.dependsOn.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.dependsOn.map(depId => {
|
||||
const dep = ARCH_SERVICES.find(s => s.id === depId)
|
||||
const depLayer = dep ? LAYERS[dep.layer] : null
|
||||
return (
|
||||
<div key={depId} className="flex items-center gap-2 bg-slate-50 rounded px-2 py-1">
|
||||
{depLayer && (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ background: depLayer.colorBorder }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-slate-700">{dep?.name || depId}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open in Graph + URL */}
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
onMarkInGraph(service)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}}
|
||||
className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-xs font-medium hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
Im Graph markieren
|
||||
</button>
|
||||
{service.url && (
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1.5 bg-purple-600 text-white rounded-lg text-xs font-medium hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Service oeffnen
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import { ARCH_SERVICES, LAYERS } from '../architecture-data'
|
||||
import { LAYER_ORDER, type LayerFilter } from '../_layout'
|
||||
|
||||
export default function Toolbar({
|
||||
layerFilter,
|
||||
showDb,
|
||||
showRag,
|
||||
showApis,
|
||||
onLayerFilter,
|
||||
onToggleDb,
|
||||
onToggleRag,
|
||||
onToggleApis,
|
||||
}: {
|
||||
layerFilter: LayerFilter
|
||||
showDb: boolean
|
||||
showRag: boolean
|
||||
showApis: boolean
|
||||
onLayerFilter: (f: LayerFilter) => void
|
||||
onToggleDb: () => void
|
||||
onToggleRag: () => void
|
||||
onToggleApis: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Layer Filter */}
|
||||
<button
|
||||
onClick={() => onLayerFilter('alle')}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
layerFilter === 'alle'
|
||||
? 'bg-slate-800 text-white'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Alle ({ARCH_SERVICES.length})
|
||||
</button>
|
||||
{LAYER_ORDER.map(layerId => {
|
||||
const layer = LAYERS[layerId]
|
||||
const count = ARCH_SERVICES.filter(s => s.layer === layerId).length
|
||||
return (
|
||||
<button
|
||||
key={layerId}
|
||||
onClick={() => onLayerFilter(layerFilter === layerId ? 'alle' : layerId)}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-all flex items-center gap-1.5"
|
||||
style={{
|
||||
background: layerFilter === layerId ? layer.colorBorder : layer.colorBg,
|
||||
color: layerFilter === layerId ? 'white' : layer.colorText,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ background: layer.colorBorder }}
|
||||
/>
|
||||
{layer.name} ({count})
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-6 bg-slate-200 mx-1" />
|
||||
|
||||
{/* Toggles */}
|
||||
<button
|
||||
onClick={onToggleDb}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
showDb
|
||||
? 'bg-slate-700 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
DB-Tabellen
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleRag}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
showRag
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-green-50 text-green-700 hover:bg-green-100'
|
||||
}`}
|
||||
>
|
||||
RAG-Collections
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleApis}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
showApis
|
||||
? 'bg-violet-600 text-white'
|
||||
: 'bg-violet-50 text-violet-700 hover:bg-violet-100'
|
||||
}`}
|
||||
>
|
||||
API-Endpunkte
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
251
admin-compliance/app/sdk/architecture/_hooks/useArchGraph.tsx
Normal file
251
admin-compliance/app/sdk/architecture/_hooks/useArchGraph.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { type Node, type Edge, MarkerType } from 'reactflow'
|
||||
import {
|
||||
ARCH_SERVICES,
|
||||
ARCH_EDGES,
|
||||
LAYERS,
|
||||
type ArchService,
|
||||
} from '../architecture-data'
|
||||
import {
|
||||
NODE_WIDTH,
|
||||
LANE_Y_START,
|
||||
getServicePosition,
|
||||
type LayerFilter,
|
||||
} from '../_layout'
|
||||
|
||||
export function useArchGraph({
|
||||
layerFilter,
|
||||
showDb,
|
||||
showRag,
|
||||
showApis,
|
||||
selectedService,
|
||||
}: {
|
||||
layerFilter: LayerFilter
|
||||
showDb: boolean
|
||||
showRag: boolean
|
||||
showApis: boolean
|
||||
selectedService: ArchService | null
|
||||
}) {
|
||||
return useMemo(() => {
|
||||
const nodes: Node[] = []
|
||||
const edges: Edge[] = []
|
||||
|
||||
const visibleServices =
|
||||
layerFilter === 'alle'
|
||||
? ARCH_SERVICES
|
||||
: ARCH_SERVICES.filter(s => s.layer === layerFilter)
|
||||
|
||||
const visibleIds = new Set(visibleServices.map(s => s.id))
|
||||
|
||||
// ── Service Nodes ──────────────────────────────────────────────────────
|
||||
visibleServices.forEach(service => {
|
||||
const layer = LAYERS[service.layer]
|
||||
const pos = getServicePosition(service)
|
||||
const isSelected = selectedService?.id === service.id
|
||||
|
||||
nodes.push({
|
||||
id: service.id,
|
||||
type: 'default',
|
||||
position: pos,
|
||||
data: {
|
||||
label: (
|
||||
<div className="text-center px-1">
|
||||
<div className="font-medium text-xs leading-tight">
|
||||
{service.nameShort}
|
||||
</div>
|
||||
<div className="text-[10px] opacity-70 mt-0.5">
|
||||
{service.tech}
|
||||
</div>
|
||||
{service.port && (
|
||||
<div className="text-[9px] opacity-50 mt-0.5">
|
||||
:{service.port}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
style: {
|
||||
background: isSelected ? layer.colorBorder : layer.colorBg,
|
||||
color: isSelected ? 'white' : layer.colorText,
|
||||
border: `2px solid ${layer.colorBorder}`,
|
||||
borderRadius: '10px',
|
||||
padding: '8px 4px',
|
||||
minWidth: `${NODE_WIDTH}px`,
|
||||
maxWidth: `${NODE_WIDTH}px`,
|
||||
cursor: 'pointer',
|
||||
boxShadow: isSelected
|
||||
? `0 0 16px ${layer.colorBorder}`
|
||||
: '0 1px 3px rgba(0,0,0,0.08)',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// ── Connection Edges ───────────────────────────────────────────────────
|
||||
ARCH_EDGES.forEach(archEdge => {
|
||||
if (visibleIds.has(archEdge.source) && visibleIds.has(archEdge.target)) {
|
||||
const isHighlighted =
|
||||
selectedService?.id === archEdge.source ||
|
||||
selectedService?.id === archEdge.target
|
||||
|
||||
edges.push({
|
||||
id: `e-${archEdge.source}-${archEdge.target}`,
|
||||
source: archEdge.source,
|
||||
target: archEdge.target,
|
||||
type: 'smoothstep',
|
||||
animated: isHighlighted,
|
||||
label: archEdge.label,
|
||||
labelStyle: { fontSize: 9, fill: isHighlighted ? '#7c3aed' : '#94a3b8' },
|
||||
style: {
|
||||
stroke: isHighlighted ? '#7c3aed' : '#94a3b8',
|
||||
strokeWidth: isHighlighted ? 2.5 : 1.5,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: isHighlighted ? '#7c3aed' : '#94a3b8',
|
||||
width: 14,
|
||||
height: 14,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ── DB Table Nodes ─────────────────────────────────────────────────────
|
||||
if (showDb) {
|
||||
const dbTablesInUse = new Set<string>()
|
||||
visibleServices.forEach(s => s.dbTables.forEach(t => dbTablesInUse.add(t)))
|
||||
|
||||
let dbIdx = 0
|
||||
dbTablesInUse.forEach(table => {
|
||||
const nodeId = `db-${table}`
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: 'default',
|
||||
position: { x: -250, y: LANE_Y_START + dbIdx * 60 },
|
||||
data: {
|
||||
label: (
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-[10px] leading-tight">{table}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
style: {
|
||||
background: '#f1f5f9',
|
||||
color: '#475569',
|
||||
border: '1px solid #94a3b8',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 6px',
|
||||
fontSize: '10px',
|
||||
minWidth: '140px',
|
||||
},
|
||||
})
|
||||
|
||||
visibleServices
|
||||
.filter(s => s.dbTables.includes(table))
|
||||
.forEach(svc => {
|
||||
edges.push({
|
||||
id: `e-db-${table}-${svc.id}`,
|
||||
source: nodeId,
|
||||
target: svc.id,
|
||||
type: 'straight',
|
||||
style: { stroke: '#94a3b8', strokeWidth: 1, strokeDasharray: '6 3' },
|
||||
})
|
||||
})
|
||||
|
||||
dbIdx++
|
||||
})
|
||||
}
|
||||
|
||||
// ── RAG Collection Nodes ───────────────────────────────────────────────
|
||||
if (showRag) {
|
||||
const ragInUse = new Set<string>()
|
||||
visibleServices.forEach(s => s.ragCollections.forEach(r => ragInUse.add(r)))
|
||||
|
||||
let ragIdx = 0
|
||||
ragInUse.forEach(collection => {
|
||||
const nodeId = `rag-${collection}`
|
||||
const rightX = 1200
|
||||
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: 'default',
|
||||
position: { x: rightX, y: LANE_Y_START + ragIdx * 60 },
|
||||
data: {
|
||||
label: (
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-[10px] leading-tight">
|
||||
{collection.replace('bp_', '')}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
style: {
|
||||
background: '#dcfce7',
|
||||
color: '#166534',
|
||||
border: '1px solid #22c55e',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 6px',
|
||||
fontSize: '10px',
|
||||
minWidth: '130px',
|
||||
},
|
||||
})
|
||||
|
||||
visibleServices
|
||||
.filter(s => s.ragCollections.includes(collection))
|
||||
.forEach(svc => {
|
||||
edges.push({
|
||||
id: `e-rag-${collection}-${svc.id}`,
|
||||
source: nodeId,
|
||||
target: svc.id,
|
||||
type: 'straight',
|
||||
style: { stroke: '#22c55e', strokeWidth: 1, strokeDasharray: '6 3' },
|
||||
})
|
||||
})
|
||||
|
||||
ragIdx++
|
||||
})
|
||||
}
|
||||
|
||||
// ── API Endpoint Nodes ─────────────────────────────────────────────────
|
||||
if (showApis) {
|
||||
visibleServices.forEach(svc => {
|
||||
if (svc.apiEndpoints.length === 0) return
|
||||
const svcPos = getServicePosition(svc)
|
||||
|
||||
svc.apiEndpoints.forEach((ep, idx) => {
|
||||
const nodeId = `api-${svc.id}-${idx}`
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: 'default',
|
||||
position: { x: svcPos.x + NODE_WIDTH + 30, y: svcPos.y + idx * 32 },
|
||||
data: {
|
||||
label: (
|
||||
<div className="text-[9px] font-mono leading-tight truncate">{ep}</div>
|
||||
),
|
||||
},
|
||||
style: {
|
||||
background: '#faf5ff',
|
||||
color: '#7c3aed',
|
||||
border: '1px solid #c4b5fd',
|
||||
borderRadius: '4px',
|
||||
padding: '2px 6px',
|
||||
fontSize: '9px',
|
||||
minWidth: '160px',
|
||||
},
|
||||
})
|
||||
|
||||
edges.push({
|
||||
id: `e-api-${svc.id}-${idx}`,
|
||||
source: svc.id,
|
||||
target: nodeId,
|
||||
type: 'straight',
|
||||
style: { stroke: '#c4b5fd', strokeWidth: 1 },
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return { nodes, edges }
|
||||
}, [layerFilter, showDb, showRag, showApis, selectedService])
|
||||
}
|
||||
30
admin-compliance/app/sdk/architecture/_layout.ts
Normal file
30
admin-compliance/app/sdk/architecture/_layout.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ARCH_SERVICES, LAYERS, type ArchService, type ServiceLayer } from './architecture-data'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export type LayerFilter = 'alle' | ServiceLayer
|
||||
|
||||
// =============================================================================
|
||||
// LAYOUT
|
||||
// =============================================================================
|
||||
|
||||
export const NODE_WIDTH = 180
|
||||
export const NODE_HEIGHT = 70
|
||||
export const NODE_X_SPACING = 220
|
||||
export const LANE_Y_START = 80
|
||||
export const LANE_LABEL_HEIGHT = 40
|
||||
|
||||
export const LAYER_ORDER: ServiceLayer[] = ['frontend', 'backend', 'infrastructure', 'data-sovereignty']
|
||||
|
||||
export function getServicePosition(service: ArchService): { x: number; y: number } {
|
||||
const layer = LAYERS[service.layer]
|
||||
const layerServices = ARCH_SERVICES.filter(s => s.layer === service.layer)
|
||||
const idx = layerServices.findIndex(s => s.id === service.id)
|
||||
|
||||
return {
|
||||
x: 80 + idx * NODE_X_SPACING,
|
||||
y: LANE_Y_START + LANE_LABEL_HEIGHT + layer.y,
|
||||
}
|
||||
}
|
||||
@@ -8,228 +8,19 @@
|
||||
* Analog zum SDK-Flow, aber fuer die Service-Topologie.
|
||||
*/
|
||||
|
||||
import { useCallback, useState, useMemo, useEffect } from 'react'
|
||||
import ReactFlow, {
|
||||
Node,
|
||||
Edge,
|
||||
Controls,
|
||||
Background,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
BackgroundVariant,
|
||||
MarkerType,
|
||||
Panel,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
|
||||
import { useCallback, useState, useMemo } from 'react'
|
||||
import {
|
||||
ARCH_SERVICES,
|
||||
ARCH_EDGES,
|
||||
LAYERS,
|
||||
getAllDbTables,
|
||||
getAllRagCollections,
|
||||
type ArchService,
|
||||
type ServiceLayer,
|
||||
} from './architecture-data'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type LayerFilter = 'alle' | ServiceLayer
|
||||
|
||||
// =============================================================================
|
||||
// LAYOUT
|
||||
// =============================================================================
|
||||
|
||||
const NODE_WIDTH = 180
|
||||
const NODE_HEIGHT = 70
|
||||
const NODE_X_SPACING = 220
|
||||
const LANE_Y_START = 80
|
||||
const LANE_LABEL_HEIGHT = 40
|
||||
|
||||
const LAYER_ORDER: ServiceLayer[] = ['frontend', 'backend', 'infrastructure', 'data-sovereignty']
|
||||
|
||||
function getServicePosition(service: ArchService): { x: number; y: number } {
|
||||
const layer = LAYERS[service.layer]
|
||||
const layerServices = ARCH_SERVICES.filter(s => s.layer === service.layer)
|
||||
const idx = layerServices.findIndex(s => s.id === service.id)
|
||||
|
||||
return {
|
||||
x: 80 + idx * NODE_X_SPACING,
|
||||
y: LANE_Y_START + LANE_LABEL_HEIGHT + layer.y,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DETAIL PANEL
|
||||
// =============================================================================
|
||||
|
||||
function DetailPanel({
|
||||
service,
|
||||
onClose,
|
||||
}: {
|
||||
service: ArchService
|
||||
onClose: () => void
|
||||
}) {
|
||||
const layer = LAYERS[service.layer]
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-white border-l border-slate-200 overflow-y-auto">
|
||||
<div className="sticky top-0 bg-white z-10 border-b border-slate-200">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<h3 className="font-bold text-slate-900">{service.name}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-slate-600 text-lg leading-none"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-4 pb-3 flex items-center gap-2">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={{ background: layer.colorBg, color: layer.colorText }}
|
||||
>
|
||||
{layer.name}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">{service.tech}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Beschreibung */}
|
||||
<p className="text-sm text-slate-700 leading-relaxed">{service.description}</p>
|
||||
<p className="text-xs text-slate-500 leading-relaxed mt-1">{service.descriptionLong}</p>
|
||||
|
||||
{/* Tech + Port + Container */}
|
||||
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">Tech</span>
|
||||
<span className="font-medium text-slate-800">{service.tech}</span>
|
||||
</div>
|
||||
{service.port && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">Port</span>
|
||||
<code className="text-xs bg-slate-200 px-1.5 py-0.5 rounded">{service.port}</code>
|
||||
</div>
|
||||
)}
|
||||
{service.url && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">URL</span>
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:underline truncate max-w-[180px]"
|
||||
>
|
||||
{service.url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">Container</span>
|
||||
<code className="text-xs bg-slate-200 px-1.5 py-0.5 rounded truncate max-w-[180px]">{service.container}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DB Tables */}
|
||||
{service.dbTables.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
|
||||
DB-Tabellen ({service.dbTables.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.dbTables.map(table => (
|
||||
<div
|
||||
key={table}
|
||||
className="text-sm bg-slate-100 rounded px-2 py-1"
|
||||
>
|
||||
<code className="text-slate-700 text-xs">{table}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RAG Collections */}
|
||||
{service.ragCollections.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
|
||||
RAG-Collections ({service.ragCollections.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.ragCollections.map(rag => (
|
||||
<div
|
||||
key={rag}
|
||||
className="text-sm bg-green-50 rounded px-2 py-1"
|
||||
>
|
||||
<code className="text-green-700 text-xs">{rag}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Endpoints */}
|
||||
{service.apiEndpoints.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
|
||||
API-Endpunkte ({service.apiEndpoints.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.apiEndpoints.map(ep => (
|
||||
<div
|
||||
key={ep}
|
||||
className="text-sm bg-violet-50 rounded px-2 py-1"
|
||||
>
|
||||
<code className="text-violet-700 text-xs">{ep}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dependencies */}
|
||||
{service.dependsOn.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
|
||||
Abhaengigkeiten
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.dependsOn.map(depId => {
|
||||
const dep = ARCH_SERVICES.find(s => s.id === depId)
|
||||
return (
|
||||
<div
|
||||
key={depId}
|
||||
className="text-sm text-slate-600 bg-slate-50 rounded px-2 py-1"
|
||||
>
|
||||
{dep?.name || depId}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Open URL */}
|
||||
{service.url && (
|
||||
<button
|
||||
onClick={() => window.open(service.url!, '_blank')}
|
||||
className="w-full mt-2 px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Service oeffnen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
import { type LayerFilter } from './_layout'
|
||||
import ArchHeader from './_components/ArchHeader'
|
||||
import Toolbar from './_components/Toolbar'
|
||||
import ArchCanvas from './_components/ArchCanvas'
|
||||
import ServiceTable from './_components/ServiceTable'
|
||||
|
||||
export default function ArchitecturePage() {
|
||||
const [selectedService, setSelectedService] = useState<ArchService | null>(null)
|
||||
@@ -251,258 +42,6 @@ export default function ArchitecturePage() {
|
||||
const allDbTables = useMemo(() => getAllDbTables(), [])
|
||||
const allRagCollections = useMemo(() => getAllRagCollections(), [])
|
||||
|
||||
// =========================================================================
|
||||
// Build Nodes + Edges
|
||||
// =========================================================================
|
||||
|
||||
const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
|
||||
const nodes: Node[] = []
|
||||
const edges: Edge[] = []
|
||||
|
||||
const visibleServices =
|
||||
layerFilter === 'alle'
|
||||
? ARCH_SERVICES
|
||||
: ARCH_SERVICES.filter(s => s.layer === layerFilter)
|
||||
|
||||
const visibleIds = new Set(visibleServices.map(s => s.id))
|
||||
|
||||
// ── Service Nodes ──────────────────────────────────────────────────────
|
||||
visibleServices.forEach(service => {
|
||||
const layer = LAYERS[service.layer]
|
||||
const pos = getServicePosition(service)
|
||||
const isSelected = selectedService?.id === service.id
|
||||
|
||||
nodes.push({
|
||||
id: service.id,
|
||||
type: 'default',
|
||||
position: pos,
|
||||
data: {
|
||||
label: (
|
||||
<div className="text-center px-1">
|
||||
<div className="font-medium text-xs leading-tight">
|
||||
{service.nameShort}
|
||||
</div>
|
||||
<div className="text-[10px] opacity-70 mt-0.5">
|
||||
{service.tech}
|
||||
</div>
|
||||
{service.port && (
|
||||
<div className="text-[9px] opacity-50 mt-0.5">
|
||||
:{service.port}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
style: {
|
||||
background: isSelected ? layer.colorBorder : layer.colorBg,
|
||||
color: isSelected ? 'white' : layer.colorText,
|
||||
border: `2px solid ${layer.colorBorder}`,
|
||||
borderRadius: '10px',
|
||||
padding: '8px 4px',
|
||||
minWidth: `${NODE_WIDTH}px`,
|
||||
maxWidth: `${NODE_WIDTH}px`,
|
||||
cursor: 'pointer',
|
||||
boxShadow: isSelected
|
||||
? `0 0 16px ${layer.colorBorder}`
|
||||
: '0 1px 3px rgba(0,0,0,0.08)',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// ── Connection Edges ───────────────────────────────────────────────────
|
||||
ARCH_EDGES.forEach(archEdge => {
|
||||
if (visibleIds.has(archEdge.source) && visibleIds.has(archEdge.target)) {
|
||||
const isHighlighted =
|
||||
selectedService?.id === archEdge.source ||
|
||||
selectedService?.id === archEdge.target
|
||||
|
||||
edges.push({
|
||||
id: `e-${archEdge.source}-${archEdge.target}`,
|
||||
source: archEdge.source,
|
||||
target: archEdge.target,
|
||||
type: 'smoothstep',
|
||||
animated: isHighlighted,
|
||||
label: archEdge.label,
|
||||
labelStyle: { fontSize: 9, fill: isHighlighted ? '#7c3aed' : '#94a3b8' },
|
||||
style: {
|
||||
stroke: isHighlighted ? '#7c3aed' : '#94a3b8',
|
||||
strokeWidth: isHighlighted ? 2.5 : 1.5,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: isHighlighted ? '#7c3aed' : '#94a3b8',
|
||||
width: 14,
|
||||
height: 14,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ── DB Table Nodes ─────────────────────────────────────────────────────
|
||||
if (showDb) {
|
||||
const dbTablesInUse = new Set<string>()
|
||||
visibleServices.forEach(s => s.dbTables.forEach(t => dbTablesInUse.add(t)))
|
||||
|
||||
let dbIdx = 0
|
||||
dbTablesInUse.forEach(table => {
|
||||
const nodeId = `db-${table}`
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: 'default',
|
||||
position: { x: -250, y: LANE_Y_START + dbIdx * 60 },
|
||||
data: {
|
||||
label: (
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-[10px] leading-tight">{table}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
style: {
|
||||
background: '#f1f5f9',
|
||||
color: '#475569',
|
||||
border: '1px solid #94a3b8',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 6px',
|
||||
fontSize: '10px',
|
||||
minWidth: '140px',
|
||||
},
|
||||
})
|
||||
|
||||
visibleServices
|
||||
.filter(s => s.dbTables.includes(table))
|
||||
.forEach(svc => {
|
||||
edges.push({
|
||||
id: `e-db-${table}-${svc.id}`,
|
||||
source: nodeId,
|
||||
target: svc.id,
|
||||
type: 'straight',
|
||||
style: { stroke: '#94a3b8', strokeWidth: 1, strokeDasharray: '6 3' },
|
||||
})
|
||||
})
|
||||
|
||||
dbIdx++
|
||||
})
|
||||
}
|
||||
|
||||
// ── RAG Collection Nodes ───────────────────────────────────────────────
|
||||
if (showRag) {
|
||||
const ragInUse = new Set<string>()
|
||||
visibleServices.forEach(s => s.ragCollections.forEach(r => ragInUse.add(r)))
|
||||
|
||||
let ragIdx = 0
|
||||
ragInUse.forEach(collection => {
|
||||
const nodeId = `rag-${collection}`
|
||||
const rightX = 1200
|
||||
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: 'default',
|
||||
position: { x: rightX, y: LANE_Y_START + ragIdx * 60 },
|
||||
data: {
|
||||
label: (
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-[10px] leading-tight">
|
||||
{collection.replace('bp_', '')}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
style: {
|
||||
background: '#dcfce7',
|
||||
color: '#166534',
|
||||
border: '1px solid #22c55e',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 6px',
|
||||
fontSize: '10px',
|
||||
minWidth: '130px',
|
||||
},
|
||||
})
|
||||
|
||||
visibleServices
|
||||
.filter(s => s.ragCollections.includes(collection))
|
||||
.forEach(svc => {
|
||||
edges.push({
|
||||
id: `e-rag-${collection}-${svc.id}`,
|
||||
source: nodeId,
|
||||
target: svc.id,
|
||||
type: 'straight',
|
||||
style: { stroke: '#22c55e', strokeWidth: 1, strokeDasharray: '6 3' },
|
||||
})
|
||||
})
|
||||
|
||||
ragIdx++
|
||||
})
|
||||
}
|
||||
|
||||
// ── API Endpoint Nodes ─────────────────────────────────────────────────
|
||||
if (showApis) {
|
||||
visibleServices.forEach(svc => {
|
||||
if (svc.apiEndpoints.length === 0) return
|
||||
const svcPos = getServicePosition(svc)
|
||||
|
||||
svc.apiEndpoints.forEach((ep, idx) => {
|
||||
const nodeId = `api-${svc.id}-${idx}`
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: 'default',
|
||||
position: { x: svcPos.x + NODE_WIDTH + 30, y: svcPos.y + idx * 32 },
|
||||
data: {
|
||||
label: (
|
||||
<div className="text-[9px] font-mono leading-tight truncate">{ep}</div>
|
||||
),
|
||||
},
|
||||
style: {
|
||||
background: '#faf5ff',
|
||||
color: '#7c3aed',
|
||||
border: '1px solid #c4b5fd',
|
||||
borderRadius: '4px',
|
||||
padding: '2px 6px',
|
||||
fontSize: '9px',
|
||||
minWidth: '160px',
|
||||
},
|
||||
})
|
||||
|
||||
edges.push({
|
||||
id: `e-api-${svc.id}-${idx}`,
|
||||
source: svc.id,
|
||||
target: nodeId,
|
||||
type: 'straight',
|
||||
style: { stroke: '#c4b5fd', strokeWidth: 1 },
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return { nodes, edges }
|
||||
}, [layerFilter, showDb, showRag, showApis, selectedService])
|
||||
|
||||
// =========================================================================
|
||||
// React Flow State
|
||||
// =========================================================================
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([])
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(initialNodes)
|
||||
setEdges(initialEdges)
|
||||
}, [initialNodes, initialEdges, setNodes, setEdges])
|
||||
|
||||
const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
|
||||
const service = ARCH_SERVICES.find(s => s.id === node.id)
|
||||
if (service) {
|
||||
setSelectedService(prev => (prev?.id === service.id ? null : service))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
setSelectedService(null)
|
||||
}, [])
|
||||
|
||||
// =========================================================================
|
||||
// Stats
|
||||
// =========================================================================
|
||||
|
||||
const stats = useMemo(() => {
|
||||
return {
|
||||
services: ARCH_SERVICES.length,
|
||||
@@ -512,439 +51,41 @@ export default function ArchitecturePage() {
|
||||
}
|
||||
}, [allDbTables, allRagCollections])
|
||||
|
||||
// =========================================================================
|
||||
// Render
|
||||
// =========================================================================
|
||||
const handleLayerFilter = useCallback((f: LayerFilter) => {
|
||||
setLayerFilter(f)
|
||||
setSelectedService(null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-slate-700 flex items-center justify-center text-white">
|
||||
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">
|
||||
Architektur-Uebersicht
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
{stats.services} Services | {stats.dbTables} DB-Tabellen | {stats.ragCollections} RAG-Collections | {stats.edges} Verbindungen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArchHeader stats={stats} />
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Layer Filter */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setLayerFilter('alle')
|
||||
setSelectedService(null)
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
layerFilter === 'alle'
|
||||
? 'bg-slate-800 text-white'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Alle ({ARCH_SERVICES.length})
|
||||
</button>
|
||||
{LAYER_ORDER.map(layerId => {
|
||||
const layer = LAYERS[layerId]
|
||||
const count = ARCH_SERVICES.filter(s => s.layer === layerId).length
|
||||
return (
|
||||
<button
|
||||
key={layerId}
|
||||
onClick={() => {
|
||||
setLayerFilter(layerFilter === layerId ? 'alle' : layerId)
|
||||
setSelectedService(null)
|
||||
}}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-all flex items-center gap-1.5"
|
||||
style={{
|
||||
background: layerFilter === layerId ? layer.colorBorder : layer.colorBg,
|
||||
color: layerFilter === layerId ? 'white' : layer.colorText,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ background: layer.colorBorder }}
|
||||
/>
|
||||
{layer.name} ({count})
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<Toolbar
|
||||
layerFilter={layerFilter}
|
||||
showDb={showDb}
|
||||
showRag={showRag}
|
||||
showApis={showApis}
|
||||
onLayerFilter={handleLayerFilter}
|
||||
onToggleDb={() => setShowDb(v => !v)}
|
||||
onToggleRag={() => setShowRag(v => !v)}
|
||||
onToggleApis={() => setShowApis(v => !v)}
|
||||
/>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-6 bg-slate-200 mx-1" />
|
||||
<ArchCanvas
|
||||
layerFilter={layerFilter}
|
||||
showDb={showDb}
|
||||
showRag={showRag}
|
||||
showApis={showApis}
|
||||
selectedService={selectedService}
|
||||
setSelectedService={setSelectedService}
|
||||
/>
|
||||
|
||||
{/* Toggles */}
|
||||
<button
|
||||
onClick={() => setShowDb(!showDb)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
showDb
|
||||
? 'bg-slate-700 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
DB-Tabellen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRag(!showRag)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
showRag
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-green-50 text-green-700 hover:bg-green-100'
|
||||
}`}
|
||||
>
|
||||
RAG-Collections
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowApis(!showApis)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
showApis
|
||||
? 'bg-violet-600 text-white'
|
||||
: 'bg-violet-50 text-violet-700 hover:bg-violet-100'
|
||||
}`}
|
||||
>
|
||||
API-Endpunkte
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flow Canvas + Detail Panel */}
|
||||
<div className="flex bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden" style={{ height: '700px' }}>
|
||||
{/* Canvas */}
|
||||
<div className="flex-1 relative">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.15 }}
|
||||
attributionPosition="bottom-left"
|
||||
>
|
||||
<Controls />
|
||||
<MiniMap
|
||||
nodeColor={node => {
|
||||
if (node.id.startsWith('db-')) return '#94a3b8'
|
||||
if (node.id.startsWith('rag-')) return '#22c55e'
|
||||
if (node.id.startsWith('api-')) return '#c4b5fd'
|
||||
const svc = ARCH_SERVICES.find(s => s.id === node.id)
|
||||
return svc ? LAYERS[svc.layer].colorBorder : '#94a3b8'
|
||||
}}
|
||||
maskColor="rgba(0,0,0,0.08)"
|
||||
/>
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
|
||||
{/* Legende */}
|
||||
<Panel
|
||||
position="bottom-right"
|
||||
className="bg-white/95 p-3 rounded-lg shadow-lg text-xs"
|
||||
>
|
||||
<div className="font-medium text-slate-700 mb-2">Legende</div>
|
||||
<div className="space-y-1">
|
||||
{LAYER_ORDER.map(layerId => {
|
||||
const layer = LAYERS[layerId]
|
||||
return (
|
||||
<div key={layerId} className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-3 h-3 rounded"
|
||||
style={{
|
||||
background: layer.colorBg,
|
||||
border: `1px solid ${layer.colorBorder}`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-slate-600">{layer.name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="border-t border-slate-200 my-1.5 pt-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-slate-200 border border-slate-400" />
|
||||
<span className="text-slate-500">DB-Tabelle</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="w-3 h-3 rounded bg-green-100 border border-green-500" />
|
||||
<span className="text-slate-500">RAG-Collection</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="w-3 h-3 rounded bg-violet-100 border border-violet-400" />
|
||||
<span className="text-slate-500">API-Endpunkt</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Swim-Lane Labels */}
|
||||
{layerFilter === 'alle' && (
|
||||
<Panel position="top-left" className="pointer-events-none">
|
||||
<div className="space-y-1">
|
||||
{LAYER_ORDER.map(layerId => {
|
||||
const layer = LAYERS[layerId]
|
||||
return (
|
||||
<div
|
||||
key={layerId}
|
||||
className="px-3 py-1 rounded text-xs font-medium opacity-50"
|
||||
style={{
|
||||
background: layer.colorBg,
|
||||
color: layer.colorText,
|
||||
border: `1px solid ${layer.colorBorder}`,
|
||||
}}
|
||||
>
|
||||
{layer.name}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* Detail Panel */}
|
||||
{selectedService && (
|
||||
<DetailPanel
|
||||
service={selectedService}
|
||||
onClose={() => setSelectedService(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Service Table (aufklappbar) */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 border-b">
|
||||
<h3 className="font-medium text-slate-700">
|
||||
Alle Services ({
|
||||
layerFilter === 'alle'
|
||||
? ARCH_SERVICES.length
|
||||
: `${ARCH_SERVICES.filter(s => s.layer === layerFilter).length} / ${ARCH_SERVICES.length}`
|
||||
})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="divide-y max-h-[600px] overflow-y-auto">
|
||||
{ARCH_SERVICES.filter(
|
||||
s => layerFilter === 'alle' || s.layer === layerFilter
|
||||
).map(service => {
|
||||
const layer = LAYERS[service.layer]
|
||||
const isExpanded = expandedServices.has(service.id)
|
||||
|
||||
return (
|
||||
<div key={service.id}>
|
||||
{/* Row Header */}
|
||||
<button
|
||||
onClick={() => toggleExpanded(service.id)}
|
||||
className={`w-full flex items-center gap-3 p-3 text-left transition-colors ${
|
||||
isExpanded
|
||||
? 'bg-purple-50'
|
||||
: 'hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{/* Chevron */}
|
||||
<svg
|
||||
className={`w-4 h-4 text-slate-400 shrink-0 transition-transform duration-200 ${
|
||||
isExpanded ? 'rotate-90' : ''
|
||||
}`}
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[10px] font-bold shrink-0"
|
||||
style={{ background: layer.colorBg, color: layer.colorText }}
|
||||
>
|
||||
{service.port ? `:${service.port}` : '--'}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-800 text-sm">
|
||||
{service.name}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 flex items-center gap-2 mt-0.5">
|
||||
<span className="text-slate-400">{service.tech}</span>
|
||||
{service.dbTables.length > 0 && (
|
||||
<span className="text-slate-400">
|
||||
DB: {service.dbTables.length}
|
||||
</span>
|
||||
)}
|
||||
{service.ragCollections.length > 0 && (
|
||||
<span className="text-green-600">
|
||||
RAG: {service.ragCollections.length}
|
||||
</span>
|
||||
)}
|
||||
{service.apiEndpoints.length > 0 && (
|
||||
<span className="text-violet-600">
|
||||
API: {service.apiEndpoints.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<code className="text-[10px] text-slate-400 shrink-0 hidden sm:block">
|
||||
{service.container}
|
||||
</code>
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-[10px] font-medium shrink-0"
|
||||
style={{ background: layer.colorBg, color: layer.colorText }}
|
||||
>
|
||||
{layer.name}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded Detail */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 pt-1 bg-slate-50/50 border-t border-slate-100">
|
||||
{/* Beschreibung */}
|
||||
<p className="text-sm text-slate-700 leading-relaxed">
|
||||
{service.description}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 leading-relaxed mt-1 mb-3">
|
||||
{service.descriptionLong}
|
||||
</p>
|
||||
|
||||
{/* Info Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-3">
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
|
||||
<div className="text-[10px] font-semibold text-slate-400 uppercase">Tech</div>
|
||||
<div className="text-sm font-medium text-slate-800 mt-0.5">{service.tech}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
|
||||
<div className="text-[10px] font-semibold text-slate-400 uppercase">Port</div>
|
||||
<div className="text-sm font-medium text-slate-800 mt-0.5">
|
||||
{service.port ? `:${service.port}` : 'Intern'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
|
||||
<div className="text-[10px] font-semibold text-slate-400 uppercase">Container</div>
|
||||
<div className="text-xs font-mono text-slate-700 mt-0.5 truncate">{service.container}</div>
|
||||
</div>
|
||||
{service.url && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
|
||||
<div className="text-[10px] font-semibold text-slate-400 uppercase">URL</div>
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:underline mt-0.5 block truncate"
|
||||
>
|
||||
{service.url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{/* DB Tables */}
|
||||
{service.dbTables.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3">
|
||||
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
|
||||
DB-Tabellen ({service.dbTables.length})
|
||||
</h4>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{service.dbTables.map(table => (
|
||||
<div key={table} className="bg-slate-50 rounded px-2 py-1">
|
||||
<code className="text-xs text-slate-700">{table}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RAG Collections */}
|
||||
{service.ragCollections.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3">
|
||||
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
|
||||
RAG-Collections ({service.ragCollections.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.ragCollections.map(rag => (
|
||||
<div key={rag} className="bg-green-50 rounded px-2 py-1">
|
||||
<code className="text-xs text-green-700">{rag}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Endpoints */}
|
||||
{service.apiEndpoints.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3">
|
||||
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
|
||||
API-Endpunkte ({service.apiEndpoints.length})
|
||||
</h4>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{service.apiEndpoints.map(ep => (
|
||||
<div key={ep} className="bg-violet-50 rounded px-2 py-1">
|
||||
<code className="text-xs text-violet-700">{ep}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dependencies */}
|
||||
{service.dependsOn.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3">
|
||||
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
|
||||
Abhaengigkeiten ({service.dependsOn.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.dependsOn.map(depId => {
|
||||
const dep = ARCH_SERVICES.find(s => s.id === depId)
|
||||
const depLayer = dep ? LAYERS[dep.layer] : null
|
||||
return (
|
||||
<div key={depId} className="flex items-center gap-2 bg-slate-50 rounded px-2 py-1">
|
||||
{depLayer && (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ background: depLayer.colorBorder }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-slate-700">{dep?.name || depId}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open in Graph + URL */}
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedService(service)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}}
|
||||
className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-xs font-medium hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
Im Graph markieren
|
||||
</button>
|
||||
{service.url && (
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1.5 bg-purple-600 text-white rounded-lg text-xs font-medium hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Service oeffnen
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<ServiceTable
|
||||
layerFilter={layerFilter}
|
||||
expandedServices={expandedServices}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
onMarkInGraph={setSelectedService}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { DisplayChecklistItem, DisplayStatus } from './types'
|
||||
|
||||
interface ChecklistItemCardProps {
|
||||
item: DisplayChecklistItem
|
||||
onStatusChange: (status: DisplayStatus) => void
|
||||
onNotesChange: (notes: string) => void
|
||||
onAddEvidence: () => void
|
||||
}
|
||||
|
||||
export function ChecklistItemCard({
|
||||
item,
|
||||
onStatusChange,
|
||||
onNotesChange,
|
||||
onAddEvidence,
|
||||
}: ChecklistItemCardProps) {
|
||||
const [showNotes, setShowNotes] = useState(false)
|
||||
|
||||
const statusColors = {
|
||||
compliant: 'bg-green-100 text-green-700 border-green-300',
|
||||
'non-compliant': 'bg-red-100 text-red-700 border-red-300',
|
||||
partial: 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||
'not-reviewed': 'bg-gray-100 text-gray-500 border-gray-300',
|
||||
}
|
||||
|
||||
const priorityColors = {
|
||||
critical: 'bg-red-100 text-red-700',
|
||||
high: 'bg-orange-100 text-orange-700',
|
||||
medium: 'bg-yellow-100 text-yellow-700',
|
||||
low: 'bg-green-100 text-green-700',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
item.status === 'non-compliant' ? 'border-red-200' :
|
||||
item.status === 'partial' ? 'border-yellow-200' :
|
||||
item.status === 'compliant' ? 'border-green-200' : 'border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-gray-500">{item.category}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${priorityColors[item.priority]}`}>
|
||||
{item.priority === 'critical' ? 'Kritisch' :
|
||||
item.priority === 'high' ? 'Hoch' :
|
||||
item.priority === 'medium' ? 'Mittel' : 'Niedrig'}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
|
||||
{item.requirementId}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-900 font-medium">{item.question}</p>
|
||||
</div>
|
||||
<select
|
||||
value={item.status}
|
||||
onChange={(e) => onStatusChange(e.target.value as DisplayStatus)}
|
||||
className={`px-3 py-2 rounded-lg border text-sm font-medium ${statusColors[item.status]}`}
|
||||
>
|
||||
<option value="not-reviewed">Nicht geprueft</option>
|
||||
<option value="compliant">Konform</option>
|
||||
<option value="partial">Teilweise</option>
|
||||
<option value="non-compliant">Nicht konform</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{item.notes && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
|
||||
{item.notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.evidence.length > 0 && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Nachweise:</span>
|
||||
{item.evidence.map(ev => (
|
||||
<span key={ev} className="px-2 py-1 text-xs bg-blue-50 text-blue-600 rounded">
|
||||
{ev}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.verifiedBy && item.verifiedAt && (
|
||||
<div className="mt-3 text-sm text-gray-500">
|
||||
Geprueft von {item.verifiedBy} am {item.verifiedAt.toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowNotes(!showNotes)}
|
||||
className="text-sm text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
{showNotes ? 'Notizen ausblenden' : 'Notizen bearbeiten'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onAddEvidence}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Nachweis hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showNotes && (
|
||||
<div className="mt-3">
|
||||
<textarea
|
||||
value={item.notes}
|
||||
onChange={(e) => onNotesChange(e.target.value)}
|
||||
placeholder="Notizen hinzufuegen..."
|
||||
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 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
'use client'
|
||||
|
||||
export function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="h-4 w-24 bg-gray-200 rounded" />
|
||||
<div className="h-4 w-16 bg-gray-200 rounded-full" />
|
||||
</div>
|
||||
<div className="h-5 w-full bg-gray-200 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { PastSession } from './types'
|
||||
|
||||
interface SessionHistoryProps {
|
||||
pastSessions: PastSession[]
|
||||
activeSessionId: string | null
|
||||
}
|
||||
|
||||
export function SessionHistory({ pastSessions, activeSessionId }: SessionHistoryProps) {
|
||||
const router = useRouter()
|
||||
|
||||
if (pastSessions.length === 0) return null
|
||||
|
||||
const statusBadge: Record<string, string> = {
|
||||
draft: 'bg-slate-100 text-slate-700',
|
||||
in_progress: 'bg-blue-100 text-blue-700',
|
||||
completed: 'bg-green-100 text-green-700',
|
||||
archived: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
const statusLabel: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
in_progress: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
archived: 'Archiviert',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Vergangene Audit-Sessions</h3>
|
||||
<div className="space-y-3">
|
||||
{pastSessions
|
||||
.filter(s => s.id !== activeSessionId)
|
||||
.map(session => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 cursor-pointer transition-colors"
|
||||
onClick={() => router.push(`/sdk/audit-report/${session.id}`)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${statusBadge[session.status] || ''}`}>
|
||||
{statusLabel[session.status] || session.status}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">{session.name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(session.created_at).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-gray-500">
|
||||
{session.completed_items}/{session.total_items} Punkte
|
||||
</span>
|
||||
<span className={`font-medium ${
|
||||
session.completion_percentage >= 80 ? 'text-green-600' :
|
||||
session.completion_percentage >= 50 ? 'text-yellow-600' : 'text-red-600'
|
||||
}`}>
|
||||
{session.completion_percentage}%
|
||||
</span>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ChecklistTemplate } from './types'
|
||||
|
||||
// =============================================================================
|
||||
// FALLBACK TEMPLATES
|
||||
// =============================================================================
|
||||
|
||||
export const checklistTemplates: ChecklistTemplate[] = [
|
||||
{
|
||||
id: 'chk-vvt-001',
|
||||
requirementId: 'req-gdpr-30',
|
||||
question: 'Ist ein Verzeichnis von Verarbeitungstaetigkeiten (VVT) vorhanden und aktuell?',
|
||||
category: 'Dokumentation',
|
||||
priority: 'critical',
|
||||
},
|
||||
{
|
||||
id: 'chk-dse-001',
|
||||
requirementId: 'req-gdpr-13',
|
||||
question: 'Sind Datenschutzhinweise fuer alle Verarbeitungen verfuegbar?',
|
||||
category: 'Transparenz',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-consent-001',
|
||||
requirementId: 'req-gdpr-6',
|
||||
question: 'Werden Einwilligungen ordnungsgemaess eingeholt und dokumentiert?',
|
||||
category: 'Einwilligung',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-dsr-001',
|
||||
requirementId: 'req-gdpr-15',
|
||||
question: 'Ist ein Prozess fuer Betroffenenrechte implementiert?',
|
||||
category: 'Betroffenenrechte',
|
||||
priority: 'critical',
|
||||
},
|
||||
{
|
||||
id: 'chk-avv-001',
|
||||
requirementId: 'req-gdpr-28',
|
||||
question: 'Sind Auftragsverarbeitungsvertraege (AVV) mit allen Dienstleistern abgeschlossen?',
|
||||
category: 'Auftragsverarbeitung',
|
||||
priority: 'critical',
|
||||
},
|
||||
{
|
||||
id: 'chk-dsfa-001',
|
||||
requirementId: 'req-gdpr-35',
|
||||
question: 'Wird eine DSFA fuer Hochrisiko-Verarbeitungen durchgefuehrt?',
|
||||
category: 'Risikobewertung',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-tom-001',
|
||||
requirementId: 'req-gdpr-32',
|
||||
question: 'Sind technische und organisatorische Massnahmen dokumentiert?',
|
||||
category: 'TOMs',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-incident-001',
|
||||
requirementId: 'req-gdpr-33',
|
||||
question: 'Gibt es einen Incident-Response-Prozess fuer Datenpannen?',
|
||||
category: 'Incident Response',
|
||||
priority: 'critical',
|
||||
},
|
||||
{
|
||||
id: 'chk-ai-001',
|
||||
requirementId: 'req-ai-act-9',
|
||||
question: 'Ist das KI-System nach EU AI Act klassifiziert?',
|
||||
category: 'AI Act',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-ai-002',
|
||||
requirementId: 'req-ai-act-13',
|
||||
question: 'Sind Transparenzanforderungen fuer KI-Systeme erfuellt?',
|
||||
category: 'AI Act',
|
||||
priority: 'high',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ChecklistItem as SDKChecklistItem } from '@/lib/sdk'
|
||||
import { DisplayStatus } from './types'
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
export function mapSDKStatusToDisplay(status: SDKChecklistItem['status']): DisplayStatus {
|
||||
switch (status) {
|
||||
case 'PASSED': return 'compliant'
|
||||
case 'FAILED': return 'non-compliant'
|
||||
case 'NOT_APPLICABLE': return 'partial'
|
||||
case 'PENDING':
|
||||
default: return 'not-reviewed'
|
||||
}
|
||||
}
|
||||
|
||||
export function mapDisplayStatusToSDK(status: DisplayStatus): SDKChecklistItem['status'] {
|
||||
switch (status) {
|
||||
case 'compliant': return 'PASSED'
|
||||
case 'non-compliant': return 'FAILED'
|
||||
case 'partial': return 'NOT_APPLICABLE'
|
||||
case 'not-reviewed':
|
||||
default: return 'PENDING'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export type DisplayStatus = 'compliant' | 'non-compliant' | 'partial' | 'not-reviewed'
|
||||
export type DisplayPriority = 'critical' | 'high' | 'medium' | 'low'
|
||||
|
||||
export interface DisplayChecklistItem {
|
||||
id: string
|
||||
requirementId: string
|
||||
question: string
|
||||
category: string
|
||||
status: DisplayStatus
|
||||
notes: string
|
||||
evidence: string[]
|
||||
priority: DisplayPriority
|
||||
verifiedBy: string | null
|
||||
verifiedAt: Date | null
|
||||
}
|
||||
|
||||
export interface PastSession {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
auditor_name: string
|
||||
created_at: string
|
||||
completed_at?: string
|
||||
completion_percentage: number
|
||||
total_items: number
|
||||
completed_items: number
|
||||
}
|
||||
|
||||
export interface ChecklistTemplate {
|
||||
id: string
|
||||
requirementId: string
|
||||
question: string
|
||||
category: string
|
||||
priority: DisplayPriority
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useSDK, ChecklistItem as SDKChecklistItem } from '@/lib/sdk'
|
||||
import { PastSession } from '../_components/types'
|
||||
import { checklistTemplates } from '../_components/checklistTemplates'
|
||||
import { mapSDKStatusToDisplay, mapDisplayStatusToSDK } from '../_components/statusHelpers'
|
||||
import { DisplayStatus, DisplayChecklistItem } from '../_components/types'
|
||||
|
||||
export function useAuditChecklist() {
|
||||
const { state, dispatch } = useSDK()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
|
||||
const notesTimerRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
||||
const [pastSessions, setPastSessions] = useState<PastSession[]>([])
|
||||
const [pdfLanguage, setPdfLanguage] = useState<'de' | 'en'>('de')
|
||||
const [generatingPdf, setGeneratingPdf] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchChecklist = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
const sessionsRes = await fetch('/api/sdk/v1/compliance/audit/sessions?status=in_progress')
|
||||
if (sessionsRes.ok) {
|
||||
const sessionsData = await sessionsRes.json()
|
||||
const sessions = sessionsData.sessions || sessionsData
|
||||
if (Array.isArray(sessions) && sessions.length > 0) {
|
||||
const session = sessions[0]
|
||||
setActiveSessionId(session.id)
|
||||
|
||||
const checklistRes = await fetch(`/api/sdk/v1/compliance/audit/checklist/${session.id}`)
|
||||
if (checklistRes.ok) {
|
||||
const checklistData = await checklistRes.json()
|
||||
const items = checklistData.items || checklistData.checklist || checklistData
|
||||
if (Array.isArray(items) && items.length > 0) {
|
||||
const mapped: SDKChecklistItem[] = items.map((item: Record<string, unknown>) => ({
|
||||
id: (item.id || item.requirement_id || '') as string,
|
||||
requirementId: (item.requirement_id || '') as string,
|
||||
title: (item.title || item.question || '') as string,
|
||||
description: (item.category || item.description || '') as string,
|
||||
status: ((item.status || 'PENDING') as string).toUpperCase() as SDKChecklistItem['status'],
|
||||
notes: (item.notes || item.auditor_notes || '') as string,
|
||||
verifiedBy: (item.verified_by || item.signed_off_by || null) as string | null,
|
||||
verifiedAt: item.verified_at || item.signed_off_at ? new Date((item.verified_at || item.signed_off_at) as string) : null,
|
||||
}))
|
||||
dispatch({ type: 'SET_STATE', payload: { checklist: mapped } })
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadFromTemplates()
|
||||
} catch {
|
||||
loadFromTemplates()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromTemplates = () => {
|
||||
if (state.checklist.length > 0) return
|
||||
|
||||
const templatesToLoad = state.requirements.length > 0
|
||||
? checklistTemplates.filter(t =>
|
||||
state.requirements.some(r => r.id === t.requirementId)
|
||||
)
|
||||
: checklistTemplates
|
||||
|
||||
const items: SDKChecklistItem[] = templatesToLoad.map(template => ({
|
||||
id: template.id,
|
||||
requirementId: template.requirementId,
|
||||
title: template.question,
|
||||
description: template.category,
|
||||
status: 'PENDING',
|
||||
notes: '',
|
||||
verifiedBy: null,
|
||||
verifiedAt: null,
|
||||
}))
|
||||
|
||||
if (items.length > 0) {
|
||||
dispatch({ type: 'SET_STATE', payload: { checklist: items } })
|
||||
}
|
||||
}
|
||||
|
||||
fetchChecklist()
|
||||
|
||||
const fetchAllSessions = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/audit/sessions')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const sessions = data.sessions || []
|
||||
setPastSessions(sessions)
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
fetchAllSessions()
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const displayItems: DisplayChecklistItem[] = state.checklist.map(item => {
|
||||
const template = checklistTemplates.find(t => t.id === item.id)
|
||||
return {
|
||||
id: item.id,
|
||||
requirementId: item.requirementId,
|
||||
question: item.title,
|
||||
category: item.description || template?.category || 'Allgemein',
|
||||
status: mapSDKStatusToDisplay(item.status),
|
||||
notes: item.notes,
|
||||
evidence: [],
|
||||
priority: template?.priority || 'medium',
|
||||
verifiedBy: item.verifiedBy,
|
||||
verifiedAt: item.verifiedAt,
|
||||
}
|
||||
})
|
||||
|
||||
const handleStatusChange = async (itemId: string, status: DisplayStatus) => {
|
||||
const sdkStatus = mapDisplayStatusToSDK(status)
|
||||
const updatedChecklist = state.checklist.map(item =>
|
||||
item.id === itemId
|
||||
? {
|
||||
...item,
|
||||
status: sdkStatus,
|
||||
verifiedBy: status !== 'not-reviewed' ? 'Aktueller Benutzer' : null,
|
||||
verifiedAt: status !== 'not-reviewed' ? new Date() : null,
|
||||
}
|
||||
: item
|
||||
)
|
||||
dispatch({ type: 'SET_STATE', payload: { checklist: updatedChecklist } })
|
||||
|
||||
if (activeSessionId) {
|
||||
try {
|
||||
const item = state.checklist.find(i => i.id === itemId)
|
||||
await fetch(`/api/sdk/v1/compliance/audit/checklist/${activeSessionId}/items/${item?.requirementId || itemId}/sign-off`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
status: sdkStatus === 'PASSED' ? 'compliant' : sdkStatus === 'FAILED' ? 'non_compliant' : sdkStatus === 'NOT_APPLICABLE' ? 'partially_compliant' : 'not_assessed',
|
||||
auditor_notes: item?.notes || '',
|
||||
}),
|
||||
})
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleNotesChange = useCallback((itemId: string, notes: string) => {
|
||||
const updatedChecklist = state.checklist.map(item =>
|
||||
item.id === itemId ? { ...item, notes } : item
|
||||
)
|
||||
dispatch({ type: 'SET_STATE', payload: { checklist: updatedChecklist } })
|
||||
|
||||
if (notesTimerRef.current[itemId]) {
|
||||
clearTimeout(notesTimerRef.current[itemId])
|
||||
}
|
||||
notesTimerRef.current[itemId] = setTimeout(async () => {
|
||||
if (activeSessionId) {
|
||||
const item = state.checklist.find(i => i.id === itemId)
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/compliance/audit/checklist/${activeSessionId}/items/${item?.requirementId || itemId}/sign-off`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notes }),
|
||||
})
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}, [state.checklist, activeSessionId, dispatch])
|
||||
|
||||
const handleExport = () => {
|
||||
const exportData = displayItems.map(item => ({
|
||||
id: item.id,
|
||||
requirementId: item.requirementId,
|
||||
question: item.question,
|
||||
category: item.category,
|
||||
status: item.status,
|
||||
notes: item.notes,
|
||||
priority: item.priority,
|
||||
verifiedBy: item.verifiedBy,
|
||||
verifiedAt: item.verifiedAt?.toISOString() || null,
|
||||
}))
|
||||
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 = `audit-checklist-${new Date().toISOString().split('T')[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handlePdfDownload = async () => {
|
||||
if (!activeSessionId) {
|
||||
setError('Kein aktives Audit vorhanden. Erstellen Sie zuerst eine Checkliste.')
|
||||
return
|
||||
}
|
||||
setGeneratingPdf(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/audit/sessions/${activeSessionId}/report/pdf?language=${pdfLanguage}`)
|
||||
if (!res.ok) throw new Error('Fehler bei der PDF-Generierung')
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit-checklist-${activeSessionId}.pdf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'PDF-Download fehlgeschlagen')
|
||||
} finally {
|
||||
setGeneratingPdf(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewChecklist = async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const res = await fetch('/api/sdk/v1/compliance/audit/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: `Compliance Audit ${new Date().toLocaleDateString('de-DE')}`,
|
||||
auditor_name: 'Aktueller Benutzer',
|
||||
regulation_codes: ['GDPR'],
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
window.location.reload()
|
||||
} else {
|
||||
setError('Fehler beim Erstellen der neuen Checkliste')
|
||||
}
|
||||
} catch {
|
||||
setError('Verbindung zum Backend fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
activeSessionId,
|
||||
pastSessions,
|
||||
pdfLanguage,
|
||||
setPdfLanguage,
|
||||
generatingPdf,
|
||||
displayItems,
|
||||
handleStatusChange,
|
||||
handleNotesChange,
|
||||
handleExport,
|
||||
handlePdfDownload,
|
||||
handleNewChecklist,
|
||||
}
|
||||
}
|
||||
@@ -1,408 +1,33 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSDK, ChecklistItem as SDKChecklistItem } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type DisplayStatus = 'compliant' | 'non-compliant' | 'partial' | 'not-reviewed'
|
||||
type DisplayPriority = 'critical' | 'high' | 'medium' | 'low'
|
||||
|
||||
interface DisplayChecklistItem {
|
||||
id: string
|
||||
requirementId: string
|
||||
question: string
|
||||
category: string
|
||||
status: DisplayStatus
|
||||
notes: string
|
||||
evidence: string[]
|
||||
priority: DisplayPriority
|
||||
verifiedBy: string | null
|
||||
verifiedAt: Date | null
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// =============================================================================
|
||||
|
||||
function mapSDKStatusToDisplay(status: SDKChecklistItem['status']): DisplayStatus {
|
||||
switch (status) {
|
||||
case 'PASSED': return 'compliant'
|
||||
case 'FAILED': return 'non-compliant'
|
||||
case 'NOT_APPLICABLE': return 'partial'
|
||||
case 'PENDING':
|
||||
default: return 'not-reviewed'
|
||||
}
|
||||
}
|
||||
|
||||
function mapDisplayStatusToSDK(status: DisplayStatus): SDKChecklistItem['status'] {
|
||||
switch (status) {
|
||||
case 'compliant': return 'PASSED'
|
||||
case 'non-compliant': return 'FAILED'
|
||||
case 'partial': return 'NOT_APPLICABLE'
|
||||
case 'not-reviewed':
|
||||
default: return 'PENDING'
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FALLBACK TEMPLATES
|
||||
// =============================================================================
|
||||
|
||||
interface ChecklistTemplate {
|
||||
id: string
|
||||
requirementId: string
|
||||
question: string
|
||||
category: string
|
||||
priority: DisplayPriority
|
||||
}
|
||||
|
||||
const checklistTemplates: ChecklistTemplate[] = [
|
||||
{
|
||||
id: 'chk-vvt-001',
|
||||
requirementId: 'req-gdpr-30',
|
||||
question: 'Ist ein Verzeichnis von Verarbeitungstaetigkeiten (VVT) vorhanden und aktuell?',
|
||||
category: 'Dokumentation',
|
||||
priority: 'critical',
|
||||
},
|
||||
{
|
||||
id: 'chk-dse-001',
|
||||
requirementId: 'req-gdpr-13',
|
||||
question: 'Sind Datenschutzhinweise fuer alle Verarbeitungen verfuegbar?',
|
||||
category: 'Transparenz',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-consent-001',
|
||||
requirementId: 'req-gdpr-6',
|
||||
question: 'Werden Einwilligungen ordnungsgemaess eingeholt und dokumentiert?',
|
||||
category: 'Einwilligung',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-dsr-001',
|
||||
requirementId: 'req-gdpr-15',
|
||||
question: 'Ist ein Prozess fuer Betroffenenrechte implementiert?',
|
||||
category: 'Betroffenenrechte',
|
||||
priority: 'critical',
|
||||
},
|
||||
{
|
||||
id: 'chk-avv-001',
|
||||
requirementId: 'req-gdpr-28',
|
||||
question: 'Sind Auftragsverarbeitungsvertraege (AVV) mit allen Dienstleistern abgeschlossen?',
|
||||
category: 'Auftragsverarbeitung',
|
||||
priority: 'critical',
|
||||
},
|
||||
{
|
||||
id: 'chk-dsfa-001',
|
||||
requirementId: 'req-gdpr-35',
|
||||
question: 'Wird eine DSFA fuer Hochrisiko-Verarbeitungen durchgefuehrt?',
|
||||
category: 'Risikobewertung',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-tom-001',
|
||||
requirementId: 'req-gdpr-32',
|
||||
question: 'Sind technische und organisatorische Massnahmen dokumentiert?',
|
||||
category: 'TOMs',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-incident-001',
|
||||
requirementId: 'req-gdpr-33',
|
||||
question: 'Gibt es einen Incident-Response-Prozess fuer Datenpannen?',
|
||||
category: 'Incident Response',
|
||||
priority: 'critical',
|
||||
},
|
||||
{
|
||||
id: 'chk-ai-001',
|
||||
requirementId: 'req-ai-act-9',
|
||||
question: 'Ist das KI-System nach EU AI Act klassifiziert?',
|
||||
category: 'AI Act',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
id: 'chk-ai-002',
|
||||
requirementId: 'req-ai-act-13',
|
||||
question: 'Sind Transparenzanforderungen fuer KI-Systeme erfuellt?',
|
||||
category: 'AI Act',
|
||||
priority: 'high',
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function ChecklistItemCard({
|
||||
item,
|
||||
onStatusChange,
|
||||
onNotesChange,
|
||||
onAddEvidence,
|
||||
}: {
|
||||
item: DisplayChecklistItem
|
||||
onStatusChange: (status: DisplayStatus) => void
|
||||
onNotesChange: (notes: string) => void
|
||||
onAddEvidence: () => void
|
||||
}) {
|
||||
const [showNotes, setShowNotes] = useState(false)
|
||||
|
||||
const statusColors = {
|
||||
compliant: 'bg-green-100 text-green-700 border-green-300',
|
||||
'non-compliant': 'bg-red-100 text-red-700 border-red-300',
|
||||
partial: 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||
'not-reviewed': 'bg-gray-100 text-gray-500 border-gray-300',
|
||||
}
|
||||
|
||||
const priorityColors = {
|
||||
critical: 'bg-red-100 text-red-700',
|
||||
high: 'bg-orange-100 text-orange-700',
|
||||
medium: 'bg-yellow-100 text-yellow-700',
|
||||
low: 'bg-green-100 text-green-700',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
item.status === 'non-compliant' ? 'border-red-200' :
|
||||
item.status === 'partial' ? 'border-yellow-200' :
|
||||
item.status === 'compliant' ? 'border-green-200' : 'border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xs text-gray-500">{item.category}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${priorityColors[item.priority]}`}>
|
||||
{item.priority === 'critical' ? 'Kritisch' :
|
||||
item.priority === 'high' ? 'Hoch' :
|
||||
item.priority === 'medium' ? 'Mittel' : 'Niedrig'}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
|
||||
{item.requirementId}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-900 font-medium">{item.question}</p>
|
||||
</div>
|
||||
<select
|
||||
value={item.status}
|
||||
onChange={(e) => onStatusChange(e.target.value as DisplayStatus)}
|
||||
className={`px-3 py-2 rounded-lg border text-sm font-medium ${statusColors[item.status]}`}
|
||||
>
|
||||
<option value="not-reviewed">Nicht geprueft</option>
|
||||
<option value="compliant">Konform</option>
|
||||
<option value="partial">Teilweise</option>
|
||||
<option value="non-compliant">Nicht konform</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{item.notes && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg text-sm text-gray-600">
|
||||
{item.notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.evidence.length > 0 && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Nachweise:</span>
|
||||
{item.evidence.map(ev => (
|
||||
<span key={ev} className="px-2 py-1 text-xs bg-blue-50 text-blue-600 rounded">
|
||||
{ev}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.verifiedBy && item.verifiedAt && (
|
||||
<div className="mt-3 text-sm text-gray-500">
|
||||
Geprueft von {item.verifiedBy} am {item.verifiedAt.toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowNotes(!showNotes)}
|
||||
className="text-sm text-purple-600 hover:text-purple-700"
|
||||
>
|
||||
{showNotes ? 'Notizen ausblenden' : 'Notizen bearbeiten'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onAddEvidence}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Nachweis hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showNotes && (
|
||||
<div className="mt-3">
|
||||
<textarea
|
||||
value={item.notes}
|
||||
onChange={(e) => onNotesChange(e.target.value)}
|
||||
placeholder="Notizen hinzufuegen..."
|
||||
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 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="h-4 w-24 bg-gray-200 rounded" />
|
||||
<div className="h-4 w-16 bg-gray-200 rounded-full" />
|
||||
</div>
|
||||
<div className="h-5 w-full bg-gray-200 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
interface PastSession {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
auditor_name: string
|
||||
created_at: string
|
||||
completed_at?: string
|
||||
completion_percentage: number
|
||||
total_items: number
|
||||
completed_items: number
|
||||
}
|
||||
import { useAuditChecklist } from './_hooks/useAuditChecklist'
|
||||
import { ChecklistItemCard } from './_components/ChecklistItemCard'
|
||||
import { LoadingSkeleton } from './_components/LoadingSkeleton'
|
||||
import { SessionHistory } from './_components/SessionHistory'
|
||||
|
||||
export default function AuditChecklistPage() {
|
||||
const { state, dispatch } = useSDK()
|
||||
const router = useRouter()
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
|
||||
const notesTimerRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
||||
const [pastSessions, setPastSessions] = useState<PastSession[]>([])
|
||||
const [pdfLanguage, setPdfLanguage] = useState<'de' | 'en'>('de')
|
||||
const [generatingPdf, setGeneratingPdf] = useState(false)
|
||||
|
||||
// Fetch checklist from backend on mount
|
||||
useEffect(() => {
|
||||
const fetchChecklist = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
// First, try to find an active audit session
|
||||
const sessionsRes = await fetch('/api/sdk/v1/compliance/audit/sessions?status=in_progress')
|
||||
if (sessionsRes.ok) {
|
||||
const sessionsData = await sessionsRes.json()
|
||||
const sessions = sessionsData.sessions || sessionsData
|
||||
if (Array.isArray(sessions) && sessions.length > 0) {
|
||||
const session = sessions[0]
|
||||
setActiveSessionId(session.id)
|
||||
|
||||
// Fetch checklist items for this session
|
||||
const checklistRes = await fetch(`/api/sdk/v1/compliance/audit/checklist/${session.id}`)
|
||||
if (checklistRes.ok) {
|
||||
const checklistData = await checklistRes.json()
|
||||
const items = checklistData.items || checklistData.checklist || checklistData
|
||||
if (Array.isArray(items) && items.length > 0) {
|
||||
const mapped: SDKChecklistItem[] = items.map((item: Record<string, unknown>) => ({
|
||||
id: (item.id || item.requirement_id || '') as string,
|
||||
requirementId: (item.requirement_id || '') as string,
|
||||
title: (item.title || item.question || '') as string,
|
||||
description: (item.category || item.description || '') as string,
|
||||
status: ((item.status || 'PENDING') as string).toUpperCase() as SDKChecklistItem['status'],
|
||||
notes: (item.notes || item.auditor_notes || '') as string,
|
||||
verifiedBy: (item.verified_by || item.signed_off_by || null) as string | null,
|
||||
verifiedAt: item.verified_at || item.signed_off_at ? new Date((item.verified_at || item.signed_off_at) as string) : null,
|
||||
}))
|
||||
dispatch({ type: 'SET_STATE', payload: { checklist: mapped } })
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: load from templates
|
||||
loadFromTemplates()
|
||||
} catch {
|
||||
loadFromTemplates()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromTemplates = () => {
|
||||
if (state.checklist.length > 0) return
|
||||
|
||||
const templatesToLoad = state.requirements.length > 0
|
||||
? checklistTemplates.filter(t =>
|
||||
state.requirements.some(r => r.id === t.requirementId)
|
||||
)
|
||||
: checklistTemplates
|
||||
|
||||
const items: SDKChecklistItem[] = templatesToLoad.map(template => ({
|
||||
id: template.id,
|
||||
requirementId: template.requirementId,
|
||||
title: template.question,
|
||||
description: template.category,
|
||||
status: 'PENDING',
|
||||
notes: '',
|
||||
verifiedBy: null,
|
||||
verifiedAt: null,
|
||||
}))
|
||||
|
||||
if (items.length > 0) {
|
||||
dispatch({ type: 'SET_STATE', payload: { checklist: items } })
|
||||
}
|
||||
}
|
||||
|
||||
fetchChecklist()
|
||||
|
||||
// Also fetch all sessions for history view
|
||||
const fetchAllSessions = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/audit/sessions')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const sessions = data.sessions || []
|
||||
setPastSessions(sessions)
|
||||
}
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
fetchAllSessions()
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Convert SDK checklist items to display items
|
||||
const displayItems: DisplayChecklistItem[] = state.checklist.map(item => {
|
||||
const template = checklistTemplates.find(t => t.id === item.id)
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
requirementId: item.requirementId,
|
||||
question: item.title,
|
||||
category: item.description || template?.category || 'Allgemein',
|
||||
status: mapSDKStatusToDisplay(item.status),
|
||||
notes: item.notes,
|
||||
evidence: [],
|
||||
priority: template?.priority || 'medium',
|
||||
verifiedBy: item.verifiedBy,
|
||||
verifiedAt: item.verifiedAt,
|
||||
}
|
||||
})
|
||||
const {
|
||||
state,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
activeSessionId,
|
||||
pastSessions,
|
||||
pdfLanguage,
|
||||
setPdfLanguage,
|
||||
generatingPdf,
|
||||
displayItems,
|
||||
handleStatusChange,
|
||||
handleNotesChange,
|
||||
handleExport,
|
||||
handlePdfDownload,
|
||||
handleNewChecklist,
|
||||
} = useAuditChecklist()
|
||||
|
||||
const filteredItems = filter === 'all'
|
||||
? displayItems
|
||||
@@ -417,141 +42,10 @@ export default function AuditChecklistPage() {
|
||||
? Math.round(((compliantCount + partialCount * 0.5) / displayItems.length) * 100)
|
||||
: 0
|
||||
|
||||
const handleStatusChange = async (itemId: string, status: DisplayStatus) => {
|
||||
const sdkStatus = mapDisplayStatusToSDK(status)
|
||||
const updatedChecklist = state.checklist.map(item =>
|
||||
item.id === itemId
|
||||
? {
|
||||
...item,
|
||||
status: sdkStatus,
|
||||
verifiedBy: status !== 'not-reviewed' ? 'Aktueller Benutzer' : null,
|
||||
verifiedAt: status !== 'not-reviewed' ? new Date() : null,
|
||||
}
|
||||
: item
|
||||
)
|
||||
dispatch({ type: 'SET_STATE', payload: { checklist: updatedChecklist } })
|
||||
|
||||
// Persist to backend if we have an active session
|
||||
if (activeSessionId) {
|
||||
try {
|
||||
const item = state.checklist.find(i => i.id === itemId)
|
||||
await fetch(`/api/sdk/v1/compliance/audit/checklist/${activeSessionId}/items/${item?.requirementId || itemId}/sign-off`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
status: sdkStatus === 'PASSED' ? 'compliant' : sdkStatus === 'FAILED' ? 'non_compliant' : sdkStatus === 'NOT_APPLICABLE' ? 'partially_compliant' : 'not_assessed',
|
||||
auditor_notes: item?.notes || '',
|
||||
}),
|
||||
})
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleNotesChange = useCallback((itemId: string, notes: string) => {
|
||||
const updatedChecklist = state.checklist.map(item =>
|
||||
item.id === itemId ? { ...item, notes } : item
|
||||
)
|
||||
dispatch({ type: 'SET_STATE', payload: { checklist: updatedChecklist } })
|
||||
|
||||
// Debounced persistence to backend
|
||||
if (notesTimerRef.current[itemId]) {
|
||||
clearTimeout(notesTimerRef.current[itemId])
|
||||
}
|
||||
notesTimerRef.current[itemId] = setTimeout(async () => {
|
||||
if (activeSessionId) {
|
||||
const item = state.checklist.find(i => i.id === itemId)
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/compliance/audit/checklist/${activeSessionId}/items/${item?.requirementId || itemId}/sign-off`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notes }),
|
||||
})
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
}, [state.checklist, activeSessionId, dispatch])
|
||||
|
||||
const handleExport = () => {
|
||||
const exportData = displayItems.map(item => ({
|
||||
id: item.id,
|
||||
requirementId: item.requirementId,
|
||||
question: item.question,
|
||||
category: item.category,
|
||||
status: item.status,
|
||||
notes: item.notes,
|
||||
priority: item.priority,
|
||||
verifiedBy: item.verifiedBy,
|
||||
verifiedAt: item.verifiedAt?.toISOString() || null,
|
||||
}))
|
||||
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 = `audit-checklist-${new Date().toISOString().split('T')[0]}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handlePdfDownload = async () => {
|
||||
if (!activeSessionId) {
|
||||
setError('Kein aktives Audit vorhanden. Erstellen Sie zuerst eine Checkliste.')
|
||||
return
|
||||
}
|
||||
setGeneratingPdf(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/audit/sessions/${activeSessionId}/report/pdf?language=${pdfLanguage}`)
|
||||
if (!res.ok) throw new Error('Fehler bei der PDF-Generierung')
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit-checklist-${activeSessionId}.pdf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'PDF-Download fehlgeschlagen')
|
||||
} finally {
|
||||
setGeneratingPdf(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewChecklist = async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const res = await fetch('/api/sdk/v1/compliance/audit/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: `Compliance Audit ${new Date().toLocaleDateString('de-DE')}`,
|
||||
auditor_name: 'Aktueller Benutzer',
|
||||
regulation_codes: ['GDPR'],
|
||||
}),
|
||||
})
|
||||
if (res.ok) {
|
||||
// Reload data
|
||||
window.location.reload()
|
||||
} else {
|
||||
setError('Fehler beim Erstellen der neuen Checkliste')
|
||||
}
|
||||
} catch {
|
||||
setError('Verbindung zum Backend fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['audit-checklist']
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Step Header */}
|
||||
<StepHeader
|
||||
stepId="audit-checklist"
|
||||
title={stepInfo.title}
|
||||
@@ -595,7 +89,6 @@ export default function AuditChecklistPage() {
|
||||
</div>
|
||||
</StepHeader>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
@@ -603,7 +96,6 @@ export default function AuditChecklistPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Requirements Alert */}
|
||||
{state.requirements.length === 0 && !loading && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -620,7 +112,6 @@ export default function AuditChecklistPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Checklist Info */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -649,7 +140,6 @@ export default function AuditChecklistPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-green-200 p-4">
|
||||
<div className="text-sm text-green-600">Konform</div>
|
||||
@@ -669,7 +159,6 @@ export default function AuditChecklistPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'not-reviewed', 'non-compliant', 'partial', 'compliant'].map(f => (
|
||||
@@ -690,10 +179,8 @@ export default function AuditChecklistPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && <LoadingSkeleton />}
|
||||
|
||||
{/* Checklist Items */}
|
||||
{!loading && (
|
||||
<div className="space-y-4">
|
||||
{filteredItems.map(item => (
|
||||
@@ -720,61 +207,7 @@ export default function AuditChecklistPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session History */}
|
||||
{pastSessions.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">Vergangene Audit-Sessions</h3>
|
||||
<div className="space-y-3">
|
||||
{pastSessions
|
||||
.filter(s => s.id !== activeSessionId)
|
||||
.map(session => {
|
||||
const statusBadge: Record<string, string> = {
|
||||
draft: 'bg-slate-100 text-slate-700',
|
||||
in_progress: 'bg-blue-100 text-blue-700',
|
||||
completed: 'bg-green-100 text-green-700',
|
||||
archived: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
const statusLabel: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
in_progress: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
archived: 'Archiviert',
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 cursor-pointer transition-colors"
|
||||
onClick={() => router.push(`/sdk/audit-report/${session.id}`)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${statusBadge[session.status] || ''}`}>
|
||||
{statusLabel[session.status] || session.status}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">{session.name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(session.created_at).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-gray-500">
|
||||
{session.completed_items}/{session.total_items} Punkte
|
||||
</span>
|
||||
<span className={`font-medium ${
|
||||
session.completion_percentage >= 80 ? 'text-green-600' :
|
||||
session.completion_percentage >= 50 ? 'text-yellow-600' : 'text-red-600'
|
||||
}`}>
|
||||
{session.completion_percentage}%
|
||||
</span>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<SessionHistory pastSessions={pastSessions} activeSessionId={activeSessionId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
128
admin-compliance/app/sdk/audit-llm/_components/ComplianceTab.tsx
Normal file
128
admin-compliance/app/sdk/audit-llm/_components/ComplianceTab.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import { StatCard } from './StatCard'
|
||||
import { formatNumber, type ComplianceReport } from './types'
|
||||
|
||||
export function ComplianceTab({ complianceReport }: { complianceReport: ComplianceReport }) {
|
||||
return (
|
||||
<div>
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard label="Requests" value={formatNumber(complianceReport.total_requests)} />
|
||||
<StatCard
|
||||
label="PII-Vorfaelle"
|
||||
value={formatNumber(complianceReport.pii_incidents)}
|
||||
highlight={complianceReport.pii_incidents > 0}
|
||||
/>
|
||||
<StatCard
|
||||
label="PII-Rate"
|
||||
value={`${(complianceReport.pii_rate * 100).toFixed(1)}%`}
|
||||
highlight={complianceReport.pii_rate > 0.05}
|
||||
/>
|
||||
<StatCard label="Redaction-Rate" value={`${(complianceReport.redaction_rate * 100).toFixed(1)}%`} />
|
||||
</div>
|
||||
|
||||
{complianceReport.policy_violations > 0 && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-red-700 font-semibold">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
{complianceReport.policy_violations} Policy-Verletzungen im Zeitraum
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* PII Categories */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">PII-Kategorien</h3>
|
||||
{Object.entries(complianceReport.top_pii_categories || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine PII erkannt</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(complianceReport.top_pii_categories).sort((a, b) => b[1] - a[1]).map(([cat, count]) => (
|
||||
<div key={cat} className="flex items-center justify-between py-1">
|
||||
<span className="text-sm text-gray-700">{cat}</span>
|
||||
<span className="px-2 py-0.5 bg-red-50 text-red-700 rounded text-xs font-mono">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Namespace Breakdown */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Namespace-Analyse</h3>
|
||||
{Object.entries(complianceReport.namespace_breakdown || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine Namespace-Daten</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="text-left py-2 text-gray-500 font-medium">Namespace</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">Requests</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">PII</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(complianceReport.namespace_breakdown).map(([ns, data]) => (
|
||||
<tr key={ns} className="border-b border-gray-50">
|
||||
<td className="py-2 font-mono text-xs">{ns}</td>
|
||||
<td className="py-2 text-right">{formatNumber(data.requests)}</td>
|
||||
<td className="py-2 text-right">
|
||||
{data.pii_incidents > 0 ? (
|
||||
<span className="text-red-600 font-medium">{data.pii_incidents}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">0</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Breakdown */}
|
||||
{Object.entries(complianceReport.user_breakdown || {}).length > 0 && (
|
||||
<div className="mt-6 bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Top-Nutzer</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="text-left py-2 text-gray-500 font-medium">User-ID</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">Requests</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">PII-Vorfaelle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(complianceReport.user_breakdown)
|
||||
.sort((a, b) => b[1].requests - a[1].requests)
|
||||
.slice(0, 10)
|
||||
.map(([userId, data]) => (
|
||||
<tr key={userId} className="border-b border-gray-50">
|
||||
<td className="py-2 font-mono text-xs">{userId}</td>
|
||||
<td className="py-2 text-right">{formatNumber(data.requests)}</td>
|
||||
<td className="py-2 text-right">
|
||||
{data.pii_incidents > 0 ? (
|
||||
<span className="text-red-600 font-medium">{data.pii_incidents}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">0</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
90
admin-compliance/app/sdk/audit-llm/_components/LLMLogTab.tsx
Normal file
90
admin-compliance/app/sdk/audit-llm/_components/LLMLogTab.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import { formatDate, formatNumber, formatDuration, type LLMLogEntry } from './types'
|
||||
|
||||
interface Props {
|
||||
logEntries: LLMLogEntry[]
|
||||
logFilter: { model: string; pii: string }
|
||||
onFilterChange: (filter: { model: string; pii: string }) => void
|
||||
}
|
||||
|
||||
export function LLMLogTab({ logEntries, logFilter, onFilterChange }: Props) {
|
||||
return (
|
||||
<div>
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Model filtern..."
|
||||
value={logFilter.model}
|
||||
onChange={e => onFilterChange({ ...logFilter, model: e.target.value })}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-48"
|
||||
/>
|
||||
<select
|
||||
value={logFilter.pii}
|
||||
onChange={e => onFilterChange({ ...logFilter, pii: e.target.value })}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Alle PII-Status</option>
|
||||
<option value="true">PII erkannt</option>
|
||||
<option value="false">Kein PII</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto bg-white rounded-xl border border-gray-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Zeitpunkt</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">User</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Model</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600">Tokens</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600">PII</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600">Dauer</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logEntries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-8 text-gray-400">
|
||||
Keine Log-Eintraege im gewaehlten Zeitraum
|
||||
</td>
|
||||
</tr>
|
||||
) : logEntries.map(entry => (
|
||||
<tr key={entry.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-gray-500">{formatDate(entry.created_at)}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{entry.user_id?.slice(0, 8)}...</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs font-medium">
|
||||
{entry.model}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono">{formatNumber(entry.total_tokens)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{entry.pii_detected ? (
|
||||
<span className="px-2 py-0.5 bg-red-50 text-red-700 rounded text-xs font-medium">
|
||||
{entry.redacted ? 'Redacted' : 'Erkannt'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-500">{formatDuration(entry.duration_ms)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
entry.status === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
||||
}`}>
|
||||
{entry.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-400">{logEntries.length} Eintraege</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
10
admin-compliance/app/sdk/audit-llm/_components/StatCard.tsx
Normal file
10
admin-compliance/app/sdk/audit-llm/_components/StatCard.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
'use client'
|
||||
|
||||
export function StatCard({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
|
||||
return (
|
||||
<div className={`rounded-xl border p-4 ${highlight ? 'border-red-200 bg-red-50' : 'border-gray-200 bg-white'}`}>
|
||||
<div className="text-sm text-gray-500">{label}</div>
|
||||
<div className={`text-2xl font-bold mt-1 ${highlight ? 'text-red-700' : 'text-gray-900'}`}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
84
admin-compliance/app/sdk/audit-llm/_components/UsageTab.tsx
Normal file
84
admin-compliance/app/sdk/audit-llm/_components/UsageTab.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import { StatCard } from './StatCard'
|
||||
import { formatNumber, formatDuration, type UsageStats } from './types'
|
||||
|
||||
export function UsageTab({ usageStats }: { usageStats: UsageStats }) {
|
||||
return (
|
||||
<div>
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard label="Requests gesamt" value={formatNumber(usageStats.total_requests)} />
|
||||
<StatCard label="Tokens gesamt" value={formatNumber(usageStats.total_tokens)} />
|
||||
<StatCard label="Avg. Dauer" value={formatDuration(usageStats.avg_duration_ms)} />
|
||||
<StatCard
|
||||
label="PII-Rate"
|
||||
value={`${(usageStats.pii_detection_rate * 100).toFixed(1)}%`}
|
||||
highlight={usageStats.pii_detection_rate > 0.1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Token Breakdown */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Model-Nutzung</h3>
|
||||
{Object.entries(usageStats.models_used || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine Daten</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(usageStats.models_used).sort((a, b) => b[1] - a[1]).map(([model, count]) => (
|
||||
<div key={model} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700">{model}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-32 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-500 rounded-full"
|
||||
style={{ width: `${(count / usageStats.total_requests) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-mono text-gray-500 w-16 text-right">{formatNumber(count)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Provider-Verteilung</h3>
|
||||
{Object.entries(usageStats.providers_used || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine Daten</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(usageStats.providers_used).sort((a, b) => b[1] - a[1]).map(([provider, count]) => (
|
||||
<div key={provider} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700 capitalize">{provider}</span>
|
||||
<span className="text-sm font-mono text-gray-500">{formatNumber(count)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token Details */}
|
||||
<div className="mt-6 bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Token-Aufschluesselung</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{formatNumber(usageStats.total_prompt_tokens)}</div>
|
||||
<div className="text-sm text-gray-500">Prompt Tokens</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{formatNumber(usageStats.total_completion_tokens)}</div>
|
||||
<div className="text-sm text-gray-500">Completion Tokens</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-purple-600">{formatNumber(usageStats.total_tokens)}</div>
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
admin-compliance/app/sdk/audit-llm/_components/types.ts
Normal file
75
admin-compliance/app/sdk/audit-llm/_components/types.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
export interface LLMLogEntry {
|
||||
id: string
|
||||
user_id: string
|
||||
namespace: string
|
||||
model: string
|
||||
provider: string
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
total_tokens: number
|
||||
pii_detected: boolean
|
||||
pii_categories: string[]
|
||||
redacted: boolean
|
||||
duration_ms: number
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface UsageStats {
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
total_prompt_tokens: number
|
||||
total_completion_tokens: number
|
||||
models_used: Record<string, number>
|
||||
providers_used: Record<string, number>
|
||||
avg_duration_ms: number
|
||||
pii_detection_rate: number
|
||||
period_start: string
|
||||
period_end: string
|
||||
}
|
||||
|
||||
export interface ComplianceReport {
|
||||
total_requests: number
|
||||
pii_incidents: number
|
||||
pii_rate: number
|
||||
redaction_rate: number
|
||||
policy_violations: number
|
||||
top_pii_categories: Record<string, number>
|
||||
namespace_breakdown: Record<string, { requests: number; pii_incidents: number }>
|
||||
user_breakdown: Record<string, { requests: number; pii_incidents: number }>
|
||||
period_start: string
|
||||
period_end: string
|
||||
}
|
||||
|
||||
export type TabId = 'llm-log' | 'usage' | 'compliance'
|
||||
|
||||
export const API_BASE = '/api/sdk/v1/audit-llm'
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function formatNumber(n: number): string {
|
||||
return n.toLocaleString('de-DE')
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
export function getDateRange(period: string): { from: string; to: string } {
|
||||
const now = new Date()
|
||||
const to = now.toISOString().slice(0, 10)
|
||||
const from = new Date(now)
|
||||
switch (period) {
|
||||
case '7d': from.setDate(from.getDate() - 7); break
|
||||
case '30d': from.setDate(from.getDate() - 30); break
|
||||
case '90d': from.setDate(from.getDate() - 90); break
|
||||
default: from.setDate(from.getDate() - 7)
|
||||
}
|
||||
return { from: from.toISOString().slice(0, 10), to }
|
||||
}
|
||||
94
admin-compliance/app/sdk/audit-llm/_hooks/useAuditData.ts
Normal file
94
admin-compliance/app/sdk/audit-llm/_hooks/useAuditData.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { API_BASE, getDateRange, type LLMLogEntry, type UsageStats, type ComplianceReport } from '../_components/types'
|
||||
|
||||
export function useAuditData(period: string, logFilter: { model: string; pii: string }) {
|
||||
const [logEntries, setLogEntries] = useState<LLMLogEntry[]>([])
|
||||
const [usageStats, setUsageStats] = useState<UsageStats | null>(null)
|
||||
const [complianceReport, setComplianceReport] = useState<ComplianceReport | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const loadLLMLog = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const params = new URLSearchParams({ from, to, limit: '100' })
|
||||
if (logFilter.model) params.set('model', logFilter.model)
|
||||
if (logFilter.pii === 'true') params.set('pii_detected', 'true')
|
||||
if (logFilter.pii === 'false') params.set('pii_detected', 'false')
|
||||
|
||||
const res = await fetch(`${API_BASE}/llm?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setLogEntries(Array.isArray(data) ? data : data.entries || data.logs || [])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [period, logFilter])
|
||||
|
||||
const loadUsage = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const res = await fetch(`${API_BASE}/usage?from=${from}&to=${to}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setUsageStats(data)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [period])
|
||||
|
||||
const loadCompliance = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const res = await fetch(`${API_BASE}/compliance-report?from=${from}&to=${to}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setComplianceReport(data)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [period])
|
||||
|
||||
const handleExport = useCallback(async (type: 'llm' | 'general' | 'compliance', format: 'json' | 'csv') => {
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const res = await fetch(`${API_BASE}/export/${type}?from=${from}&to=${to}&format=${format}`)
|
||||
if (!res.ok) throw new Error(`Export fehlgeschlagen: ${res.status}`)
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit-${type}-${from}-${to}.${format}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Export fehlgeschlagen')
|
||||
}
|
||||
}, [period])
|
||||
|
||||
return {
|
||||
logEntries,
|
||||
usageStats,
|
||||
complianceReport,
|
||||
loading,
|
||||
error,
|
||||
loadLLMLog,
|
||||
loadUsage,
|
||||
loadCompliance,
|
||||
handleExport,
|
||||
}
|
||||
}
|
||||
@@ -1,167 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { useAuditData } from './_hooks/useAuditData'
|
||||
import { LLMLogTab } from './_components/LLMLogTab'
|
||||
import { UsageTab } from './_components/UsageTab'
|
||||
import { ComplianceTab } from './_components/ComplianceTab'
|
||||
import type { TabId } from './_components/types'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface LLMLogEntry {
|
||||
id: string
|
||||
user_id: string
|
||||
namespace: string
|
||||
model: string
|
||||
provider: string
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
total_tokens: number
|
||||
pii_detected: boolean
|
||||
pii_categories: string[]
|
||||
redacted: boolean
|
||||
duration_ms: number
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface UsageStats {
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
total_prompt_tokens: number
|
||||
total_completion_tokens: number
|
||||
models_used: Record<string, number>
|
||||
providers_used: Record<string, number>
|
||||
avg_duration_ms: number
|
||||
pii_detection_rate: number
|
||||
period_start: string
|
||||
period_end: string
|
||||
}
|
||||
|
||||
interface ComplianceReport {
|
||||
total_requests: number
|
||||
pii_incidents: number
|
||||
pii_rate: number
|
||||
redaction_rate: number
|
||||
policy_violations: number
|
||||
top_pii_categories: Record<string, number>
|
||||
namespace_breakdown: Record<string, { requests: number; pii_incidents: number }>
|
||||
user_breakdown: Record<string, { requests: number; pii_incidents: number }>
|
||||
period_start: string
|
||||
period_end: string
|
||||
}
|
||||
|
||||
type TabId = 'llm-log' | 'usage' | 'compliance'
|
||||
|
||||
// =============================================================================
|
||||
// HELPERS
|
||||
// =============================================================================
|
||||
|
||||
const API_BASE = '/api/sdk/v1/audit-llm'
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString('de-DE')
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
function getDateRange(period: string): { from: string; to: string } {
|
||||
const now = new Date()
|
||||
const to = now.toISOString().slice(0, 10)
|
||||
const from = new Date(now)
|
||||
switch (period) {
|
||||
case '7d': from.setDate(from.getDate() - 7); break
|
||||
case '30d': from.setDate(from.getDate() - 30); break
|
||||
case '90d': from.setDate(from.getDate() - 90); break
|
||||
default: from.setDate(from.getDate() - 7)
|
||||
}
|
||||
return { from: from.toISOString().slice(0, 10), to }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
const tabs: { id: TabId; label: string }[] = [
|
||||
{ id: 'llm-log', label: 'LLM-Log' },
|
||||
{ id: 'usage', label: 'Nutzung' },
|
||||
{ id: 'compliance', label: 'Compliance' },
|
||||
]
|
||||
|
||||
export default function AuditLLMPage() {
|
||||
const { state } = useSDK()
|
||||
const [activeTab, setActiveTab] = useState<TabId>('llm-log')
|
||||
const [period, setPeriod] = useState('7d')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// LLM Log state
|
||||
const [logEntries, setLogEntries] = useState<LLMLogEntry[]>([])
|
||||
const [logFilter, setLogFilter] = useState({ model: '', pii: '' })
|
||||
|
||||
// Usage state
|
||||
const [usageStats, setUsageStats] = useState<UsageStats | null>(null)
|
||||
|
||||
// Compliance state
|
||||
const [complianceReport, setComplianceReport] = useState<ComplianceReport | null>(null)
|
||||
|
||||
// ─── Load Data ───────────────────────────────────────────────────────
|
||||
|
||||
const loadLLMLog = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const params = new URLSearchParams({ from, to, limit: '100' })
|
||||
if (logFilter.model) params.set('model', logFilter.model)
|
||||
if (logFilter.pii === 'true') params.set('pii_detected', 'true')
|
||||
if (logFilter.pii === 'false') params.set('pii_detected', 'false')
|
||||
|
||||
const res = await fetch(`${API_BASE}/llm?${params}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setLogEntries(Array.isArray(data) ? data : data.entries || data.logs || [])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [period, logFilter])
|
||||
|
||||
const loadUsage = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const res = await fetch(`${API_BASE}/usage?from=${from}&to=${to}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setUsageStats(data)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [period])
|
||||
|
||||
const loadCompliance = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const res = await fetch(`${API_BASE}/compliance-report?from=${from}&to=${to}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
setComplianceReport(data)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [period])
|
||||
const {
|
||||
logEntries,
|
||||
usageStats,
|
||||
complianceReport,
|
||||
loading,
|
||||
error,
|
||||
loadLLMLog,
|
||||
loadUsage,
|
||||
loadCompliance,
|
||||
handleExport,
|
||||
} = useAuditData(period, logFilter)
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'llm-log') loadLLMLog()
|
||||
@@ -169,42 +38,13 @@ export default function AuditLLMPage() {
|
||||
else if (activeTab === 'compliance') loadCompliance()
|
||||
}, [activeTab, loadLLMLog, loadUsage, loadCompliance])
|
||||
|
||||
// ─── Export ──────────────────────────────────────────────────────────
|
||||
|
||||
const handleExport = async (type: 'llm' | 'general' | 'compliance', format: 'json' | 'csv') => {
|
||||
try {
|
||||
const { from, to } = getDateRange(period)
|
||||
const res = await fetch(`${API_BASE}/export/${type}?from=${from}&to=${to}&format=${format}`)
|
||||
if (!res.ok) throw new Error(`Export fehlgeschlagen: ${res.status}`)
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit-${type}-${from}-${to}.${format}`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Export fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tabs ────────────────────────────────────────────────────────────
|
||||
|
||||
const tabs: { id: TabId; label: string }[] = [
|
||||
{ id: 'llm-log', label: 'LLM-Log' },
|
||||
{ id: 'usage', label: 'Nutzung' },
|
||||
{ id: 'compliance', label: 'Compliance' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">LLM Audit Dashboard</h1>
|
||||
<p className="text-gray-500 mt-1">Monitoring und Compliance-Analyse der LLM-Operationen</p>
|
||||
</div>
|
||||
|
||||
{/* Period + Tabs */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||
{tabs.map(tab => (
|
||||
@@ -254,289 +94,22 @@ export default function AuditLLMPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── LLM-Log Tab ── */}
|
||||
{!loading && activeTab === 'llm-log' && (
|
||||
<div>
|
||||
{/* Filters */}
|
||||
<div className="flex gap-3 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Model filtern..."
|
||||
value={logFilter.model}
|
||||
onChange={e => setLogFilter(f => ({ ...f, model: e.target.value }))}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm w-48"
|
||||
/>
|
||||
<select
|
||||
value={logFilter.pii}
|
||||
onChange={e => setLogFilter(f => ({ ...f, pii: e.target.value }))}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Alle PII-Status</option>
|
||||
<option value="true">PII erkannt</option>
|
||||
<option value="false">Kein PII</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto bg-white rounded-xl border border-gray-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Zeitpunkt</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">User</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-600">Model</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600">Tokens</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600">PII</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-gray-600">Dauer</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-600">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logEntries.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center py-8 text-gray-400">
|
||||
Keine Log-Eintraege im gewaehlten Zeitraum
|
||||
</td>
|
||||
</tr>
|
||||
) : logEntries.map(entry => (
|
||||
<tr key={entry.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-gray-500">{formatDate(entry.created_at)}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">{entry.user_id?.slice(0, 8)}...</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs font-medium">
|
||||
{entry.model}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono">{formatNumber(entry.total_tokens)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{entry.pii_detected ? (
|
||||
<span className="px-2 py-0.5 bg-red-50 text-red-700 rounded text-xs font-medium">
|
||||
{entry.redacted ? 'Redacted' : 'Erkannt'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-500">{formatDuration(entry.duration_ms)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
entry.status === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
||||
}`}>
|
||||
{entry.status}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-400">{logEntries.length} Eintraege</div>
|
||||
</div>
|
||||
<LLMLogTab
|
||||
logEntries={logEntries}
|
||||
logFilter={logFilter}
|
||||
onFilterChange={setLogFilter}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Nutzung Tab ── */}
|
||||
{!loading && activeTab === 'usage' && usageStats && (
|
||||
<div>
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard label="Requests gesamt" value={formatNumber(usageStats.total_requests)} />
|
||||
<StatCard label="Tokens gesamt" value={formatNumber(usageStats.total_tokens)} />
|
||||
<StatCard label="Avg. Dauer" value={formatDuration(usageStats.avg_duration_ms)} />
|
||||
<StatCard
|
||||
label="PII-Rate"
|
||||
value={`${(usageStats.pii_detection_rate * 100).toFixed(1)}%`}
|
||||
highlight={usageStats.pii_detection_rate > 0.1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Token Breakdown */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Model-Nutzung</h3>
|
||||
{Object.entries(usageStats.models_used || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine Daten</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(usageStats.models_used).sort((a, b) => b[1] - a[1]).map(([model, count]) => (
|
||||
<div key={model} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700">{model}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-32 h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-500 rounded-full"
|
||||
style={{ width: `${(count / usageStats.total_requests) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-mono text-gray-500 w-16 text-right">{formatNumber(count)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Provider-Verteilung</h3>
|
||||
{Object.entries(usageStats.providers_used || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine Daten</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(usageStats.providers_used).sort((a, b) => b[1] - a[1]).map(([provider, count]) => (
|
||||
<div key={provider} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-700 capitalize">{provider}</span>
|
||||
<span className="text-sm font-mono text-gray-500">{formatNumber(count)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token Details */}
|
||||
<div className="mt-6 bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Token-Aufschluesselung</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{formatNumber(usageStats.total_prompt_tokens)}</div>
|
||||
<div className="text-sm text-gray-500">Prompt Tokens</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{formatNumber(usageStats.total_completion_tokens)}</div>
|
||||
<div className="text-sm text-gray-500">Completion Tokens</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-purple-600">{formatNumber(usageStats.total_tokens)}</div>
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UsageTab usageStats={usageStats} />
|
||||
)}
|
||||
|
||||
{/* ── Compliance Tab ── */}
|
||||
{!loading && activeTab === 'compliance' && complianceReport && (
|
||||
<div>
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard label="Requests" value={formatNumber(complianceReport.total_requests)} />
|
||||
<StatCard
|
||||
label="PII-Vorfaelle"
|
||||
value={formatNumber(complianceReport.pii_incidents)}
|
||||
highlight={complianceReport.pii_incidents > 0}
|
||||
/>
|
||||
<StatCard
|
||||
label="PII-Rate"
|
||||
value={`${(complianceReport.pii_rate * 100).toFixed(1)}%`}
|
||||
highlight={complianceReport.pii_rate > 0.05}
|
||||
/>
|
||||
<StatCard label="Redaction-Rate" value={`${(complianceReport.redaction_rate * 100).toFixed(1)}%`} />
|
||||
</div>
|
||||
|
||||
{complianceReport.policy_violations > 0 && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl">
|
||||
<div className="flex items-center gap-2 text-red-700 font-semibold">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
{complianceReport.policy_violations} Policy-Verletzungen im Zeitraum
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* PII Categories */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">PII-Kategorien</h3>
|
||||
{Object.entries(complianceReport.top_pii_categories || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine PII erkannt</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(complianceReport.top_pii_categories).sort((a, b) => b[1] - a[1]).map(([cat, count]) => (
|
||||
<div key={cat} className="flex items-center justify-between py-1">
|
||||
<span className="text-sm text-gray-700">{cat}</span>
|
||||
<span className="px-2 py-0.5 bg-red-50 text-red-700 rounded text-xs font-mono">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Namespace Breakdown */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Namespace-Analyse</h3>
|
||||
{Object.entries(complianceReport.namespace_breakdown || {}).length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Keine Namespace-Daten</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="text-left py-2 text-gray-500 font-medium">Namespace</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">Requests</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">PII</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(complianceReport.namespace_breakdown).map(([ns, data]) => (
|
||||
<tr key={ns} className="border-b border-gray-50">
|
||||
<td className="py-2 font-mono text-xs">{ns}</td>
|
||||
<td className="py-2 text-right">{formatNumber(data.requests)}</td>
|
||||
<td className="py-2 text-right">
|
||||
{data.pii_incidents > 0 ? (
|
||||
<span className="text-red-600 font-medium">{data.pii_incidents}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">0</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Breakdown */}
|
||||
{Object.entries(complianceReport.user_breakdown || {}).length > 0 && (
|
||||
<div className="mt-6 bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Top-Nutzer</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100">
|
||||
<th className="text-left py-2 text-gray-500 font-medium">User-ID</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">Requests</th>
|
||||
<th className="text-right py-2 text-gray-500 font-medium">PII-Vorfaelle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(complianceReport.user_breakdown)
|
||||
.sort((a, b) => b[1].requests - a[1].requests)
|
||||
.slice(0, 10)
|
||||
.map(([userId, data]) => (
|
||||
<tr key={userId} className="border-b border-gray-50">
|
||||
<td className="py-2 font-mono text-xs">{userId}</td>
|
||||
<td className="py-2 text-right">{formatNumber(data.requests)}</td>
|
||||
<td className="py-2 text-right">
|
||||
{data.pii_incidents > 0 ? (
|
||||
<span className="text-red-600 font-medium">{data.pii_incidents}</span>
|
||||
) : (
|
||||
<span className="text-gray-400">0</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ComplianceTab complianceReport={complianceReport} />
|
||||
)}
|
||||
|
||||
{/* Empty state for usage/compliance when no data */}
|
||||
{!loading && activeTab === 'usage' && !usageStats && !error && (
|
||||
<div className="text-center py-12 text-gray-400">Keine Nutzungsdaten verfuegbar</div>
|
||||
)}
|
||||
@@ -546,16 +119,3 @@ export default function AuditLLMPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STAT CARD
|
||||
// =============================================================================
|
||||
|
||||
function StatCard({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
|
||||
return (
|
||||
<div className={`rounded-xl border p-4 ${highlight ? 'border-red-200 bg-red-50' : 'border-gray-200 bg-white'}`}>
|
||||
<div className="text-sm text-gray-500">{label}</div>
|
||||
<div className={`text-2xl font-bold mt-1 ${highlight ? 'text-red-700' : 'text-gray-900'}`}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { CompanyProfile, BUSINESS_MODEL_LABELS, COMPANY_SIZE_LABELS, TARGET_MARKET_LABELS } from '@/lib/sdk/types'
|
||||
import { LEGAL_FORM_LABELS } from './constants'
|
||||
|
||||
export function ProfileSummary({
|
||||
formData,
|
||||
onEdit,
|
||||
onContinue,
|
||||
}: {
|
||||
formData: Partial<CompanyProfile>
|
||||
onEdit: () => void
|
||||
onContinue: () => void
|
||||
}) {
|
||||
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={onEdit} 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={onContinue} className="px-8 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium">
|
||||
Weiter zu Scope
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={onEdit} 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { CompanyProfile } from '@/lib/sdk/types'
|
||||
import { AISystem, AISystemTemplate } from './types'
|
||||
import { AI_SYSTEM_TEMPLATES } from './ai-system-data'
|
||||
|
||||
export function StepAISystems({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile> & { aiSystems?: AISystem[] }
|
||||
onChange: (updates: Record<string, unknown>) => void
|
||||
}) {
|
||||
const aiSystems: AISystem[] = (data as any).aiSystems || []
|
||||
const [expandedSystem, setExpandedSystem] = useState<string | null>(null)
|
||||
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set())
|
||||
|
||||
const activeIds = new Set(aiSystems.map(a => a.id))
|
||||
|
||||
const toggleTemplateSystem = (template: AISystemTemplate) => {
|
||||
if (activeIds.has(template.id)) {
|
||||
onChange({ aiSystems: aiSystems.filter(a => a.id !== template.id) })
|
||||
if (expandedSystem === template.id) setExpandedSystem(null)
|
||||
} else {
|
||||
const newSystem: AISystem = {
|
||||
id: template.id, name: template.name, vendor: template.vendor,
|
||||
purpose: template.typicalPurposes.join(', '), purposes: [],
|
||||
processes_personal_data: template.processes_personal_data_likely, isCustom: false,
|
||||
}
|
||||
onChange({ aiSystems: [...aiSystems, newSystem] })
|
||||
setExpandedSystem(template.id)
|
||||
}
|
||||
}
|
||||
|
||||
const updateAISystem = (id: string, updates: Partial<AISystem>) => {
|
||||
onChange({ aiSystems: aiSystems.map(a => a.id === id ? { ...a, ...updates } : a) })
|
||||
}
|
||||
|
||||
const togglePurpose = (systemId: string, purpose: string) => {
|
||||
const system = aiSystems.find(a => a.id === systemId)
|
||||
if (!system) return
|
||||
const purposes = system.purposes || []
|
||||
const updated = purposes.includes(purpose) ? purposes.filter(p => p !== purpose) : [...purposes, purpose]
|
||||
updateAISystem(systemId, { purposes: updated, purpose: updated.join(', ') })
|
||||
}
|
||||
|
||||
const addCustomSystem = () => {
|
||||
const id = `custom_ai_${Date.now()}`
|
||||
onChange({ aiSystems: [...aiSystems, { id, name: '', vendor: '', purpose: '', processes_personal_data: false, isCustom: true }] })
|
||||
setExpandedSystem(id)
|
||||
}
|
||||
|
||||
const removeSystem = (id: string) => {
|
||||
onChange({ aiSystems: aiSystems.filter(a => a.id !== id) })
|
||||
if (expandedSystem === id) setExpandedSystem(null)
|
||||
}
|
||||
|
||||
const toggleCategoryCollapse = (category: string) => {
|
||||
setCollapsedCategories(prev => { const next = new Set(prev); if (next.has(category)) next.delete(category); else next.add(category); return next })
|
||||
}
|
||||
|
||||
const categoryActiveCount = (systems: AISystemTemplate[]) => systems.filter(s => activeIds.has(s.id)).length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-1">KI-Systeme im Einsatz</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Waehlen Sie die KI-Systeme aus, die in Ihrem Unternehmen eingesetzt werden. Dies dient der Erfassung fuer den EU AI Act und die DSGVO-Dokumentation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{AI_SYSTEM_TEMPLATES.map(group => {
|
||||
const isCollapsed = collapsedCategories.has(group.category)
|
||||
const activeCount = categoryActiveCount(group.systems)
|
||||
|
||||
return (
|
||||
<div key={group.category} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button type="button" onClick={() => toggleCategoryCollapse(group.category)} className="w-full flex items-center gap-3 px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors text-left">
|
||||
<span className="text-base">{group.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900 flex-1">{group.category}</span>
|
||||
{activeCount > 0 && <span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">{activeCount} aktiv</span>}
|
||||
<svg className={`w-4 h-4 text-gray-400 transition-transform ${isCollapsed ? '' : '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>
|
||||
</button>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="p-3 space-y-2">
|
||||
{group.systems.map(template => {
|
||||
const isActive = activeIds.has(template.id)
|
||||
const system = aiSystems.find(a => a.id === template.id)
|
||||
const isExpanded = expandedSystem === template.id
|
||||
|
||||
return (
|
||||
<div key={template.id}>
|
||||
<div
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${isActive ? 'border-purple-500 bg-purple-50' : 'border-gray-100 hover:border-purple-300'}`}
|
||||
onClick={() => { if (!isActive) { toggleTemplateSystem(template) } else { setExpandedSystem(isExpanded ? null : template.id) } }}
|
||||
>
|
||||
<input type="checkbox" checked={isActive} onChange={e => { e.stopPropagation(); toggleTemplateSystem(template) }} className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900">{template.name}</div>
|
||||
<p className="text-xs text-gray-500">{template.vendor}</p>
|
||||
</div>
|
||||
{isActive && (
|
||||
<svg className={`w-4 h-4 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>
|
||||
|
||||
{isActive && isExpanded && system && (
|
||||
<div className="ml-4 mt-2 p-4 bg-gray-50 rounded-lg border border-gray-200 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">Einsatzzweck</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{template.typicalPurposes.map(purpose => (
|
||||
<button key={purpose} type="button" onClick={() => togglePurpose(template.id, purpose)}
|
||||
className={`px-3 py-1.5 text-xs rounded-full border transition-all ${(system.purposes || []).includes(purpose) ? 'bg-purple-100 border-purple-300 text-purple-700' : 'bg-white border-gray-200 text-gray-600 hover:border-purple-200'}`}>
|
||||
{purpose}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input type="text" value={system.notes || ''} onChange={e => updateAISystem(template.id, { notes: e.target.value })} placeholder="Weitere Einsatzzwecke / Anmerkungen..." className="mt-2 w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
|
||||
{template.dataWarning && (
|
||||
<div className={`flex items-start gap-2 px-3 py-2 rounded-lg ${template.dataWarning.includes('EU') || template.dataWarning.includes('Deutscher Anbieter') || template.dataWarning.includes('NICHT') ? 'bg-blue-50 border border-blue-200' : 'bg-amber-50 border border-amber-200'}`}>
|
||||
<span className="text-sm mt-0.5">{template.dataWarning.includes('EU') || template.dataWarning.includes('Deutscher Anbieter') ? '\u2139\uFE0F' : '\u26A0\uFE0F'}</span>
|
||||
<span className="text-xs text-gray-800">{template.dataWarning}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 px-1 cursor-pointer">
|
||||
<input type="checkbox" checked={system.processes_personal_data} onChange={e => updateAISystem(template.id, { processes_personal_data: e.target.checked })} className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500" />
|
||||
<span className="text-sm text-gray-700">Verarbeitet personenbezogene Daten</span>
|
||||
</label>
|
||||
|
||||
<button type="button" onClick={() => removeSystem(template.id)} className="text-xs text-red-500 hover:text-red-700">KI-System entfernen</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{aiSystems.filter(a => a.isCustom).map(system => (
|
||||
<div key={system.id} className="mt-2">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border-2 border-purple-500 bg-purple-50 cursor-pointer" onClick={() => setExpandedSystem(expandedSystem === system.id ? null : system.id)}>
|
||||
<span className="w-4 h-4 flex items-center justify-center text-purple-600 flex-shrink-0 text-sm">+</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900">{system.name || 'Neues KI-System'}</span>
|
||||
{system.vendor && <span className="text-xs text-gray-500 ml-2">({system.vendor})</span>}
|
||||
</div>
|
||||
<svg className={`w-4 h-4 text-gray-400 transition-transform ${expandedSystem === system.id ? '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>
|
||||
{expandedSystem === system.id && (
|
||||
<div className="ml-4 mt-2 p-4 bg-gray-50 rounded-lg border border-gray-200 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input type="text" value={system.name} onChange={e => updateAISystem(system.id, { name: e.target.value })} placeholder="Name (z.B. ChatGPT, Copilot)" 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={system.vendor} onChange={e => updateAISystem(system.id, { vendor: e.target.value })} placeholder="Anbieter (z.B. OpenAI, Microsoft)" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
<input type="text" value={system.purpose} onChange={e => updateAISystem(system.id, { purpose: e.target.value })} placeholder="Einsatzzweck (z.B. Kundensupport, Code-Assistenz)" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
<label className="flex items-center gap-2 px-1 cursor-pointer">
|
||||
<input type="checkbox" checked={system.processes_personal_data} onChange={e => updateAISystem(system.id, { processes_personal_data: e.target.checked })} className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500" />
|
||||
<span className="text-sm text-gray-700">Verarbeitet personenbezogene Daten</span>
|
||||
</label>
|
||||
<button type="button" onClick={() => removeSystem(system.id)} className="text-xs text-red-500 hover:text-red-700">KI-System entfernen</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button type="button" onClick={addCustomSystem} className="w-full mt-3 px-3 py-2 text-sm text-purple-700 bg-purple-50 border-2 border-dashed border-purple-200 rounded-lg hover:bg-purple-100 hover:border-purple-300 transition-colors">
|
||||
+ Eigenes KI-System hinzufuegen
|
||||
</button>
|
||||
|
||||
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-lg">{'\u2139\uFE0F'}</span>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-blue-900 mb-1">AI Act Risikoeinstufung</h4>
|
||||
<p className="text-xs text-blue-800 mb-3">
|
||||
Die detaillierte Risikoeinstufung Ihrer KI-Systeme nach EU AI Act (verboten / hochriskant / begrenzt / minimal) erfolgt automatisch im AI-Act-Modul.
|
||||
</p>
|
||||
<a href="/sdk/ai-act" className="inline-flex items-center gap-1 text-sm font-medium text-blue-700 hover:text-blue-900">
|
||||
Zum AI-Act-Modul
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user