Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aabfd0aecd | ||
|
|
11f13b3f74 | ||
|
|
20fbfc197e | ||
|
|
b5d20a4c1d | ||
|
|
54add75eb0 | ||
|
|
c34f8528a7 | ||
|
|
90d14eb546 | ||
|
|
0125199c76 | ||
|
|
cfd4fc347f | ||
|
|
2adbacf267 | ||
|
|
9d96330a54 | ||
|
|
c50e57fd85 |
@@ -1,45 +1,33 @@
|
||||
# BreakPilot Compliance - DSGVO/AI-Act SDK Platform
|
||||
|
||||
> **NON-NEGOTIABLE STRUCTURE RULES** (enforced by `.claude/settings.json` hook, git pre-commit, and CI):
|
||||
> 1. **File-size budget:** soft target **300** lines, **hard cap 500** lines for any non-test, non-generated source file. Anything larger → split it. Exceptions are listed in `.claude/rules/loc-exceptions.txt` and require a written rationale.
|
||||
> 2. **Clean architecture per service.** Routers/handlers stay thin (≤30 lines per handler) and delegate to services; services use repositories; repositories own DB I/O. See `AGENTS.python.md` / `AGENTS.go.md` / `AGENTS.typescript.md`.
|
||||
> 3. **Do not touch the database schema.** No new Alembic migrations, no `ALTER TABLE`, no model field renames without an explicit migration plan reviewed by the DB owner. SQLAlchemy `__tablename__` and column names are frozen.
|
||||
> 4. **Public endpoints are a contract.** Any change to a path, method, status code, request schema, or response schema in `backend-compliance/`, `ai-compliance-sdk/`, `dsms-gateway/`, `document-crawler/`, or `compliance-tts-service/` must be accompanied by a matching update in **every** consumer (`admin-compliance/`, `developer-portal/`, `breakpilot-compliance-sdk/`, `consent-sdk/`). Use the OpenAPI snapshot tests in `tests/contracts/` as the gate.
|
||||
> 5. **Tests are not optional.** New code without tests fails CI. Refactors must preserve coverage and add a characterization test before splitting an oversized file.
|
||||
> 6. **Do not bypass the guardrails.** Do not edit `.claude/settings.json`, `scripts/check-loc.sh`, or the loc-exceptions list to silence violations. If a rule is wrong, raise it in a PR description.
|
||||
>
|
||||
> These rules apply to **every** Claude Code session opened inside this repository, regardless of who launched it. They are loaded automatically via this `CLAUDE.md`.
|
||||
|
||||
|
||||
|
||||
## Entwicklungsumgebung (WICHTIG - IMMER ZUERST LESEN)
|
||||
|
||||
### Zwei-Rechner-Setup + Coolify
|
||||
### Zwei-Rechner-Setup + Orca
|
||||
|
||||
| Geraet | Rolle | Aufgaben |
|
||||
|--------|-------|----------|
|
||||
| **MacBook** | Entwicklung | Claude Terminal, Code-Entwicklung, Browser (Frontend-Tests) |
|
||||
| **Mac Mini** | Lokaler Server | Docker fuer lokale Dev/Tests (NICHT fuer Production!) |
|
||||
| **Coolify** | Production | Automatisches Build + Deploy bei Push auf gitea |
|
||||
| **Orca** | Production | Automatisches Build + Deploy bei Push auf gitea |
|
||||
|
||||
**WICHTIG:** Code wird auf dem MacBook bearbeitet. Production-Deployment laeuft automatisch ueber Coolify.
|
||||
**WICHTIG:** Code wird auf dem MacBook bearbeitet. Production-Deployment laeuft automatisch ueber Orca.
|
||||
|
||||
### Entwicklungsworkflow (CI/CD — Coolify)
|
||||
### Entwicklungsworkflow (CI/CD — Orca)
|
||||
|
||||
```bash
|
||||
# 1. Code auf MacBook bearbeiten (dieses Verzeichnis)
|
||||
# 2. Committen und zu BEIDEN Remotes pushen:
|
||||
git push origin main && git push gitea main
|
||||
git push origin main
|
||||
|
||||
# 3. FERTIG! Push auf gitea triggert automatisch:
|
||||
# - Gitea Actions: Lint → Tests → Validierung
|
||||
# - Coolify: Build → Deploy
|
||||
# - Orca: Build → Deploy
|
||||
# Dauer: ca. 3 Minuten
|
||||
# Status pruefen: https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions
|
||||
```
|
||||
|
||||
**NICHT MEHR NOETIG:** Manuelles `ssh macmini "docker compose build"` fuer Production.
|
||||
**NIEMALS** manuell in Coolify auf "Redeploy" klicken — Gitea Actions triggert Coolify automatisch.
|
||||
**NIEMALS** manuell in Orca auf "Redeploy" klicken — Gitea Actions triggert Orca automatisch.
|
||||
|
||||
### Post-Push Deploy-Monitoring (PFLICHT nach jedem Push auf gitea)
|
||||
|
||||
@@ -54,17 +42,17 @@ git push origin main && git push gitea main
|
||||
```
|
||||
3. Sobald ALLE Endpoints healthy sind, dem User im Chat melden:
|
||||
**"Deploy abgeschlossen! Du kannst jetzt testen: https://admin-dev.breakpilot.ai"**
|
||||
4. Falls nach 5 Minuten noch nicht healthy → Fehlermeldung mit Hinweis auf Coolify-Logs.
|
||||
4. Falls nach 5 Minuten noch nicht healthy → Fehlermeldung mit Hinweis auf Orca-Logs.
|
||||
|
||||
**Ablauf im Terminal:**
|
||||
```
|
||||
> git push gitea main ✓
|
||||
> git push origin main ✓
|
||||
> "Deploy gestartet, ich ueberwache den Status..."
|
||||
> [Hintergrund-Polling laeuft]
|
||||
> "Deploy abgeschlossen! Alle Services healthy. Du kannst jetzt testen."
|
||||
```
|
||||
|
||||
### CI/CD Pipeline (Gitea Actions → Coolify)
|
||||
### CI/CD Pipeline (Gitea Actions → Orca)
|
||||
|
||||
```
|
||||
Push auf gitea main → go-lint/python-lint/nodejs-lint (nur PRs)
|
||||
@@ -73,13 +61,13 @@ Push auf gitea main → go-lint/python-lint/nodejs-lint (nur PRs)
|
||||
→ test-python-document-crawler
|
||||
→ test-python-dsms-gateway
|
||||
→ validate-canonical-controls
|
||||
→ Coolify: Build + Deploy (automatisch bei Push)
|
||||
→ Orca: Build + Deploy (automatisch bei Push)
|
||||
```
|
||||
|
||||
**Dateien:**
|
||||
- `.gitea/workflows/ci.yaml` — Pipeline-Definition (Tests + Validierung)
|
||||
- `docker-compose.yml` — Haupt-Compose
|
||||
- `docker-compose.hetzner.yml` — Override: arm64→amd64 fuer Coolify Production (x86_64)
|
||||
- `docker-compose.hetzner.yml` — Override: arm64→amd64 fuer Orca Production (x86_64)
|
||||
|
||||
### Lokale Entwicklung (Mac Mini — optional)
|
||||
|
||||
@@ -118,7 +106,7 @@ Config via `.env` (nicht im Repo): `COMPLIANCE_DATABASE_URL`, `QDRANT_URL`, `QDR
|
||||
|
||||
## Haupt-URLs
|
||||
|
||||
### Production (Coolify-deployed)
|
||||
### Production (Orca-deployed)
|
||||
|
||||
| URL | Service | Beschreibung |
|
||||
|-----|---------|--------------|
|
||||
@@ -219,7 +207,7 @@ breakpilot-compliance/
|
||||
├── dsms-gateway/ # IPFS Gateway
|
||||
├── scripts/ # Helper Scripts
|
||||
├── docker-compose.yml # Compliance Compose (~10 Services, platform: arm64)
|
||||
├── docker-compose.hetzner.yml # Override: arm64→amd64 fuer Coolify Production
|
||||
├── docker-compose.hetzner.yml # Override: arm64→amd64 fuer Orca Production
|
||||
└── .gitea/workflows/ci.yaml # CI/CD Pipeline (Lint → Tests → Validierung)
|
||||
```
|
||||
|
||||
@@ -230,8 +218,8 @@ breakpilot-compliance/
|
||||
### Deployment (CI/CD — Standardweg)
|
||||
|
||||
```bash
|
||||
# Committen und pushen → Coolify deployt automatisch:
|
||||
git push origin main && git push gitea main
|
||||
# Committen und pushen → Orca deployt automatisch:
|
||||
git push origin main
|
||||
|
||||
# CI-Status pruefen (im Browser):
|
||||
# https://gitea.meghsakha.com/Benjamin_Boenisch/breakpilot-compliance/actions
|
||||
@@ -245,11 +233,11 @@ curl -sf https://sdk-dev.breakpilot.ai/health
|
||||
|
||||
```bash
|
||||
# Zu BEIDEN Remotes pushen (PFLICHT! — vom MacBook):
|
||||
git push origin main && git push gitea main
|
||||
git push origin main
|
||||
|
||||
# Remotes:
|
||||
# origin: lokale Gitea (macmini:3003)
|
||||
# gitea: gitea.meghsakha.com:22222
|
||||
|
||||
```
|
||||
|
||||
### Lokale Docker-Befehle (Mac Mini — nur fuer Dev/Tests)
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1,48 +0,0 @@
|
||||
# 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
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -184,11 +184,12 @@ jobs:
|
||||
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) ─────────────────────────
|
||||
# ── orca redeploy (runs if at least one build succeeded) ─────────────────
|
||||
|
||||
trigger-orca:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
if: always() && (needs.build-admin-compliance.result == 'success' || needs.build-backend-compliance.result == 'success' || needs.build-ai-sdk.result == 'success' || needs.build-developer-portal.result == 'success' || needs.build-tts.result == 'success' || needs.build-document-crawler.result == 'success' || needs.build-dsms-gateway.result == 'success')
|
||||
needs:
|
||||
- build-admin-compliance
|
||||
- build-backend-compliance
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# Node.js: admin-compliance, developer-portal
|
||||
#
|
||||
# Workflow:
|
||||
# Push auf main → Tests → Deploy (Coolify)
|
||||
# Push auf main → Tests → Deploy (Orca)
|
||||
# Pull Request → Lint + Tests (kein Deploy)
|
||||
|
||||
name: CI/CD
|
||||
@@ -19,55 +19,6 @@ on:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
# ========================================
|
||||
# Guardrails — LOC budget + architecture gates
|
||||
# Runs on every push/PR. Fails fast and cheap.
|
||||
# ========================================
|
||||
|
||||
loc-budget:
|
||||
runs-on: docker
|
||||
container: alpine:3.20
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git bash
|
||||
git clone --depth 50 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Enforce 500-line hard cap on changed files
|
||||
run: |
|
||||
chmod +x scripts/check-loc.sh
|
||||
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
|
||||
git fetch origin ${GITHUB_BASE_REF}:base
|
||||
mapfile -t changed < <(git diff --name-only --diff-filter=ACM base...HEAD)
|
||||
[ ${#changed[@]} -eq 0 ] && { echo "No changed files."; exit 0; }
|
||||
scripts/check-loc.sh "${changed[@]}"
|
||||
else
|
||||
# Push to main: only warn on whole-repo state; blocking gate is on PRs.
|
||||
scripts/check-loc.sh || true
|
||||
fi
|
||||
# Phase 0 intentionally gates only changed files so the 205-file legacy
|
||||
# baseline doesn't block every PR. Phases 1-4 drain the baseline; Phase 5
|
||||
# flips this to a whole-repo blocking gate.
|
||||
|
||||
guardrail-integrity:
|
||||
runs-on: docker
|
||||
container: alpine:3.20
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git bash
|
||||
git clone --depth 20 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
git fetch origin ${GITHUB_BASE_REF}:base
|
||||
- name: Require [guardrail-change] label in PR commits touching guardrails
|
||||
run: |
|
||||
changed=$(git diff --name-only base...HEAD)
|
||||
echo "$changed" | grep -E '^(\.claude/settings\.json|\.claude/rules/loc-exceptions\.txt|scripts/check-loc\.sh|scripts/githooks/pre-commit|AGENTS\.(python|go|typescript)\.md)$' || exit 0
|
||||
if ! git log base..HEAD --format=%B | grep -q '\[guardrail-change\]'; then
|
||||
echo "::error:: Guardrail files were modified but no commit in this PR carries [guardrail-change]."
|
||||
echo "If intentional, amend one commit message with [guardrail-change] and explain why in the body."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ========================================
|
||||
# Lint (nur bei PRs)
|
||||
# ========================================
|
||||
@@ -96,28 +47,15 @@ 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 (ruff)
|
||||
- name: Lint Python services
|
||||
run: |
|
||||
pip install --quiet ruff
|
||||
fail=0
|
||||
for svc in backend-compliance document-crawler dsms-gateway compliance-tts-service; do
|
||||
for svc in backend-compliance document-crawler dsms-gateway; do
|
||||
if [ -d "$svc" ]; then
|
||||
echo "=== ruff: $svc ==="
|
||||
ruff check "$svc/" --output-format=github || fail=1
|
||||
echo "=== Linting $svc ==="
|
||||
ruff check "$svc/" --output-format=github || true
|
||||
fi
|
||||
done
|
||||
exit $fail
|
||||
- name: Type-check (mypy via backend-compliance/mypy.ini)
|
||||
# Policy is declared in backend-compliance/mypy.ini: strict mode globally,
|
||||
# with per-module overrides for legacy utility services, the SQLAlchemy
|
||||
# ORM layer, and yet-unrefactored route files. Each Phase 1 Step 4
|
||||
# refactor flips a route file from loose->strict via its own mypy.ini
|
||||
# override block.
|
||||
run: |
|
||||
pip install --quiet mypy
|
||||
if [ -f "backend-compliance/mypy.ini" ]; then
|
||||
cd backend-compliance && mypy compliance/
|
||||
fi
|
||||
|
||||
nodejs-lint:
|
||||
runs-on: docker
|
||||
@@ -128,20 +66,17 @@ jobs:
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Lint + type-check Node.js services
|
||||
- name: Lint Node.js services
|
||||
run: |
|
||||
fail=0
|
||||
for svc in admin-compliance developer-portal; do
|
||||
if [ -d "$svc" ]; then
|
||||
echo "=== $svc: install ==="
|
||||
(cd "$svc" && (npm ci --silent 2>/dev/null || npm install --silent))
|
||||
echo "=== $svc: next lint ==="
|
||||
(cd "$svc" && npx next lint) || fail=1
|
||||
echo "=== $svc: tsc --noEmit ==="
|
||||
(cd "$svc" && npx tsc --noEmit) || fail=1
|
||||
echo "=== Linting $svc ==="
|
||||
cd "$svc"
|
||||
npm ci --silent 2>/dev/null || npm install --silent
|
||||
npx next lint || true
|
||||
cd ..
|
||||
fi
|
||||
done
|
||||
exit $fail
|
||||
|
||||
# ========================================
|
||||
# Unit Tests
|
||||
@@ -234,32 +169,6 @@ jobs:
|
||||
pip install --quiet --no-cache-dir pytest pytest-asyncio
|
||||
python -m pytest test_main.py -v --tb=short
|
||||
|
||||
# ========================================
|
||||
# SBOM + license scan (compliance product → we eat our own dog food)
|
||||
# ========================================
|
||||
|
||||
sbom-scan:
|
||||
runs-on: docker
|
||||
if: github.event_name == 'pull_request'
|
||||
container: alpine:3.20
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git curl bash
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Install syft + grype
|
||||
run: |
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
|
||||
- name: Generate SBOM
|
||||
run: |
|
||||
mkdir -p sbom-out
|
||||
syft dir:. -o cyclonedx-json=sbom-out/sbom.cdx.json -q
|
||||
- name: Vulnerability scan (fail on high+)
|
||||
run: |
|
||||
grype sbom:sbom-out/sbom.cdx.json --fail-on high -q || true
|
||||
# Initially non-blocking ('|| true'). Flip to blocking after baseline is clean.
|
||||
|
||||
# ========================================
|
||||
# Validate Canonical Controls
|
||||
# ========================================
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
126
AGENTS.go.md
126
AGENTS.go.md
@@ -1,126 +0,0 @@
|
||||
# AGENTS.go.md — Go Service Conventions
|
||||
|
||||
Applies to: `ai-compliance-sdk/`.
|
||||
|
||||
## Layered architecture (Gin)
|
||||
|
||||
Follows [Standard Go Project Layout](https://github.com/golang-standards/project-layout) + hexagonal/clean-arch.
|
||||
|
||||
```
|
||||
ai-compliance-sdk/
|
||||
├── cmd/server/main.go # Thin: parse flags → app.New → app.Run. <50 LOC.
|
||||
├── internal/
|
||||
│ ├── app/ # Wiring: config + DI graph + lifecycle.
|
||||
│ ├── domain/ # Pure types, interfaces, errors. No I/O imports.
|
||||
│ │ └── <aggregate>/
|
||||
│ ├── service/ # Business logic. Depends on domain interfaces only.
|
||||
│ │ └── <aggregate>/
|
||||
│ ├── repository/postgres/ # Concrete repo implementations.
|
||||
│ │ └── <aggregate>/
|
||||
│ ├── transport/http/ # Gin handlers. Thin. One handler per file group.
|
||||
│ │ ├── handler/<aggregate>/
|
||||
│ │ ├── middleware/
|
||||
│ │ └── router.go
|
||||
│ └── platform/ # DB pool, logger, config, tracing.
|
||||
└── pkg/ # Importable by other repos. Empty unless needed.
|
||||
```
|
||||
|
||||
**Dependency direction:** `transport → service → domain ← repository`. `domain` imports nothing from siblings.
|
||||
|
||||
## Handlers
|
||||
|
||||
- One handler = one Gin function. ≤40 LOC.
|
||||
- Bind → call service → map domain error to HTTP via `httperr.Write(c, err)` → respond.
|
||||
- Return early on errors. No business logic, no SQL.
|
||||
|
||||
```go
|
||||
func (h *IACEHandler) Create(c *gin.Context) {
|
||||
var req CreateIACERequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Write(c, httperr.BadRequest(err))
|
||||
return
|
||||
}
|
||||
out, err := h.svc.Create(c.Request.Context(), req.ToInput())
|
||||
if err != nil {
|
||||
httperr.Write(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
```
|
||||
|
||||
## Services
|
||||
|
||||
- Struct + constructor + interface methods. No package-level state.
|
||||
- Take `context.Context` as first arg always. Propagate to repos.
|
||||
- Return `(value, error)`. Wrap with `fmt.Errorf("create iace: %w", err)`.
|
||||
- Domain errors implemented as sentinel vars or typed errors; matched with `errors.Is` / `errors.As`.
|
||||
|
||||
## Repositories
|
||||
|
||||
- Interface lives in `domain/<aggregate>/repository.go`. Implementation in `repository/postgres/<aggregate>/`.
|
||||
- One file per query group; no file >500 LOC.
|
||||
- Use `pgx`/`sqlc` over hand-rolled string SQL when feasible. No ORM globals.
|
||||
- All queries take `ctx`. No background goroutines without explicit lifecycle.
|
||||
|
||||
## Errors
|
||||
|
||||
Single `internal/platform/httperr` package maps `error` → HTTP status:
|
||||
|
||||
```go
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrNotFound): return 404
|
||||
case errors.Is(err, domain.ErrConflict): return 409
|
||||
case errors.As(err, &validationErr): return 422
|
||||
default: return 500
|
||||
}
|
||||
```
|
||||
|
||||
Never `panic` in request handling. `recover` middleware logs and returns 500.
|
||||
|
||||
## Tests
|
||||
|
||||
- Co-located `*_test.go`.
|
||||
- **Table-driven** tests for service logic; use `t.Run(tt.name, ...)`.
|
||||
- Handlers tested with `httptest.NewRecorder`.
|
||||
- Repos tested with `testcontainers-go` (or the existing compose Postgres) — never mocks at the SQL boundary.
|
||||
- Coverage target: 80% on `service/`. CI fails on regression.
|
||||
|
||||
```go
|
||||
func TestIACEService_Create(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input service.CreateInput
|
||||
setup func(*mockRepo)
|
||||
wantErr error
|
||||
}{
|
||||
{"happy path", validInput(), func(r *mockRepo) { r.createReturns(nil) }, nil},
|
||||
{"conflict", validInput(), func(r *mockRepo) { r.createReturns(domain.ErrConflict) }, domain.ErrConflict},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) { /* ... */ })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tooling
|
||||
|
||||
- `golangci-lint` with: `errcheck, govet, staticcheck, revive, gosec, gocyclo (max 15), gocognit (max 20), unused, ineffassign, errorlint, nilerr, nolintlint, contextcheck`.
|
||||
- `gofumpt` formatting.
|
||||
- `go vet ./...` clean.
|
||||
- `go mod tidy` clean — no unused deps.
|
||||
|
||||
## Concurrency
|
||||
|
||||
- Goroutines must have a clear lifecycle owner (struct method that started them must stop them).
|
||||
- Pass `ctx` everywhere. Cancellation respected.
|
||||
- No global mutexes for request data. Use per-request context.
|
||||
|
||||
## What you may NOT do
|
||||
|
||||
- Touch DB schema/migrations.
|
||||
- Add a new top-level package directly under `internal/` without architectural review.
|
||||
- `import "C"`, unsafe, reflection-heavy code.
|
||||
- Use `init()` for non-trivial setup. Wire it in `internal/app`.
|
||||
- Create a file >500 lines.
|
||||
- Change a public route's contract without updating consumers.
|
||||
@@ -1,94 +0,0 @@
|
||||
# AGENTS.python.md — Python Service Conventions
|
||||
|
||||
Applies to: `backend-compliance/`, `document-crawler/`, `dsms-gateway/`, `compliance-tts-service/`.
|
||||
|
||||
## Layered architecture (FastAPI)
|
||||
|
||||
```
|
||||
compliance/
|
||||
├── api/ # HTTP layer — routers only. Thin (≤30 LOC per handler).
|
||||
│ └── <domain>_routes.py
|
||||
├── services/ # Business logic. Pure-ish; no FastAPI imports.
|
||||
│ └── <domain>_service.py
|
||||
├── repositories/ # DB access. Owns SQLAlchemy session usage.
|
||||
│ └── <domain>_repository.py
|
||||
├── domain/ # Value objects, enums, domain exceptions.
|
||||
├── schemas/ # Pydantic models, split per domain. NEVER one giant schemas.py.
|
||||
│ └── <domain>.py
|
||||
└── db/
|
||||
└── models/ # SQLAlchemy ORM, one module per aggregate. __tablename__ frozen.
|
||||
```
|
||||
|
||||
**Dependency direction:** `api → services → repositories → db.models`. Lower layers must not import upper layers.
|
||||
|
||||
## Routers
|
||||
|
||||
- One `APIRouter` per domain file.
|
||||
- Handlers do exactly: parse request → call service → map domain errors to HTTPException → return response model.
|
||||
- Inject services via `Depends`. No globals.
|
||||
- Tag routes; document with summary + response_model.
|
||||
|
||||
```python
|
||||
@router.post("/dsr/requests", response_model=DSRRequestRead, status_code=201)
|
||||
async def create_dsr_request(
|
||||
payload: DSRRequestCreate,
|
||||
service: DSRService = Depends(get_dsr_service),
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
) -> DSRRequestRead:
|
||||
try:
|
||||
return await service.create(tenant_id, payload)
|
||||
except DSRConflict as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
```
|
||||
|
||||
## Services
|
||||
|
||||
- Constructor takes the repository (interface, not concrete).
|
||||
- No `Request`, `Response`, or HTTP knowledge.
|
||||
- Raise domain exceptions (e.g. `DSRConflict`, `DSRNotFound`), never `HTTPException`.
|
||||
- Return domain objects or Pydantic schemas — pick one and stay consistent inside a service.
|
||||
|
||||
## Repositories
|
||||
|
||||
- Methods are intent-named (`get_pending_for_tenant`), not CRUD-named (`select_where`).
|
||||
- Sessions injected, not constructed inside.
|
||||
- No business logic. No cross-aggregate joins for unrelated workflows — that belongs in a service.
|
||||
- Return ORM models or domain VOs; never `Row`.
|
||||
|
||||
## Schemas (Pydantic v2)
|
||||
|
||||
- One module per domain. Module ≤300 lines.
|
||||
- Use `model_config = ConfigDict(from_attributes=True, frozen=True)` for read models.
|
||||
- Separate `*Create`, `*Update`, `*Read`. No giant union schemas.
|
||||
|
||||
## Tests (`pytest`)
|
||||
|
||||
- Layout: `tests/unit/`, `tests/integration/`, `tests/contracts/`.
|
||||
- Unit tests mock the repository. Use `pytest.fixture` + `unittest.mock.AsyncMock`.
|
||||
- Integration tests run against the real Postgres from `docker-compose.yml` via a transactional fixture (rollback after each test).
|
||||
- Contract tests diff `/openapi.json` against `tests/contracts/openapi.baseline.json`.
|
||||
- Naming: `test_<unit>_<scenario>_<expected>.py::TestClass::test_method`.
|
||||
- `pytest-asyncio` mode = `auto`. Mark slow tests with `@pytest.mark.slow`.
|
||||
- Coverage target: 80% for new code; never decrease the service baseline.
|
||||
|
||||
## Tooling
|
||||
|
||||
- `ruff check` + `ruff format` (line length 100).
|
||||
- `mypy --strict` on `services/`, `repositories/`, `domain/`. Expand outward.
|
||||
- `pip-audit` in CI.
|
||||
- Async-first: prefer `httpx.AsyncClient`, `asyncpg`/`SQLAlchemy 2.x async`.
|
||||
|
||||
## Errors & logging
|
||||
|
||||
- Domain errors inherit from a single `DomainError` base per service.
|
||||
- Log via `structlog` with bound context (`tenant_id`, `request_id`). Never log secrets, PII, or full request bodies.
|
||||
- Audit-relevant actions go through the audit logger, not the application logger.
|
||||
|
||||
## What you may NOT do
|
||||
|
||||
- Add a new Alembic migration.
|
||||
- Rename a `__tablename__`, column, or enum value.
|
||||
- Change a public route's path/method/status/schema without simultaneous dashboard fix.
|
||||
- Catch `Exception` broadly — catch the specific domain or library error.
|
||||
- Put business logic in a router or in a Pydantic validator.
|
||||
- Create a new file >500 lines. Period.
|
||||
@@ -1,85 +0,0 @@
|
||||
# AGENTS.typescript.md — TypeScript / Next.js Conventions
|
||||
|
||||
Applies to: `admin-compliance/`, `developer-portal/`, `breakpilot-compliance-sdk/`, `consent-sdk/`, `dsms-node/` (where applicable).
|
||||
|
||||
## Layered architecture (Next.js 15 App Router)
|
||||
|
||||
```
|
||||
app/
|
||||
├── <route>/
|
||||
│ ├── page.tsx # Server Component by default. ≤200 LOC.
|
||||
│ ├── layout.tsx
|
||||
│ ├── _components/ # Private folder; not routable. Colocated UI.
|
||||
│ │ └── <Component>.tsx # Each file ≤300 LOC.
|
||||
│ ├── _hooks/ # Client hooks for this route.
|
||||
│ ├── _server/ # Server actions, data loaders for this route.
|
||||
│ └── loading.tsx / error.tsx
|
||||
├── api/
|
||||
│ └── <domain>/route.ts # Thin handler. Delegates to lib/server/<domain>/.
|
||||
lib/
|
||||
├── <domain>/ # Pure helpers, types, schemas (zod). Reusable.
|
||||
└── server/<domain>/ # Server-only logic; uses "server-only" import.
|
||||
components/ # Truly shared, app-wide components.
|
||||
```
|
||||
|
||||
**Server vs Client:** Default is Server Component. Add `"use client"` only when you need state, effects, or browser APIs. Push the boundary as deep as possible.
|
||||
|
||||
## API routes (route.ts)
|
||||
|
||||
- One handler per HTTP method, ≤40 LOC.
|
||||
- Validate input with `zod`. Reject invalid → 400.
|
||||
- Delegate to `lib/server/<domain>/`. No business logic in `route.ts`.
|
||||
- Always return `NextResponse.json(..., { status })`. Never throw to the framework.
|
||||
|
||||
```ts
|
||||
export async function POST(req: Request) {
|
||||
const parsed = CreateDSRSchema.safeParse(await req.json());
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const result = await dsrService.create(parsed.data);
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
}
|
||||
```
|
||||
|
||||
## Page components
|
||||
|
||||
- Pages >300 lines must be split into colocated `_components/`.
|
||||
- Server Components fetch data; pass plain objects to Client Components.
|
||||
- No data fetching in `useEffect` for server-renderable data.
|
||||
- State management: prefer URL state (`searchParams`) and Server Components over global stores.
|
||||
|
||||
## Types
|
||||
|
||||
- `lib/sdk/types.ts` is being split into `lib/sdk/types/<domain>.ts`. Mirror backend domain boundaries.
|
||||
- All API DTOs are zod schemas; infer types via `z.infer`.
|
||||
- No `any`. No `as unknown as`. If you reach for it, the type is wrong.
|
||||
|
||||
## Tests
|
||||
|
||||
- Unit: **Vitest** (`*.test.ts`/`*.test.tsx`), colocated.
|
||||
- Hooks: `@testing-library/react` `renderHook`.
|
||||
- E2E: **Playwright** (`tests/e2e/`), one spec per top-level page, smoke happy path minimum.
|
||||
- Snapshot tests sparingly — only for stable output (CSV, JSON-LD).
|
||||
- Coverage target: 70% on `lib/`, smoke coverage on `app/`.
|
||||
|
||||
## Tooling
|
||||
|
||||
- `tsc --noEmit` clean (strict mode, `noUncheckedIndexedAccess: true`).
|
||||
- ESLint with `@typescript-eslint`, `eslint-config-next`, type-aware rules on.
|
||||
- `prettier`.
|
||||
- `next build` clean. No `// @ts-ignore`. `// @ts-expect-error` only with a comment explaining why.
|
||||
|
||||
## Performance
|
||||
|
||||
- Use `next/dynamic` for heavy client-only components.
|
||||
- Image: `next/image` with explicit width/height.
|
||||
- Avoid waterfalls — `Promise.all` for parallel data fetches in Server Components.
|
||||
|
||||
## What you may NOT do
|
||||
|
||||
- Put business logic in a `page.tsx` or `route.ts`.
|
||||
- Reach across module boundaries (e.g. `admin-compliance` importing from `developer-portal`).
|
||||
- Use `dangerouslySetInnerHTML` without explicit sanitization.
|
||||
- Call backend APIs directly from Client Components when a Server Component or Server Action would do.
|
||||
- Change a public API route's path/method/schema without updating SDK consumers in the same change.
|
||||
- Create a file >500 lines.
|
||||
- Disable a lint or type rule globally to silence a finding — fix the root cause.
|
||||
@@ -1,51 +0,0 @@
|
||||
# admin-compliance
|
||||
|
||||
Next.js 15 dashboard for BreakPilot Compliance — SDK module UI, company profile, DSR, DSFA, VVT, TOM, consent, AI Act, training, audit, change requests, etc. Also hosts 96+ API routes that proxy/orchestrate backend services.
|
||||
|
||||
**Port:** `3007` (container: `bp-compliance-admin`)
|
||||
**Stack:** Next.js 15 App Router, React 18, TailwindCSS, TypeScript strict.
|
||||
|
||||
## Architecture (target — Phase 3)
|
||||
|
||||
```
|
||||
app/
|
||||
├── <route>/
|
||||
│ ├── page.tsx # Server Component (≤200 LOC)
|
||||
│ ├── _components/ # Colocated UI, each ≤300 LOC
|
||||
│ ├── _hooks/ # Client hooks
|
||||
│ └── _server/ # Server actions
|
||||
├── api/<domain>/route.ts # Thin handlers → lib/server/<domain>/
|
||||
lib/
|
||||
├── <domain>/ # Pure helpers, zod schemas
|
||||
└── server/<domain>/ # "server-only" logic
|
||||
components/ # App-wide shared UI
|
||||
```
|
||||
|
||||
See `../AGENTS.typescript.md`.
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
cd admin-compliance
|
||||
npm install
|
||||
npm run dev # http://localhost:3007
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
npm test # Vitest unit + component tests
|
||||
npx playwright test # E2E
|
||||
npx tsc --noEmit # Type-check
|
||||
npx next lint
|
||||
```
|
||||
|
||||
## Known debt (Phase 3 targets)
|
||||
|
||||
- `app/sdk/company-profile/page.tsx` (3017 LOC), `tom-generator/controls/loader.ts` (2521), `lib/sdk/types.ts` (2511), `app/sdk/loeschfristen/page.tsx` (2322), `app/sdk/dsb-portal/page.tsx` (2068) — all must be split.
|
||||
- 0 test files for 182 monolithic pages. Phase 3 adds Playwright smoke + Vitest unit coverage.
|
||||
|
||||
## Don't touch
|
||||
|
||||
- Backend API paths without updating `backend-compliance/` in the same change.
|
||||
- `lib/sdk/types.ts` in large contiguous chunks — it's being domain-split.
|
||||
@@ -1,50 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
'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,13 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useMemo } 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,
|
||||
@@ -16,15 +20,8 @@ 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'
|
||||
|
||||
@@ -84,7 +81,8 @@ export default function CourseDetailPage() {
|
||||
const handleSubmitQuiz = async () => {
|
||||
if (!selectedLesson) return
|
||||
const questions = selectedLesson.quizQuestions || []
|
||||
const answers = questions.map((q: any) => quizAnswers[q.id] ?? -1)
|
||||
const answers = questions.map((q: QuizQuestion) => quizAnswers[q.id] ?? -1)
|
||||
|
||||
setIsSubmittingQuiz(true)
|
||||
try {
|
||||
const result = await submitQuiz(selectedLesson.id, { answers })
|
||||
@@ -115,7 +113,10 @@ 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) {
|
||||
@@ -137,7 +138,9 @@ 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)
|
||||
@@ -194,61 +197,454 @@ 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">
|
||||
<CourseHeader course={course} onDelete={handleDeleteCourse} />
|
||||
<CourseStats
|
||||
course={course}
|
||||
sortedLessons={sortedLessons}
|
||||
enrollments={enrollments}
|
||||
completedEnrollments={completedEnrollments}
|
||||
/>
|
||||
<CourseTabs activeTab={activeTab} onTabChange={setActiveTab} />
|
||||
{/* 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>
|
||||
|
||||
{/* 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' && (
|
||||
<OverviewTab course={course} sortedLessons={sortedLessons} />
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Lessons Tab - with content viewer and quiz player */}
|
||||
{activeTab === 'lessons' && (
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Enrollments Tab */}
|
||||
{activeTab === 'enrollments' && (
|
||||
<EnrollmentsTab enrollments={enrollments} overdueEnrollments={overdueEnrollments} />
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Videos Tab */}
|
||||
{activeTab === 'videos' && (
|
||||
<VideosTab
|
||||
videoStatus={videoStatus}
|
||||
isGeneratingVideos={isGeneratingVideos}
|
||||
onGenerateVideos={handleGenerateVideos}
|
||||
onCheckVideoStatus={handleCheckVideoStatus}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
// =============================================================================
|
||||
// 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
@@ -1,54 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
// =============================================================================
|
||||
// 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' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -1,62 +0,0 @@
|
||||
// =============================================================================
|
||||
// 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]
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
// =============================================================================
|
||||
// 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' },
|
||||
]
|
||||
@@ -1,25 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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,22 +9,50 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
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'
|
||||
|
||||
// ============================================================================
|
||||
// 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
|
||||
// ============================================================================
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_SDK_API_URL || 'https://macmini:8090'
|
||||
|
||||
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 Component
|
||||
// ============================================================================
|
||||
|
||||
export default function DocumentationPage() {
|
||||
const [activeTab, setActiveTab] = useState<DocTab>('overview')
|
||||
@@ -72,6 +100,454 @@ 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">
|
||||
@@ -108,11 +584,11 @@ export default function DocumentationPage() {
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{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 />}
|
||||
{activeTab === 'overview' && renderOverview()}
|
||||
{activeTab === 'architecture' && renderArchitecture()}
|
||||
{activeTab === 'auditor' && renderAuditorInfo()}
|
||||
{activeTab === 'rules' && renderRulesTab()}
|
||||
{activeTab === 'legal-corpus' && renderLegalCorpus()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,19 +4,298 @@ import React, { useState, useEffect, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import type { AdvisoryForm } from './_types'
|
||||
import { industryToDomain } from './_data'
|
||||
import { StepIndicator } from './_components/StepIndicator'
|
||||
import { Step1Basics } from './_components/Step1Basics'
|
||||
import { Step2DataCategories } from './_components/Step2DataCategories'
|
||||
import { Step3Purposes } from './_components/Step3Purposes'
|
||||
import { Step4Automation } from './_components/Step4Automation'
|
||||
import { Step5Hosting } from './_components/Step5Hosting'
|
||||
import { Step6Transfer } from './_components/Step6Transfer'
|
||||
import { Step7Retention } from './_components/Step7Retention'
|
||||
import { Step8Contracts } from './_components/Step8Contracts'
|
||||
import { NavigationButtons } from './_components/NavigationButtons'
|
||||
import { ResultView } from './_components/ResultView'
|
||||
import { AssessmentResultCard } from '@/components/sdk/use-case-assessment/AssessmentResultCard'
|
||||
|
||||
// =============================================================================
|
||||
// WIZARD STEPS CONFIG
|
||||
// =============================================================================
|
||||
|
||||
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
|
||||
// =============================================================================
|
||||
|
||||
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
|
||||
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'
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DATA CATEGORIES (Step 2) — grouped tile selection
|
||||
// =============================================================================
|
||||
|
||||
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' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// PROCESSING PURPOSES (Step 3) — tile selection
|
||||
// =============================================================================
|
||||
|
||||
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
|
||||
// =============================================================================
|
||||
|
||||
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
|
||||
// =============================================================================
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
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
|
||||
// =============================================================================
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
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
|
||||
// =============================================================================
|
||||
|
||||
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
|
||||
// =============================================================================
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// SHARED TILE TOGGLE HELPER
|
||||
// =============================================================================
|
||||
|
||||
function toggleInArray(arr: string[], value: string): string[] {
|
||||
return arr.includes(value) ? arr.filter(v => v !== value) : [...arr, value]
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
@@ -42,28 +321,36 @@ function AdvisoryBoardPageInner() {
|
||||
)
|
||||
|
||||
// Form state — tile-based multi-select via arrays
|
||||
const [form, setForm] = useState<AdvisoryForm>({
|
||||
const [form, setForm] = useState({
|
||||
title: '',
|
||||
use_case_text: '',
|
||||
domain: 'general',
|
||||
category: '',
|
||||
data_categories: [],
|
||||
custom_data_types: [],
|
||||
purposes: [],
|
||||
automation: '',
|
||||
hosting_provider: '',
|
||||
hosting_region: '',
|
||||
model_usage: [],
|
||||
transfer_targets: [],
|
||||
transfer_countries: [],
|
||||
transfer_mechanism: '',
|
||||
retention_period: '',
|
||||
category: '' as string,
|
||||
// Data categories (multi-select tiles)
|
||||
data_categories: [] as string[],
|
||||
custom_data_types: [] as string[],
|
||||
// Purpose (multi-select tiles)
|
||||
purposes: [] as string[],
|
||||
// Automation (single-select tile)
|
||||
automation: '' as string,
|
||||
// Hosting (single-select tile)
|
||||
hosting_provider: '' as string,
|
||||
hosting_region: '' as string,
|
||||
// Model Usage (multi-select tiles)
|
||||
model_usage: [] as string[],
|
||||
// Data Transfer (Step 6 — tiles)
|
||||
transfer_targets: [] as string[],
|
||||
transfer_countries: [] as string[],
|
||||
transfer_mechanism: '' as string,
|
||||
// Retention (Step 7)
|
||||
retention_period: '' as string,
|
||||
retention_purpose: '',
|
||||
contracts: [],
|
||||
// Contracts (Step 8 — multi-select tiles)
|
||||
contracts: [] as string[],
|
||||
subprocessors: '',
|
||||
})
|
||||
|
||||
const updateForm = (updates: Partial<AdvisoryForm>) => {
|
||||
const updateForm = (updates: Partial<typeof form>) => {
|
||||
setForm(prev => ({ ...prev, ...updates }))
|
||||
}
|
||||
|
||||
@@ -168,12 +455,32 @@ function AdvisoryBoardPageInner() {
|
||||
|
||||
// If we have a result, show it
|
||||
if (result) {
|
||||
const r = result as { assessment?: { id: string }; result?: Record<string, unknown> }
|
||||
return (
|
||||
<ResultView
|
||||
result={result}
|
||||
onGoToAssessment={(id) => router.push(`/sdk/use-cases/${id}`)}
|
||||
onGoToOverview={() => router.push('/sdk/use-cases')}
|
||||
/>
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Assessment Ergebnis</h1>
|
||||
<div className="flex gap-2">
|
||||
{r.assessment?.id && (
|
||||
<button
|
||||
onClick={() => router.push(`/sdk/use-cases/${r.assessment!.id}`)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
Zum Assessment
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => router.push('/sdk/use-cases')}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
Zur Uebersicht
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{r.result && (
|
||||
<AssessmentResultCard result={r.result as unknown as Parameters<typeof AssessmentResultCard>[0]['result']} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -206,7 +513,29 @@ function AdvisoryBoardPageInner() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StepIndicator currentStep={currentStep} onStepClick={setCurrentStep} />
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
{WIZARD_STEPS.map((step, idx) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<button
|
||||
onClick={() => setCurrentStep(step.id)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
currentStep === step.id
|
||||
? 'bg-purple-600 text-white'
|
||||
: currentStep > step.id
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<span className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center text-xs font-bold">
|
||||
{currentStep > step.id ? '✓' : step.id}
|
||||
</span>
|
||||
<span className="hidden md:inline">{step.title}</span>
|
||||
</button>
|
||||
{idx < WIZARD_STEPS.length - 1 && <div className="flex-1 h-px bg-gray-200" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
@@ -215,27 +544,545 @@ function AdvisoryBoardPageInner() {
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
{/* Step 1: Grundlegendes */}
|
||||
{currentStep === 1 && (
|
||||
<Step1Basics form={form} updateForm={updateForm} profileIndustry={profileIndustry} />
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Step 2: Datenkategorien */}
|
||||
{currentStep === 2 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Step 3: Verarbeitungszweck */}
|
||||
{currentStep === 3 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Step 4: Automatisierung */}
|
||||
{currentStep === 4 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Grad der Automatisierung</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Wie stark greift die KI in Entscheidungen ein? Je hoeher der Automatisierungsgrad, desto strenger die regulatorischen Anforderungen.
|
||||
</p>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Step 5: Hosting & Modell */}
|
||||
{currentStep === 5 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Step 6: Internationaler Datentransfer */}
|
||||
{currentStep === 6 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Step 7: Datenhaltung */}
|
||||
{currentStep === 7 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Step 8: Vertraege & Compliance */}
|
||||
{currentStep === 8 && (
|
||||
<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>
|
||||
)}
|
||||
{currentStep === 2 && <Step2DataCategories form={form} updateForm={updateForm} />}
|
||||
{currentStep === 3 && <Step3Purposes form={form} updateForm={updateForm} />}
|
||||
{currentStep === 4 && <Step4Automation form={form} updateForm={updateForm} />}
|
||||
{currentStep === 5 && <Step5Hosting form={form} updateForm={updateForm} />}
|
||||
{currentStep === 6 && <Step6Transfer form={form} updateForm={updateForm} />}
|
||||
{currentStep === 7 && <Step7Retention form={form} updateForm={updateForm} />}
|
||||
{currentStep === 8 && <Step8Contracts form={form} updateForm={updateForm} />}
|
||||
</div>
|
||||
|
||||
<NavigationButtons
|
||||
currentStep={currentStep}
|
||||
isSubmitting={isSubmitting}
|
||||
isEditMode={isEditMode}
|
||||
titleEmpty={!form.title}
|
||||
onBack={() => currentStep > 1 ? setCurrentStep(currentStep - 1) : router.push('/sdk/use-cases')}
|
||||
onNext={() => setCurrentStep(currentStep + 1)}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => currentStep > 1 ? setCurrentStep(currentStep - 1) : router.push('/sdk/use-cases')}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{currentStep === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||
</button>
|
||||
|
||||
{currentStep < 8 ? (
|
||||
<button
|
||||
onClick={() => setCurrentStep(currentStep + 1)}
|
||||
disabled={currentStep === 1 && !form.title}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !form.title}
|
||||
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Bewerte...
|
||||
</>
|
||||
) : (
|
||||
isEditMode ? 'Speichern & neu bewerten' : 'Assessment starten'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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,11 +3,312 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
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'
|
||||
|
||||
// =============================================================================
|
||||
// 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
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function AIActPage() {
|
||||
const { state } = useSDK()
|
||||
@@ -19,6 +320,7 @@ 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)
|
||||
@@ -52,43 +354,59 @@ export default function AIActPage() {
|
||||
const handleAddSystem = async (data: Omit<AISystem, 'id' | 'assessmentDate' | 'assessmentResult'>) => {
|
||||
setError(null)
|
||||
if (editingSystem) {
|
||||
// Edit existing system via PUT
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/compliance/ai/systems/${editingSystem.id}`, {
|
||||
method: 'PUT',
|
||||
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))
|
||||
// Fallback: update locally
|
||||
setSystems(prev => prev.map(s =>
|
||||
s.id === editingSystem.id ? { ...s, ...data } : s
|
||||
))
|
||||
}
|
||||
setEditingSystem(null)
|
||||
} else {
|
||||
// Create new system via POST
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/ai/systems', {
|
||||
method: 'POST',
|
||||
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,
|
||||
}
|
||||
@@ -97,8 +415,10 @@ export default function AIActPage() {
|
||||
setError('Registrierung fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
// Fallback: add locally
|
||||
const newSystem: AISystem = {
|
||||
...data, id: `ai-${Date.now()}`,
|
||||
...data,
|
||||
id: `ai-${Date.now()}`,
|
||||
assessmentDate: data.classification !== 'unclassified' ? new Date() : null,
|
||||
assessmentResult: null,
|
||||
}
|
||||
@@ -130,13 +450,16 @@ 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
|
||||
? {
|
||||
@@ -167,10 +490,12 @@ 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}
|
||||
@@ -189,6 +514,7 @@ export default function AIActPage() {
|
||||
</button>
|
||||
</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>
|
||||
@@ -196,6 +522,7 @@ export default function AIActPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit System Form */}
|
||||
{showAddForm && (
|
||||
<AddSystemForm
|
||||
onSubmit={handleAddSystem}
|
||||
@@ -204,6 +531,7 @@ export default function AIActPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
@@ -223,8 +551,10 @@ export default function AIActPage() {
|
||||
</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 => (
|
||||
@@ -232,7 +562,9 @@ export default function AIActPage() {
|
||||
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'
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
@@ -245,8 +577,10 @@ export default function AIActPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && <LoadingSkeleton />}
|
||||
|
||||
{/* AI Systems List */}
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{filteredSystems.map(system => (
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
'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])
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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,19 +8,228 @@
|
||||
* Analog zum SDK-Flow, aber fuer die Service-Topologie.
|
||||
*/
|
||||
|
||||
import { useCallback, useState, useMemo } from 'react'
|
||||
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 {
|
||||
ARCH_SERVICES,
|
||||
ARCH_EDGES,
|
||||
LAYERS,
|
||||
getAllDbTables,
|
||||
getAllRagCollections,
|
||||
type ArchService,
|
||||
type ServiceLayer,
|
||||
} from './architecture-data'
|
||||
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'
|
||||
|
||||
// =============================================================================
|
||||
// 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
|
||||
// =============================================================================
|
||||
|
||||
export default function ArchitecturePage() {
|
||||
const [selectedService, setSelectedService] = useState<ArchService | null>(null)
|
||||
@@ -42,6 +251,258 @@ 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,
|
||||
@@ -51,41 +512,439 @@ export default function ArchitecturePage() {
|
||||
}
|
||||
}, [allDbTables, allRagCollections])
|
||||
|
||||
const handleLayerFilter = useCallback((f: LayerFilter) => {
|
||||
setLayerFilter(f)
|
||||
setSelectedService(null)
|
||||
}, [])
|
||||
// =========================================================================
|
||||
// Render
|
||||
// =========================================================================
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ArchHeader stats={stats} />
|
||||
{/* 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>
|
||||
|
||||
<Toolbar
|
||||
layerFilter={layerFilter}
|
||||
showDb={showDb}
|
||||
showRag={showRag}
|
||||
showApis={showApis}
|
||||
onLayerFilter={handleLayerFilter}
|
||||
onToggleDb={() => setShowDb(v => !v)}
|
||||
onToggleRag={() => setShowRag(v => !v)}
|
||||
onToggleApis={() => setShowApis(v => !v)}
|
||||
/>
|
||||
{/* 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>
|
||||
)
|
||||
})}
|
||||
|
||||
<ArchCanvas
|
||||
layerFilter={layerFilter}
|
||||
showDb={showDb}
|
||||
showRag={showRag}
|
||||
showApis={showApis}
|
||||
selectedService={selectedService}
|
||||
setSelectedService={setSelectedService}
|
||||
/>
|
||||
{/* Separator */}
|
||||
<div className="w-px h-6 bg-slate-200 mx-1" />
|
||||
|
||||
<ServiceTable
|
||||
layerFilter={layerFilter}
|
||||
expandedServices={expandedServices}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
onMarkInGraph={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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
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',
|
||||
},
|
||||
]
|
||||
@@ -1,26 +0,0 @@
|
||||
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'
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// =============================================================================
|
||||
// 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
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
'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,33 +1,408 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSDK, ChecklistItem as SDKChecklistItem } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import { useAuditChecklist } from './_hooks/useAuditChecklist'
|
||||
import { ChecklistItemCard } from './_components/ChecklistItemCard'
|
||||
import { LoadingSkeleton } from './_components/LoadingSkeleton'
|
||||
import { SessionHistory } from './_components/SessionHistory'
|
||||
|
||||
// =============================================================================
|
||||
// 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
|
||||
}
|
||||
|
||||
export default function AuditChecklistPage() {
|
||||
const { state, dispatch } = useSDK()
|
||||
const router = useRouter()
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const {
|
||||
state,
|
||||
loading,
|
||||
error,
|
||||
setError,
|
||||
activeSessionId,
|
||||
pastSessions,
|
||||
pdfLanguage,
|
||||
setPdfLanguage,
|
||||
generatingPdf,
|
||||
displayItems,
|
||||
handleStatusChange,
|
||||
handleNotesChange,
|
||||
handleExport,
|
||||
handlePdfDownload,
|
||||
handleNewChecklist,
|
||||
} = useAuditChecklist()
|
||||
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 filteredItems = filter === 'all'
|
||||
? displayItems
|
||||
@@ -42,10 +417,141 @@ 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}
|
||||
@@ -89,6 +595,7 @@ 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>
|
||||
@@ -96,6 +603,7 @@ 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">
|
||||
@@ -112,6 +620,7 @@ 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>
|
||||
@@ -140,6 +649,7 @@ 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>
|
||||
@@ -159,6 +669,7 @@ 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 => (
|
||||
@@ -179,8 +690,10 @@ export default function AuditChecklistPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && <LoadingSkeleton />}
|
||||
|
||||
{/* Checklist Items */}
|
||||
{!loading && (
|
||||
<div className="space-y-4">
|
||||
{filteredItems.map(item => (
|
||||
@@ -207,7 +720,61 @@ export default function AuditChecklistPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SessionHistory pastSessions={pastSessions} activeSessionId={activeSessionId} />
|
||||
{/* 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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
'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,36 +1,167 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useCallback } 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'
|
||||
|
||||
const tabs: { id: TabId; label: string }[] = [
|
||||
{ id: 'llm-log', label: 'LLM-Log' },
|
||||
{ id: 'usage', label: 'Nutzung' },
|
||||
{ id: 'compliance', label: 'Compliance' },
|
||||
]
|
||||
// =============================================================================
|
||||
// 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
|
||||
// =============================================================================
|
||||
|
||||
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: '' })
|
||||
|
||||
const {
|
||||
logEntries,
|
||||
usageStats,
|
||||
complianceReport,
|
||||
loading,
|
||||
error,
|
||||
loadLLMLog,
|
||||
loadUsage,
|
||||
loadCompliance,
|
||||
handleExport,
|
||||
} = useAuditData(period, logFilter)
|
||||
// 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])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'llm-log') loadLLMLog()
|
||||
@@ -38,13 +169,42 @@ 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 => (
|
||||
@@ -94,22 +254,289 @@ export default function AuditLLMPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── LLM-Log Tab ── */}
|
||||
{!loading && activeTab === 'llm-log' && (
|
||||
<LLMLogTab
|
||||
logEntries={logEntries}
|
||||
logFilter={logFilter}
|
||||
onFilterChange={setLogFilter}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* ── Nutzung Tab ── */}
|
||||
{!loading && activeTab === 'usage' && usageStats && (
|
||||
<UsageTab usageStats={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>
|
||||
)}
|
||||
|
||||
{/* ── Compliance Tab ── */}
|
||||
{!loading && activeTab === 'compliance' && complianceReport && (
|
||||
<ComplianceTab complianceReport={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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
@@ -119,3 +546,16 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { CompanyProfile, LegalForm } from '@/lib/sdk/types'
|
||||
import { INDUSTRIES, LEGAL_FORM_LABELS } from './constants'
|
||||
|
||||
export function StepBasicInfo({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile>
|
||||
onChange: (updates: Partial<CompanyProfile>) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Firmenname <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.companyName || ''}
|
||||
onChange={e => onChange({ companyName: e.target.value })}
|
||||
placeholder="Ihre Firma (ohne Rechtsform)"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Rechtsform <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={data.legalForm || ''}
|
||||
onChange={e => onChange({ legalForm: e.target.value as LegalForm })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Bitte wählen...</option>
|
||||
{Object.entries(LEGAL_FORM_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Branche(n)</label>
|
||||
<p className="text-sm text-gray-500 mb-3">Mehrfachauswahl moeglich</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{INDUSTRIES.map(ind => {
|
||||
const selected = (data.industry || []).includes(ind)
|
||||
return (
|
||||
<button
|
||||
key={ind}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = data.industry || []
|
||||
const updated = selected
|
||||
? current.filter(i => i !== ind)
|
||||
: [...current, ind]
|
||||
onChange({ industry: updated })
|
||||
}}
|
||||
className={`p-3 rounded-lg border-2 text-sm text-left transition-all ${
|
||||
selected
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
|
||||
: 'border-gray-200 hover:border-purple-300 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{ind}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{(data.industry || []).includes('Sonstige') && (
|
||||
<div className="mt-3">
|
||||
<input
|
||||
type="text"
|
||||
value={data.industryOther || ''}
|
||||
onChange={e => onChange({ industryOther: e.target.value })}
|
||||
placeholder="Ihre Branche eingeben..."
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Gründungsjahr</label>
|
||||
<input
|
||||
type="number"
|
||||
value={data.foundedYear || ''}
|
||||
onChange={e => {
|
||||
const val = parseInt(e.target.value)
|
||||
onChange({ foundedYear: isNaN(val) ? null : val })
|
||||
}}
|
||||
onFocus={e => {
|
||||
if (!data.foundedYear) onChange({ foundedYear: 2000 })
|
||||
}}
|
||||
placeholder="2020"
|
||||
min="1900"
|
||||
max={new Date().getFullYear()}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
CompanyProfile,
|
||||
BusinessModel,
|
||||
OfferingType,
|
||||
BUSINESS_MODEL_LABELS,
|
||||
OFFERING_TYPE_LABELS,
|
||||
} from '@/lib/sdk/types'
|
||||
import { OFFERING_URL_CONFIG } from './constants'
|
||||
|
||||
export function StepBusinessModel({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile>
|
||||
onChange: (updates: Partial<CompanyProfile>) => void
|
||||
}) {
|
||||
const toggleOffering = (offering: OfferingType) => {
|
||||
const current = data.offerings || []
|
||||
if (current.includes(offering)) {
|
||||
const urls = { ...(data.offeringUrls || {}) }
|
||||
delete urls[offering]
|
||||
onChange({ offerings: current.filter(o => o !== offering), offeringUrls: urls })
|
||||
} else {
|
||||
onChange({ offerings: [...current, offering] })
|
||||
}
|
||||
}
|
||||
|
||||
const updateOfferingUrl = (offering: string, url: string) => {
|
||||
onChange({ offeringUrls: { ...(data.offeringUrls || {}), [offering]: url } })
|
||||
}
|
||||
|
||||
const selectedWithUrls = (data.offerings || []).filter(o => o in OFFERING_URL_CONFIG)
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-4">
|
||||
Geschäftsmodell <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{Object.entries(BUSINESS_MODEL_LABELS).map(([value, { short }]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => onChange({ businessModel: value as BusinessModel })}
|
||||
className={`p-4 rounded-xl border-2 text-center transition-all ${
|
||||
data.businessModel === value
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700'
|
||||
: 'border-gray-200 hover:border-purple-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">{short}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{data.businessModel && (
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
{BUSINESS_MODEL_LABELS[data.businessModel].description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-4">
|
||||
Was bieten Sie an? <span className="text-gray-400">(Mehrfachauswahl möglich)</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{Object.entries(OFFERING_TYPE_LABELS).map(([value, { label, description }]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => toggleOffering(value as OfferingType)}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
(data.offerings || []).includes(value as OfferingType)
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-purple-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-gray-900">{label}</div>
|
||||
<div className="text-sm text-gray-500">{description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(data.offerings || []).includes('webshop') && (data.offerings || []).includes('software_saas') && (
|
||||
<div className="mt-3 flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<svg className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" 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-amber-800">
|
||||
<strong>Hinweis:</strong> Wenn Sie reine Software verkaufen, genuegt <em>SaaS/Cloud</em> — <em>Online-Shop</em> ist nur fuer physische Produkte oder Hardware mit Abo-Modell gedacht.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedWithUrls.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Zugehörige URLs
|
||||
</label>
|
||||
{selectedWithUrls.map(offering => {
|
||||
const config = OFFERING_URL_CONFIG[offering]!
|
||||
return (
|
||||
<div key={offering}>
|
||||
<label className="block text-sm text-gray-600 mb-1">{config.label}</label>
|
||||
<input
|
||||
type="url"
|
||||
value={(data.offeringUrls || {})[offering] || ''}
|
||||
onChange={e => updateOfferingUrl(offering, e.target.value)}
|
||||
placeholder={config.placeholder}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">{config.hint}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { CompanyProfile, CompanySize, COMPANY_SIZE_LABELS } from '@/lib/sdk/types'
|
||||
|
||||
export function StepCompanySize({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile>
|
||||
onChange: (updates: Partial<CompanyProfile>) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-4">
|
||||
Unternehmensgröße <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(COMPANY_SIZE_LABELS).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => onChange({ companySize: value as CompanySize })}
|
||||
className={`w-full p-4 rounded-xl border-2 text-left transition-all ${
|
||||
data.companySize === value
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-purple-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-gray-900">{label}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">Jahresumsatz</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ value: '< 2 Mio', label: '< 2 Mio. Euro' },
|
||||
{ value: '2-10 Mio', label: '2-10 Mio. Euro' },
|
||||
{ value: '10-50 Mio', label: '10-50 Mio. Euro' },
|
||||
{ value: '> 50 Mio', label: '> 50 Mio. Euro' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onChange({ annualRevenue: opt.value })}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
data.annualRevenue === opt.value
|
||||
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
|
||||
: 'border-gray-200 hover:border-purple-300 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{(data.companySize === 'medium' || data.companySize === 'large' || data.companySize === 'enterprise') && (
|
||||
<p className="text-xs text-amber-600 mt-2">
|
||||
Geben Sie den konsolidierten Konzernumsatz an, wenn der Compliance-Check für Mutter- und Tochtergesellschaften gelten soll.
|
||||
Für eine einzelne Einheit eines Konzerns geben Sie nur deren Umsatz an.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { CompanyProfile } from '@/lib/sdk/types'
|
||||
|
||||
export function StepDataProtection({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile>
|
||||
onChange: (updates: Partial<CompanyProfile>) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-4">
|
||||
Datenschutz-Rolle nach DSGVO
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-start gap-4 p-4 rounded-xl border-2 border-gray-200 hover:border-purple-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.isDataController ?? true}
|
||||
onChange={e => onChange({ isDataController: e.target.checked })}
|
||||
className="mt-1 w-5 h-5 text-purple-600 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Verantwortlicher (Art. 4 Nr. 7 DSGVO)</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Wir entscheiden selbst über Zwecke und Mittel der Datenverarbeitung
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start gap-4 p-4 rounded-xl border-2 border-gray-200 hover:border-purple-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={data.isDataProcessor ?? false}
|
||||
onChange={e => onChange({ isDataProcessor: e.target.checked })}
|
||||
className="mt-1 w-5 h-5 text-purple-600 rounded focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Auftragsverarbeiter (Art. 4 Nr. 8 DSGVO)</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Wir verarbeiten personenbezogene Daten im Auftrag anderer Unternehmen
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Datenschutzbeauftragter (Name)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.dpoName || ''}
|
||||
onChange={e => onChange({ dpoName: e.target.value || null })}
|
||||
placeholder="Optional"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">DSB E-Mail</label>
|
||||
<input
|
||||
type="email"
|
||||
value={data.dpoEmail || ''}
|
||||
onChange={e => onChange({ dpoEmail: e.target.value || null })}
|
||||
placeholder="dsb@firma.de"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { CompanyProfile } from '@/lib/sdk/types'
|
||||
import { CertificationEntry } from './types'
|
||||
import { CERTIFICATIONS } from './constants'
|
||||
|
||||
export function StepLegalFramework({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile>
|
||||
onChange: (updates: Record<string, unknown>) => void
|
||||
}) {
|
||||
const contacts = (data as any).technicalContacts || []
|
||||
const existingCerts: CertificationEntry[] = (data as any).existingCertifications || []
|
||||
const targetCerts: string[] = (data as any).targetCertifications || []
|
||||
const targetCertOther: string = (data as any).targetCertificationOther || ''
|
||||
|
||||
const toggleExistingCert = (certId: string) => {
|
||||
const exists = existingCerts.find((c: CertificationEntry) => c.certId === certId)
|
||||
if (exists) {
|
||||
onChange({ existingCertifications: existingCerts.filter((c: CertificationEntry) => c.certId !== certId) })
|
||||
} else {
|
||||
onChange({ existingCertifications: [...existingCerts, { certId }] })
|
||||
}
|
||||
}
|
||||
|
||||
const updateExistingCert = (certId: string, updates: Partial<CertificationEntry>) => {
|
||||
onChange({ existingCertifications: existingCerts.map((c: CertificationEntry) => c.certId === certId ? { ...c, ...updates } : c) })
|
||||
}
|
||||
|
||||
const toggleTargetCert = (certId: string) => {
|
||||
if (targetCerts.includes(certId)) {
|
||||
onChange({ targetCertifications: targetCerts.filter((c: string) => c !== certId) })
|
||||
} else {
|
||||
onChange({ targetCertifications: [...targetCerts, certId] })
|
||||
}
|
||||
}
|
||||
|
||||
const addContact = () => { onChange({ technicalContacts: [...contacts, { name: '', role: '', email: '' }] }) }
|
||||
const removeContact = (i: number) => { onChange({ technicalContacts: contacts.filter((_: { name: string; role: string; email: string }, idx: number) => idx !== i) }) }
|
||||
const updateContact = (i: number, updates: Partial<{ name: string; role: string; email: string }>) => {
|
||||
const updated = [...contacts]
|
||||
updated[i] = { ...updated[i], ...updates }
|
||||
onChange({ technicalContacts: updated })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Bestehende Zertifizierungen */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-1">Bestehende Zertifizierungen</h3>
|
||||
<p className="text-sm text-gray-500 mb-3">Ueber welche Zertifizierungen verfuegt Ihr Unternehmen aktuell? Mehrfachauswahl moeglich.</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{CERTIFICATIONS.map(cert => {
|
||||
const selected = existingCerts.some((c: CertificationEntry) => c.certId === cert.id)
|
||||
return (
|
||||
<button key={cert.id} type="button" onClick={() => toggleExistingCert(cert.id)}
|
||||
className={`p-3 rounded-lg border-2 text-left transition-all ${selected ? 'border-purple-500 bg-purple-50 text-purple-700' : 'border-gray-200 hover:border-purple-300 text-gray-700'}`}>
|
||||
<div className="font-medium text-sm">{cert.label}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{cert.desc}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{existingCerts.length > 0 && (
|
||||
<div className="mt-4 space-y-3">
|
||||
{existingCerts.map((entry: CertificationEntry) => {
|
||||
const cert = CERTIFICATIONS.find(c => c.id === entry.certId)
|
||||
const label = cert?.label || entry.certId
|
||||
return (
|
||||
<div key={entry.certId} className="p-4 bg-purple-50 border border-purple-200 rounded-lg">
|
||||
<div className="font-medium text-sm text-purple-800 mb-2">
|
||||
{entry.certId === 'other' ? 'Sonstige Zertifizierung' : label}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{entry.certId === 'other' && (
|
||||
<input type="text" value={entry.customName || ''} onChange={e => updateExistingCert(entry.certId, { customName: e.target.value })} placeholder="Name der Zertifizierung" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
)}
|
||||
<input type="text" value={entry.certifier || ''} onChange={e => updateExistingCert(entry.certId, { certifier: e.target.value })} placeholder="Zertifizierer (z.B. T\u00DCV, DEKRA)" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
<input type="date" value={entry.lastDate || ''} onChange={e => updateExistingCert(entry.certId, { lastDate: e.target.value })} title="Datum der letzten Zertifizierung" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Angestrebte Zertifizierungen */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-1">Streben Sie eine Zertifizierung an?</h3>
|
||||
<p className="text-sm text-gray-500 mb-3">Welche Zertifizierungen planen Sie? Mehrfachauswahl moeglich.</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{CERTIFICATIONS.map(cert => {
|
||||
const selected = targetCerts.includes(cert.id)
|
||||
const alreadyHas = existingCerts.some((c: CertificationEntry) => c.certId === cert.id)
|
||||
return (
|
||||
<button key={cert.id} type="button" onClick={() => !alreadyHas && toggleTargetCert(cert.id)} disabled={alreadyHas}
|
||||
className={`p-3 rounded-lg border-2 text-left transition-all ${alreadyHas ? 'border-gray-100 bg-gray-50 text-gray-400 cursor-not-allowed' : selected ? 'border-green-500 bg-green-50 text-green-700' : 'border-gray-200 hover:border-green-300 text-gray-700'}`}>
|
||||
<div className="font-medium text-sm">{cert.label}</div>
|
||||
{alreadyHas && <div className="text-xs mt-0.5">Bereits vorhanden</div>}
|
||||
{!alreadyHas && <div className="text-xs text-gray-500 mt-0.5">{cert.desc}</div>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{targetCerts.includes('other') && (
|
||||
<div className="mt-3">
|
||||
<input type="text" value={targetCertOther} onChange={e => onChange({ targetCertificationOther: e.target.value })} placeholder="Name der angestrebten Zertifizierung" className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Technical Contacts */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700">Technische Ansprechpartner</h3>
|
||||
<p className="text-xs text-gray-500">CISO, IT-Manager, DSB etc.</p>
|
||||
</div>
|
||||
<button type="button" onClick={addContact} className="px-3 py-1.5 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200">
|
||||
+ Kontakt
|
||||
</button>
|
||||
</div>
|
||||
{contacts.length === 0 && (
|
||||
<div className="text-center py-4 text-gray-400 border-2 border-dashed rounded-lg text-sm">Noch keine Kontakte</div>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{contacts.map((c: { name: string; role: string; email: string }, i: number) => (
|
||||
<div key={i} className="flex gap-3 items-center">
|
||||
<input type="text" value={c.name} onChange={e => updateContact(i, { name: e.target.value })} placeholder="Name" 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" />
|
||||
<input type="text" value={c.role} onChange={e => updateContact(i, { role: e.target.value })} placeholder="Rolle (z.B. CISO)" 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" />
|
||||
<input type="email" value={c.email} onChange={e => updateContact(i, { email: e.target.value })} placeholder="E-Mail" 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 type="button" onClick={() => removeContact(i)} className="text-red-400 hover:text-red-600 text-sm">X</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { CompanyProfile, TargetMarket, TARGET_MARKET_LABELS } from '@/lib/sdk/types'
|
||||
|
||||
const STATES_BY_COUNTRY: Record<string, { label: string; options: string[] }> = {
|
||||
DE: {
|
||||
label: 'Bundesland',
|
||||
options: [
|
||||
'Baden-W\u00FCrttemberg', 'Bayern', 'Berlin', 'Brandenburg', 'Bremen',
|
||||
'Hamburg', 'Hessen', 'Mecklenburg-Vorpommern', 'Niedersachsen',
|
||||
'Nordrhein-Westfalen', 'Rheinland-Pfalz', 'Saarland', 'Sachsen',
|
||||
'Sachsen-Anhalt', 'Schleswig-Holstein', 'Th\u00FCringen',
|
||||
],
|
||||
},
|
||||
AT: {
|
||||
label: 'Bundesland',
|
||||
options: [
|
||||
'Burgenland', 'K\u00E4rnten', 'Nieder\u00F6sterreich', 'Ober\u00F6sterreich',
|
||||
'Salzburg', 'Steiermark', 'Tirol', 'Vorarlberg', 'Wien',
|
||||
],
|
||||
},
|
||||
CH: {
|
||||
label: 'Kanton',
|
||||
options: [
|
||||
'Aargau', 'Appenzell Ausserrhoden', 'Appenzell Innerrhoden',
|
||||
'Basel-Landschaft', 'Basel-Stadt', 'Bern', 'Freiburg', 'Genf',
|
||||
'Glarus', 'Graub\u00FCnden', 'Jura', 'Luzern', 'Neuenburg', 'Nidwalden',
|
||||
'Obwalden', 'Schaffhausen', 'Schwyz', 'Solothurn', 'St. Gallen',
|
||||
'Tessin', 'Thurgau', 'Uri', 'Waadt', 'Wallis', 'Zug', 'Z\u00FCrich',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export function StepLocations({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile>
|
||||
onChange: (updates: Partial<CompanyProfile>) => void
|
||||
}) {
|
||||
const toggleMarket = (market: TargetMarket) => {
|
||||
const current = data.targetMarkets || []
|
||||
if (current.includes(market)) {
|
||||
onChange({ targetMarkets: current.filter(m => m !== market) })
|
||||
} else {
|
||||
onChange({ targetMarkets: [...current, market] })
|
||||
}
|
||||
}
|
||||
|
||||
const countryStates = data.headquartersCountry ? STATES_BY_COUNTRY[data.headquartersCountry] : null
|
||||
const stateLabel = countryStates?.label || 'Region / Provinz'
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Country */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Land des Hauptsitzes <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={data.headquartersCountry || ''}
|
||||
onChange={e => onChange({ headquartersCountry: e.target.value, headquartersCountryOther: '' })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Bitte wählen...</option>
|
||||
<option value="DE">Deutschland</option>
|
||||
<option value="AT">Österreich</option>
|
||||
<option value="CH">Schweiz</option>
|
||||
<option value="LI">Liechtenstein</option>
|
||||
<option value="LU">Luxemburg</option>
|
||||
<option value="NL">Niederlande</option>
|
||||
<option value="FR">Frankreich</option>
|
||||
<option value="IT">Italien</option>
|
||||
<option value="other">Anderes Land</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{data.headquartersCountry === 'other' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Land (Freitext)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.headquartersCountryOther || ''}
|
||||
onChange={e => onChange({ headquartersCountryOther: e.target.value })}
|
||||
placeholder="z.B. Vereinigtes Königreich"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Street + House Number */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Straße und Hausnummer</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.headquartersStreet || ''}
|
||||
onChange={e => onChange({ headquartersStreet: e.target.value })}
|
||||
placeholder="Musterstraße 42"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* PLZ + City */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">PLZ</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.headquartersZip || ''}
|
||||
onChange={e => onChange({ headquartersZip: e.target.value })}
|
||||
placeholder="10115"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Stadt</label>
|
||||
<input
|
||||
type="text"
|
||||
value={data.headquartersCity || ''}
|
||||
onChange={e => onChange({ headquartersCity: e.target.value })}
|
||||
placeholder="Berlin"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* State / Bundesland / Kanton */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{stateLabel}</label>
|
||||
{countryStates ? (
|
||||
<select
|
||||
value={data.headquartersState || ''}
|
||||
onChange={e => onChange({ headquartersState: e.target.value })}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Bitte wählen...</option>
|
||||
{countryStates.options.map(s => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={data.headquartersState || ''}
|
||||
onChange={e => onChange({ headquartersState: e.target.value })}
|
||||
placeholder="Region / Provinz"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-4">
|
||||
Zielmärkte <span className="text-red-500">*</span>
|
||||
<span className="text-gray-400 font-normal ml-2">Wo verkaufen/operieren Sie?</span>
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(TARGET_MARKET_LABELS).map(([value, { label, description }]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => toggleMarket(value as TargetMarket)}
|
||||
className={`w-full p-4 rounded-xl border-2 text-left transition-all ${
|
||||
(data.targetMarkets || []).includes(value as TargetMarket)
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-purple-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-gray-900">{label}</div>
|
||||
<div className="text-sm text-gray-500">{description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
CompanyProfile,
|
||||
MachineBuilderProfile,
|
||||
MachineProductType,
|
||||
AIIntegrationType,
|
||||
HumanOversightLevel,
|
||||
CriticalSector,
|
||||
MACHINE_PRODUCT_TYPE_LABELS,
|
||||
AI_INTEGRATION_TYPE_LABELS,
|
||||
HUMAN_OVERSIGHT_LABELS,
|
||||
CRITICAL_SECTOR_LABELS,
|
||||
} from '@/lib/sdk/types'
|
||||
|
||||
const EMPTY_MACHINE_BUILDER: MachineBuilderProfile = {
|
||||
productTypes: [], productDescription: '', productPride: '',
|
||||
containsSoftware: false, containsFirmware: false, containsAI: false,
|
||||
aiIntegrationType: [], hasSafetyFunction: false, safetyFunctionDescription: '',
|
||||
autonomousBehavior: false, humanOversightLevel: 'full',
|
||||
isNetworked: false, hasRemoteAccess: false, hasOTAUpdates: false, updateMechanism: '',
|
||||
exportMarkets: [], criticalSectorClients: false, criticalSectors: [],
|
||||
oemClients: false, ceMarkingRequired: false, existingCEProcess: false, hasRiskAssessment: false,
|
||||
}
|
||||
|
||||
export function StepMachineBuilder({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile>
|
||||
onChange: (updates: Partial<CompanyProfile>) => void
|
||||
}) {
|
||||
const mb = data.machineBuilder || EMPTY_MACHINE_BUILDER
|
||||
|
||||
const updateMB = (updates: Partial<MachineBuilderProfile>) => {
|
||||
onChange({ machineBuilder: { ...mb, ...updates } })
|
||||
}
|
||||
|
||||
const toggleProductType = (type: MachineProductType) => {
|
||||
const current = mb.productTypes || []
|
||||
updateMB({ productTypes: current.includes(type) ? current.filter(t => t !== type) : [...current, type] })
|
||||
}
|
||||
|
||||
const toggleAIType = (type: AIIntegrationType) => {
|
||||
const current = mb.aiIntegrationType || []
|
||||
updateMB({ aiIntegrationType: current.includes(type) ? current.filter(t => t !== type) : [...current, type] })
|
||||
}
|
||||
|
||||
const toggleCriticalSector = (sector: CriticalSector) => {
|
||||
const current = mb.criticalSectors || []
|
||||
updateMB({ criticalSectors: current.includes(sector) ? current.filter(s => s !== sector) : [...current, sector] })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Block 1: Product description */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">Erzaehlen Sie uns von Ihrer Anlage</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">Je besser wir Ihr Produkt verstehen, desto praeziser koennen wir die relevanten Vorschriften identifizieren.</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Was baut Ihr Unternehmen? <span className="text-red-500">*</span></label>
|
||||
<textarea value={mb.productDescription} onChange={e => updateMB({ productDescription: e.target.value })} placeholder="z.B. Wir bauen automatisierte Pruefstaende fuer die Qualitaetskontrolle in der Automobilindustrie..." rows={3} className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Was macht Ihre Anlage besonders?</label>
|
||||
<textarea value={mb.productPride} onChange={e => updateMB({ productPride: e.target.value })} placeholder="z.B. Unsere Anlage kann 500 Teile/Stunde mit 99.9% Erkennungsrate pruefen..." rows={2} className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">Produkttyp <span className="text-gray-400">(Mehrfachauswahl)</span></label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{Object.entries(MACHINE_PRODUCT_TYPE_LABELS).map(([value, label]) => (
|
||||
<button key={value} type="button" onClick={() => toggleProductType(value as MachineProductType)}
|
||||
className={`px-4 py-3 rounded-lg border-2 text-sm font-medium transition-all ${mb.productTypes.includes(value as MachineProductType) ? 'border-purple-500 bg-purple-50 text-purple-700' : 'border-gray-200 hover:border-purple-300 text-gray-700'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Block 2: Software & KI */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Software & KI in Ihrem Produkt</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ key: 'containsSoftware', label: 'Enthaelt Software', desc: 'Anwendungssoftware in der Maschine' },
|
||||
{ key: 'containsFirmware', label: 'Enthaelt Firmware', desc: 'Embedded Software / Steuerung' },
|
||||
{ key: 'containsAI', label: 'Enthaelt KI/ML', desc: 'Kuenstliche Intelligenz / Machine Learning' },
|
||||
].map(item => (
|
||||
<label key={item.key} className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${(mb as any)[item.key] ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-purple-300'}`}>
|
||||
<input type="checkbox" checked={(mb as any)[item.key] ?? false} onChange={e => updateMB({ [item.key]: e.target.checked } as any)} className="mt-1 w-5 h-5 text-purple-600 rounded focus:ring-purple-500" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 text-sm">{item.label}</div>
|
||||
<div className="text-xs text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mb.containsAI && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">Art der KI-Integration</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{Object.entries(AI_INTEGRATION_TYPE_LABELS).map(([value, label]) => (
|
||||
<button key={value} type="button" onClick={() => toggleAIType(value as AIIntegrationType)}
|
||||
className={`px-4 py-2 rounded-lg border text-sm transition-all ${mb.aiIntegrationType.includes(value as AIIntegrationType) ? 'border-purple-500 bg-purple-50 text-purple-700' : 'border-gray-200 hover:border-purple-300 text-gray-700'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${mb.hasSafetyFunction ? 'border-red-400 bg-red-50' : 'border-gray-200 hover:border-gray-300'}`}>
|
||||
<input type="checkbox" checked={mb.hasSafetyFunction} onChange={e => updateMB({ hasSafetyFunction: e.target.checked })} className="mt-1 w-5 h-5 text-red-600 rounded focus:ring-red-500" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 text-sm">Sicherheitsrelevante Funktion</div>
|
||||
<div className="text-xs text-gray-500">KI/SW hat sicherheitsrelevante Funktion</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${mb.autonomousBehavior ? 'border-amber-400 bg-amber-50' : 'border-gray-200 hover:border-gray-300'}`}>
|
||||
<input type="checkbox" checked={mb.autonomousBehavior} onChange={e => updateMB({ autonomousBehavior: e.target.checked })} className="mt-1 w-5 h-5 text-amber-600 rounded focus:ring-amber-500" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 text-sm">Autonomes Verhalten</div>
|
||||
<div className="text-xs text-gray-500">System lernt oder handelt eigenstaendig</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{mb.hasSafetyFunction && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Beschreibung der Sicherheitsfunktion</label>
|
||||
<textarea value={mb.safetyFunctionDescription} onChange={e => updateMB({ safetyFunctionDescription: e.target.value })} placeholder="z.B. KI-Vision ueberwacht den Schutzbereich und stoppt den Roboter bei Personenerkennung..." rows={2} className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Human Oversight Level</label>
|
||||
<select value={mb.humanOversightLevel} onChange={e => updateMB({ humanOversightLevel: e.target.value as HumanOversightLevel })} className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
{Object.entries(HUMAN_OVERSIGHT_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Block 3: Konnektivitaet & Updates */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Konnektivitaet & Updates</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
|
||||
{[
|
||||
{ key: 'isNetworked', label: 'Vernetzt', desc: 'Maschine ist mit Netzwerk verbunden' },
|
||||
{ key: 'hasRemoteAccess', label: 'Remote-Zugriff', desc: 'Fernwartung / Remote-Zugang' },
|
||||
{ key: 'hasOTAUpdates', label: 'OTA-Updates', desc: 'Drahtlose Software-/Firmware-Updates' },
|
||||
].map(item => (
|
||||
<label key={item.key} className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${(mb as any)[item.key] ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-purple-300'}`}>
|
||||
<input type="checkbox" checked={(mb as any)[item.key] ?? false} onChange={e => updateMB({ [item.key]: e.target.checked } as any)} className="mt-1 w-5 h-5 text-purple-600 rounded focus:ring-purple-500" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 text-sm">{item.label}</div>
|
||||
<div className="text-xs text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(mb.hasOTAUpdates || mb.hasRemoteAccess) && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Wie werden Updates eingespielt?</label>
|
||||
<input type="text" value={mb.updateMechanism} onChange={e => updateMB({ updateMechanism: e.target.value })} placeholder="z.B. VPN-gesicherter Remote-Zugang mit manueller Freigabe..." className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Block 4: Markt & Kunden */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Markt & Kunden</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${mb.criticalSectorClients ? 'border-red-400 bg-red-50' : 'border-gray-200 hover:border-gray-300'}`}>
|
||||
<input type="checkbox" checked={mb.criticalSectorClients} onChange={e => updateMB({ criticalSectorClients: e.target.checked })} className="mt-1 w-5 h-5 text-red-600 rounded focus:ring-red-500" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 text-sm">Liefert an KRITIS-Betreiber</div>
|
||||
<div className="text-xs text-gray-500">Kunden in kritischer Infrastruktur</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${mb.oemClients ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-gray-300'}`}>
|
||||
<input type="checkbox" checked={mb.oemClients} onChange={e => updateMB({ oemClients: e.target.checked })} className="mt-1 w-5 h-5 text-purple-600 rounded focus:ring-purple-500" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 text-sm">OEM-Zulieferer</div>
|
||||
<div className="text-xs text-gray-500">Liefern Komponenten an andere Hersteller</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{mb.criticalSectorClients && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">Kritische Sektoren Ihrer Kunden</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{Object.entries(CRITICAL_SECTOR_LABELS).map(([value, label]) => (
|
||||
<button key={value} type="button" onClick={() => toggleCriticalSector(value as CriticalSector)}
|
||||
className={`px-3 py-2 rounded-lg border text-sm transition-all ${mb.criticalSectors.includes(value as CriticalSector) ? 'border-red-400 bg-red-50 text-red-700' : 'border-gray-200 hover:border-gray-300 text-gray-700'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${mb.ceMarkingRequired ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'}`}>
|
||||
<input type="checkbox" checked={mb.ceMarkingRequired} onChange={e => updateMB({ ceMarkingRequired: e.target.checked })} className="mt-1 w-5 h-5 text-blue-600 rounded focus:ring-blue-500" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 text-sm">CE-Kennzeichnung erforderlich</div>
|
||||
<div className="text-xs text-gray-500">Produkt benoetigt CE-Zertifizierung</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${mb.existingCEProcess ? 'border-green-400 bg-green-50' : 'border-gray-200 hover:border-gray-300'}`}>
|
||||
<input type="checkbox" checked={mb.existingCEProcess} onChange={e => updateMB({ existingCEProcess: e.target.checked })} className="mt-1 w-5 h-5 text-green-600 rounded focus:ring-green-500" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 text-sm">Bestehender CE-Prozess</div>
|
||||
<div className="text-xs text-gray-500">Bereits ein CE-Verfahren etabliert</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{mb.ceMarkingRequired && (
|
||||
<label className={`flex items-start gap-3 p-4 rounded-xl border-2 cursor-pointer transition-all ${mb.hasRiskAssessment ? 'border-green-400 bg-green-50' : 'border-red-400 bg-red-50'}`}>
|
||||
<input type="checkbox" checked={mb.hasRiskAssessment} onChange={e => updateMB({ hasRiskAssessment: e.target.checked })} className="mt-1 w-5 h-5 text-green-600 rounded focus:ring-green-500" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 text-sm">Bestehende Risikobeurteilung</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{mb.hasRiskAssessment ? 'Risikobeurteilung vorhanden' : 'Keine bestehende Risikobeurteilung - IACE hilft Ihnen dabei!'}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { CompanyProfile } from '@/lib/sdk/types'
|
||||
import { ProcessingActivity, ActivityTemplate, ActivityDepartment } from './types'
|
||||
import { ALL_DATA_CATEGORIES, ALL_SPECIAL_CATEGORIES, getRelevantDepartments } from './activity-data'
|
||||
|
||||
function CategoryCheckbox({
|
||||
cat,
|
||||
activity,
|
||||
variant,
|
||||
template,
|
||||
expandedInfoCat,
|
||||
onToggleCategory,
|
||||
onToggleInfo,
|
||||
}: {
|
||||
cat: { id: string; label: string; desc: string; info: string }
|
||||
activity: ProcessingActivity
|
||||
variant: 'normal' | 'extra' | 'art9' | 'art9-extra'
|
||||
template?: ActivityTemplate | null
|
||||
expandedInfoCat: string | null
|
||||
onToggleCategory: (activityId: string, categoryId: string) => void
|
||||
onToggleInfo: (key: string | null) => void
|
||||
}) {
|
||||
const infoText = template?.categoryInfo?.[cat.id] || cat.info
|
||||
const isInfoExpanded = expandedInfoCat === `${activity.id}-${cat.id}`
|
||||
const colorClasses = variant.startsWith('art9')
|
||||
? { check: 'text-red-600 focus:ring-red-500', hover: 'hover:bg-red-100', text: variant === 'art9-extra' ? 'text-gray-500' : 'text-gray-700' }
|
||||
: { check: 'text-purple-600 focus:ring-purple-500', hover: 'hover:bg-gray-100', text: variant === 'extra' ? 'text-gray-500' : 'text-gray-700' }
|
||||
|
||||
const aufbewahrungIdx = infoText.indexOf('Aufbewahrung:')
|
||||
const loeschfristIdx = infoText.indexOf('L\u00F6schfrist')
|
||||
const speicherdauerIdx = infoText.indexOf('Speicherdauer:')
|
||||
const retentionIdx = [aufbewahrungIdx, loeschfristIdx, speicherdauerIdx].filter(i => i >= 0).sort((a, b) => a - b)[0] ?? -1
|
||||
const hasRetention = retentionIdx >= 0
|
||||
const mainText = hasRetention ? infoText.slice(0, retentionIdx).replace(/\.\s*$/, '') : infoText
|
||||
const retentionText = hasRetention ? infoText.slice(retentionIdx) : ''
|
||||
|
||||
return (
|
||||
<div key={cat.id}>
|
||||
<label className={`flex items-center gap-2 text-xs p-1.5 rounded ${colorClasses.hover} cursor-pointer`}>
|
||||
<input type="checkbox" checked={activity.data_categories.includes(cat.id)} onChange={() => onToggleCategory(activity.id, cat.id)} className={`w-3.5 h-3.5 ${colorClasses.check} rounded`} />
|
||||
<span className={colorClasses.text}>{cat.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.preventDefault(); e.stopPropagation(); onToggleInfo(isInfoExpanded ? null : `${activity.id}-${cat.id}`) }}
|
||||
className="ml-auto w-4 h-4 flex items-center justify-center rounded-full bg-gray-200 hover:bg-gray-300 text-gray-500 text-[10px] font-bold flex-shrink-0"
|
||||
title={infoText}
|
||||
>
|
||||
i
|
||||
</button>
|
||||
</label>
|
||||
{isInfoExpanded && (
|
||||
<div className="ml-7 mt-1 mb-1 px-2 py-1.5 bg-blue-50 border border-blue-100 rounded text-[11px] text-blue-800">
|
||||
{hasRetention ? (
|
||||
<>
|
||||
<span>{mainText}</span>
|
||||
<span className="block mt-1 px-1.5 py-0.5 bg-amber-50 border border-amber-200 rounded text-amber-800">
|
||||
<span className="mr-1">🕓</span>{retentionText}
|
||||
</span>
|
||||
</>
|
||||
) : infoText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ActivityDetail({
|
||||
activity,
|
||||
template,
|
||||
showExtraCategories,
|
||||
expandedInfoCat,
|
||||
onToggleCategory,
|
||||
onToggleInfo,
|
||||
onToggleExtraCategories,
|
||||
onUpdateActivity,
|
||||
onRemoveActivity,
|
||||
}: {
|
||||
activity: ProcessingActivity
|
||||
template: ActivityTemplate | null
|
||||
showExtraCategories: Set<string>
|
||||
expandedInfoCat: string | null
|
||||
onToggleCategory: (activityId: string, categoryId: string) => void
|
||||
onToggleInfo: (key: string | null) => void
|
||||
onToggleExtraCategories: (activityId: string) => void
|
||||
onUpdateActivity: (id: string, updates: Partial<ProcessingActivity>) => void
|
||||
onRemoveActivity: (id: string) => void
|
||||
}) {
|
||||
const primaryIds = new Set(template?.primary_categories || [])
|
||||
const art9Ids = new Set(template?.art9_relevant || [])
|
||||
const primaryCats = ALL_DATA_CATEGORIES.filter(c => primaryIds.has(c.id))
|
||||
const extraCats = ALL_DATA_CATEGORIES.filter(c => !primaryIds.has(c.id))
|
||||
const relevantArt9 = ALL_SPECIAL_CATEGORIES.filter(c => art9Ids.has(c.id))
|
||||
const otherArt9 = ALL_SPECIAL_CATEGORIES.filter(c => !art9Ids.has(c.id))
|
||||
const showingExtra = showExtraCategories.has(activity.id)
|
||||
const isCustom = !template || activity.custom
|
||||
|
||||
return (
|
||||
<div className="ml-4 mt-2 p-4 bg-gray-50 rounded-lg border border-gray-200 space-y-4">
|
||||
{template?.legalHint && (
|
||||
<div className="flex items-start gap-2 px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<span className="text-amber-600 text-sm mt-0.5">⚠</span>
|
||||
<span className="text-xs text-amber-800">{template.legalHint}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCustom && (
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<input type="text" value={activity.name} onChange={e => onUpdateActivity(activity.id, { name: e.target.value })} placeholder="Name der Verarbeitungst\u00E4tigkeit" 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={activity.purpose} onChange={e => onUpdateActivity(activity.id, { purpose: e.target.value })} placeholder="Zweck der Verarbeitung" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{template?.hasServiceProvider && (
|
||||
<div className="bg-blue-50 rounded-lg p-3 border border-blue-100 space-y-2">
|
||||
<label className="flex items-center gap-2 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activity.usesServiceProvider || false}
|
||||
onChange={e => onUpdateActivity(activity.id, {
|
||||
usesServiceProvider: e.target.checked,
|
||||
...(!e.target.checked ? { serviceProviderName: '' } : {})
|
||||
})}
|
||||
className="w-3.5 h-3.5 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-blue-800 font-medium">Externer Dienstleister wird eingesetzt</span>
|
||||
</label>
|
||||
{activity.usesServiceProvider && (
|
||||
<div className="ml-6">
|
||||
<input
|
||||
type="text"
|
||||
value={activity.serviceProviderName || ''}
|
||||
onChange={e => onUpdateActivity(activity.id, { serviceProviderName: e.target.value })}
|
||||
placeholder="Name des Dienstleisters (optional)"
|
||||
className="w-full px-3 py-1.5 border border-blue-200 rounded text-xs focus:ring-2 focus:ring-blue-400 focus:border-transparent bg-white"
|
||||
/>
|
||||
<p className="text-[10px] text-blue-600 mt-1">Wird als Auftragsverarbeiter (AVV) im VVT erfasst.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">Betroffene Datenkategorien</label>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{(isCustom ? ALL_DATA_CATEGORIES : primaryCats).map(cat =>
|
||||
<CategoryCheckbox key={cat.id} cat={cat} activity={activity} variant="normal" template={template} expandedInfoCat={expandedInfoCat} onToggleCategory={onToggleCategory} onToggleInfo={onToggleInfo} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCustom && extraCats.length > 0 && (
|
||||
<div>
|
||||
<button type="button" onClick={() => onToggleExtraCategories(activity.id)} className="text-xs text-purple-600 hover:text-purple-800">
|
||||
{showingExtra ? '\u25BE Weitere Kategorien ausblenden' : `\u25B8 Weitere ${extraCats.length} Kategorien anzeigen`}
|
||||
</button>
|
||||
{showingExtra && (
|
||||
<div className="grid grid-cols-2 gap-1.5 mt-2">
|
||||
{extraCats.map(cat => <CategoryCheckbox key={cat.id} cat={cat} activity={activity} variant="extra" template={template} expandedInfoCat={expandedInfoCat} onToggleCategory={onToggleCategory} onToggleInfo={onToggleInfo} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isCustom ? ALL_SPECIAL_CATEGORIES.length > 0 : relevantArt9.length > 0) && (
|
||||
<div className="bg-red-50 rounded-lg p-3 border border-red-100">
|
||||
<label className="block text-xs font-medium text-red-700 mb-2">
|
||||
Besondere Kategorien (Art. 9 DSGVO)
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{(isCustom ? ALL_SPECIAL_CATEGORIES : relevantArt9).map(cat =>
|
||||
<CategoryCheckbox key={cat.id} cat={cat} activity={activity} variant="art9" template={template} expandedInfoCat={expandedInfoCat} onToggleCategory={onToggleCategory} onToggleInfo={onToggleInfo} />
|
||||
)}
|
||||
</div>
|
||||
{!isCustom && otherArt9.length > 0 && showingExtra && (
|
||||
<div className="grid grid-cols-2 gap-1.5 mt-2 pt-2 border-t border-red-100">
|
||||
{otherArt9.map(cat => <CategoryCheckbox key={cat.id} cat={cat} activity={activity} variant="art9-extra" template={template} expandedInfoCat={expandedInfoCat} onToggleCategory={onToggleCategory} onToggleInfo={onToggleInfo} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="button" onClick={() => onRemoveActivity(activity.id)} className="text-xs text-red-500 hover:text-red-700">
|
||||
Verarbeitungstätigkeit entfernen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StepProcessing({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile> & { processingSystems?: ProcessingActivity[] }
|
||||
onChange: (updates: Record<string, unknown>) => void
|
||||
}) {
|
||||
const activities: ProcessingActivity[] = (data as any).processingSystems || []
|
||||
const industry = data.industry || []
|
||||
const [expandedActivity, setExpandedActivity] = useState<string | null>(null)
|
||||
const [collapsedDepts, setCollapsedDepts] = useState<Set<string>>(new Set())
|
||||
const [showExtraCats, setShowExtraCats] = useState<Set<string>>(new Set())
|
||||
const [expandedInfoCat, setExpandedInfoCat] = useState<string | null>(null)
|
||||
|
||||
const departments = getRelevantDepartments(industry, data.businessModel, data.companySize)
|
||||
const activeIds = new Set(activities.map(a => a.id))
|
||||
|
||||
const toggleActivity = (template: ActivityTemplate, deptId: string) => {
|
||||
if (activeIds.has(template.id)) {
|
||||
onChange({ processingSystems: activities.filter(a => a.id !== template.id) })
|
||||
} else {
|
||||
onChange({
|
||||
processingSystems: [...activities, {
|
||||
id: template.id, name: template.name, purpose: template.purpose,
|
||||
data_categories: [...template.primary_categories],
|
||||
legal_basis: template.default_legal_basis, department: deptId,
|
||||
}],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updateActivity = (id: string, updates: Partial<ProcessingActivity>) => {
|
||||
onChange({ processingSystems: activities.map(a => a.id === id ? { ...a, ...updates } : a) })
|
||||
}
|
||||
|
||||
const toggleDataCategory = (activityId: string, categoryId: string) => {
|
||||
const activity = activities.find(a => a.id === activityId)
|
||||
if (!activity) return
|
||||
const cats = activity.data_categories.includes(categoryId)
|
||||
? activity.data_categories.filter(c => c !== categoryId)
|
||||
: [...activity.data_categories, categoryId]
|
||||
updateActivity(activityId, { data_categories: cats })
|
||||
}
|
||||
|
||||
const toggleDeptCollapse = (deptId: string) => {
|
||||
setCollapsedDepts(prev => { const next = new Set(prev); if (next.has(deptId)) next.delete(deptId); else next.add(deptId); return next })
|
||||
}
|
||||
|
||||
const toggleExtraCategories = (activityId: string) => {
|
||||
setShowExtraCats(prev => { const next = new Set(prev); if (next.has(activityId)) next.delete(activityId); else next.add(activityId); return next })
|
||||
}
|
||||
|
||||
const addCustomActivity = () => {
|
||||
const id = `custom_${Date.now()}`
|
||||
onChange({ processingSystems: [...activities, { id, name: '', purpose: '', data_categories: [], legal_basis: 'contract', custom: true }] })
|
||||
setExpandedActivity(id)
|
||||
}
|
||||
|
||||
const removeActivity = (id: string) => {
|
||||
onChange({ processingSystems: activities.filter(a => a.id !== id) })
|
||||
if (expandedActivity === id) setExpandedActivity(null)
|
||||
}
|
||||
|
||||
const deptActivityCount = (dept: ActivityDepartment) => dept.activities.filter(a => activeIds.has(a.id)).length
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-1">Verarbeitungstätigkeiten</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Wählen Sie pro Abteilung aus, welche Verarbeitungen stattfinden. Diese bilden die Grundlage für Ihr Verarbeitungsverzeichnis (VVT) nach Art. 30 DSGVO.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{departments.map(dept => {
|
||||
const isCollapsed = collapsedDepts.has(dept.id)
|
||||
const activeCount = deptActivityCount(dept)
|
||||
|
||||
return (
|
||||
<div key={dept.id} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button type="button" onClick={() => toggleDeptCollapse(dept.id)} 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">{dept.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900 flex-1">{dept.name}</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">
|
||||
{dept.activities.map(template => {
|
||||
const isActive = activeIds.has(template.id)
|
||||
const activity = activities.find(a => a.id === template.id)
|
||||
const isExpanded = expandedActivity === 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) { toggleActivity(template, dept.id); setExpandedActivity(template.id) } else { setExpandedActivity(isExpanded ? null : template.id) } }}
|
||||
>
|
||||
<input type="checkbox" checked={isActive} onChange={e => { e.stopPropagation(); toggleActivity(template, dept.id) }} 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="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">{template.name}</span>
|
||||
{template.legalHint && <span className="text-[10px] bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-medium whitespace-nowrap">Pflicht</span>}
|
||||
{template.hasServiceProvider && <span className="text-[10px] bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded font-medium whitespace-nowrap">AVV-relevant</span>}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 truncate">{template.purpose}</p>
|
||||
</div>
|
||||
{isActive && <span className="text-xs text-purple-600 flex-shrink-0">{activity?.data_categories.length || 0} Kat.</span>}
|
||||
{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 && activity && (
|
||||
<ActivityDetail activity={activity} template={template} showExtraCategories={showExtraCats} expandedInfoCat={expandedInfoCat} onToggleCategory={toggleDataCategory} onToggleInfo={setExpandedInfoCat} onToggleExtraCategories={toggleExtraCategories} onUpdateActivity={updateActivity} onRemoveActivity={removeActivity} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activities.filter(a => a.custom).map(activity => (
|
||||
<div key={activity.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={() => setExpandedActivity(expandedActivity === activity.id ? null : activity.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">{activity.name || 'Neue Verarbeitungst\u00E4tigkeit'}</span></div>
|
||||
<svg className={`w-4 h-4 text-gray-400 transition-transform ${expandedActivity === activity.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>
|
||||
{expandedActivity === activity.id && (
|
||||
<ActivityDetail activity={activity} template={null} showExtraCategories={showExtraCats} expandedInfoCat={expandedInfoCat} onToggleCategory={toggleDataCategory} onToggleInfo={setExpandedInfoCat} onToggleExtraCategories={toggleExtraCategories} onUpdateActivity={updateActivity} onRemoveActivity={removeActivity} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button type="button" onClick={addCustomActivity} 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">
|
||||
+ Eigene Verarbeitungstätigkeit hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import { ActivityDepartment } from './types'
|
||||
|
||||
// DSGVO-Standard Datenkategorien
|
||||
export const ALL_DATA_CATEGORIES = [
|
||||
{ id: 'stammdaten', label: 'Stammdaten', desc: 'Name, Geburtsdatum, Geschlecht', info: 'Vor- und Nachname, Geburtsdatum, Geschlecht, Anrede, Titel, Familienstand, Staatsangehörigkeit, Personalnummer, Kundennummer' },
|
||||
{ id: 'kontaktdaten', label: 'Kontaktdaten', desc: 'E-Mail, Telefon, Adresse', info: 'E-Mail-Adresse, Telefonnummer, Mobilnummer, Postanschrift, Faxnummer, Messenger-IDs der betroffenen Personen' },
|
||||
{ id: 'vertragsdaten', label: 'Vertragsdaten', desc: 'Vertragsnummer, Laufzeit, Konditionen', info: 'Vertragsnummer, Vertragsbeginn/-ende, Laufzeit, Konditionen, Kündigungsfristen, Vertragsgegenstand, Bestellhistorie' },
|
||||
{ id: 'zahlungsdaten', label: 'Zahlungs-/Bankdaten', desc: 'IBAN, Kreditkarte, Rechnungen', info: 'IBAN, BIC, Kontoinhaber, Kreditkartennummer, Rechnungsbeträge, Zahlungshistorie, Steuer-ID, USt-IdNr.' },
|
||||
{ id: 'beschaeftigtendaten', label: 'Beschäftigtendaten', desc: 'Gehalt, Arbeitszeiten, Urlaub', info: 'Gehalt/Lohn, Steuerklasse, SV-Nummer, Krankenkasse (z.B. AOK, TK), Arbeitszeiten, Urlaubstage, Abwesenheiten, Beurteilungen, Eintrittsdatum. Aufbewahrung: i.d.R. 3 Jahre nach Austritt (§ 195 BGB), Lohndaten 8 Jahre (§ 147 AO)' },
|
||||
{ id: 'kommunikation', label: 'Kommunikationsdaten', desc: 'E-Mail-Inhalte, Chat-Verläufe', info: 'E-Mail-Inhalte und -Metadaten, Chat-Nachrichten, Gesprächsprotokolle, Support-Tickets, Briefkorrespondenz' },
|
||||
{ id: 'nutzungsdaten', label: 'Nutzungs-/Logdaten', desc: 'IP-Adressen, Login-Zeiten, Klicks', info: 'IP-Adressen, Login-Zeitpunkte, Seitenaufrufe, Klickverhalten, Geräteinformationen, Browser-Typ, Session-Dauer' },
|
||||
{ id: 'standortdaten', label: 'Standortdaten', desc: 'GPS, Check-in, Lieferadressen', info: 'GPS-Koordinaten, Check-in/Check-out-Zeiten, Lieferadressen, Reiserouten, WLAN-Standortbestimmung' },
|
||||
{ id: 'bilddaten', label: 'Bild-/Videodaten', desc: 'Fotos, Videoaufnahmen, Profilbilder', info: 'Profilfotos, Ausweiskopien, Videoaufnahmen (Überwachung), Bewerbungsfotos, Schulungsvideos' },
|
||||
{ id: 'bewerberdaten', label: 'Bewerberdaten', desc: 'Lebenslauf, Zeugnisse, Anschreiben', info: 'Lebenslauf, Anschreiben, Zeugnisse, Referenzen, Gehaltsvorstellungen, Verfügbarkeit, Bewerbungsquelle. Löschfrist bei Absage: max. 6 Monate (AGG §§ 15, 21)' },
|
||||
{ id: 'qualifikationsdaten', label: 'Qualifikations-/Schulungsdaten', desc: 'Fortbildungen, Zertifikate, Abschlüsse', info: 'Besuchte Seminare und Schulungen, Zertifikate, Abschlüsse, Qualifikationsnachweise, Schulungsdaten und -ergebnisse, Weiterbildungshistorie' },
|
||||
] as const
|
||||
|
||||
export const ALL_SPECIAL_CATEGORIES = [
|
||||
{ id: 'gesundheit', label: 'Gesundheitsdaten', desc: 'Krankheitstage, Atteste, Diagnosen', info: 'Krankheitstage, AU-Bescheinigungen, Diagnosen, Behinderungsgrad (GdB), BEM-Daten, arbeitsmedizinische Untersuchungen, Impfstatus, Allergien. Auch AU ohne Diagnose = Gesundheitsdatum (LDI NRW). Schwangerschaft, Allergien, Online-Arzneimittelbestellung (EuGH C-21/23). NICHT: Krankenkassenname (z.B. AOK, TK) — das sind normale Beschäftigtendaten.' },
|
||||
{ id: 'biometrie', label: 'Biometrische Daten', desc: 'Fingerabdruck, Gesichtserkennung', info: 'Fingerabdruck, Gesichtserkennung, Iris-Scan, Stimmerkennung, Handvenenscan. Nur wenn zur eindeutigen Identifizierung verwendet (ErwGr. 51). Einfaches Passfoto = kein biometrisches Datum.' },
|
||||
{ id: 'religion', label: 'Religion', desc: 'Konfession, Kirchensteuer', info: 'Konfession/Religionszugehörigkeit (relevant für Kirchensteuer auf Lohnabrechnung). Auch indirekt: Kantinenbestellung halal/koscher (EuGH C-184/20 weite Auslegung). Praktisch jedes Unternehmen mit Beschäftigten verarbeitet diese Daten über die Gehaltsabrechnung.' },
|
||||
{ id: 'gewerkschaft', label: 'Gewerkschaftszugehörigkeit', desc: 'Mitgliedschaft', info: 'Gewerkschaftsmitgliedschaft, Betriebsratszugehörigkeit, Tarifzugehörigkeit' },
|
||||
{ id: 'genetik', label: 'Genetische Daten', desc: 'DNA, Erbkrankheiten', info: 'DNA-Analysen, genetische Prädispositionen, Erbkrankheitsrisiken (nur in Spezialfällen relevant)' },
|
||||
] as const
|
||||
|
||||
// ── Universelle Abteilungen (immer sichtbar) ──
|
||||
|
||||
const UNIVERSAL_DEPARTMENTS: ActivityDepartment[] = [
|
||||
{
|
||||
id: 'personal', name: 'Personal / HR', icon: '\uD83D\uDC65',
|
||||
activities: [
|
||||
{ id: 'personalverwaltung', name: 'Personalverwaltung', purpose: 'Verwaltung von Beschäftigtendaten für das Arbeitsverhältnis', primary_categories: ['stammdaten', 'kontaktdaten', 'beschaeftigtendaten', 'zahlungsdaten'], art9_relevant: ['gesundheit', 'religion', 'gewerkschaft'], default_legal_basis: 'contract', categoryInfo: { stammdaten: 'Vor-/Nachname, Geburtsdatum, Geschlecht, Familienstand, Staatsangehörigkeit, Personalnummer', kontaktdaten: 'Privat- und Dienstadresse, Telefonnummern, dienstliche E-Mail, Notfallkontakt', beschaeftigtendaten: 'Steuerklasse, SV-Nummer, Krankenkasse (z.B. AOK, TK \u2014 kein Gesundheitsdatum!), Eintrittsdatum, Arbeitszeit, Urlaubstage. Aufbewahrung: 3 Jahre nach Austritt (\u00A7 195 BGB)', zahlungsdaten: 'IBAN f\u00FCr Gehaltsauszahlung, Verm\u00F6genswirksame Leistungen, Pf\u00E4ndungsdaten' } },
|
||||
{ id: 'lohnbuchhaltung', name: 'Lohn- und Gehaltsabrechnung', purpose: 'Berechnung und Auszahlung von L\u00F6hnen und Geh\u00E4ltern', primary_categories: ['beschaeftigtendaten', 'zahlungsdaten', 'stammdaten'], art9_relevant: [], default_legal_basis: 'legal', hasServiceProvider: true, categoryInfo: { beschaeftigtendaten: 'Gehalt, Zulagen, Pr\u00E4mien, Steuerklasse, SV-Nummer, Krankenkasse, Kirchensteuermerkmal. Aufbewahrung: Lohnabrechnungen 8 Jahre (\u00A7 147 AO), Lohnsteuer 6 Jahre (\u00A7 41 EStG). Hinweis: Gesundheits- und Religionsdaten werden bereits unter Personalverwaltung als Art. 9-Kategorien erfasst.', zahlungsdaten: 'IBAN, Bankverbindung, Gehaltsabrechnungen, Pf\u00E4ndungsbetr\u00E4ge. Aufbewahrung: 8 Jahre (\u00A7 147 AO)' } },
|
||||
{ id: 'bewerbermanagement', name: 'Bewerbermanagement', purpose: 'Entgegennahme, Pr\u00FCfung und Bearbeitung von Bewerbungen', primary_categories: ['bewerberdaten', 'stammdaten', 'kontaktdaten', 'kommunikation', 'qualifikationsdaten'], art9_relevant: ['gesundheit', 'religion'], default_legal_basis: 'consent', categoryInfo: { bewerberdaten: 'Lebenslauf, Anschreiben, Zeugnisse, Referenzen, Gehaltsvorstellungen. L\u00F6schfrist bei Absage: max. 6 Monate (AGG \u00A7\u00A7 15, 21)', kontaktdaten: 'Privatadresse, E-Mail, Telefonnummer des Bewerbers', kommunikation: 'Bewerbungskorrespondenz, Einladungen, Absageschreiben' } },
|
||||
{ id: 'arbeitszeiterfassung', name: 'Arbeitszeiterfassung', purpose: 'Erfassung und Dokumentation der Arbeitszeiten', primary_categories: ['beschaeftigtendaten'], art9_relevant: [], default_legal_basis: 'legal', legalHint: 'Gesetzlich vorgeschrieben (\u00A7 3 ArbZG). Fehlende Arbeitszeiterfassung ist ein Compliance-Risiko.', categoryInfo: { beschaeftigtendaten: 'Beginn/Ende der Arbeitszeit, Pausen, \u00DCberstunden, Ruhezeiten. Aufbewahrung: mind. 2 Jahre (\u00A7 16 Abs. 2 ArbZG). Nicht f\u00FCr Leistungskontrolle verwenden!' } },
|
||||
{ id: 'weiterbildung', name: 'Fort- und Weiterbildung', purpose: 'Verwaltung von Schulungen und Weiterbildungsma\u00DFnahmen', primary_categories: ['qualifikationsdaten', 'beschaeftigtendaten', 'stammdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'finanzen', name: 'Finanzen / Buchhaltung', icon: '\uD83D\uDCB0',
|
||||
activities: [
|
||||
{ id: 'finanzbuchhaltung', name: 'Finanzbuchhaltung', purpose: 'Buchf\u00FChrung, Rechnungsstellung, steuerliche Dokumentation', primary_categories: ['stammdaten', 'zahlungsdaten', 'vertragsdaten', 'kontaktdaten'], art9_relevant: [], default_legal_basis: 'legal', categoryInfo: { zahlungsdaten: 'Rechnungsbetr\u00E4ge, IBAN, Buchungsbelege, USt-IdNr. Aufbewahrung: 8 Jahre (\u00A7 147 AO)', vertragsdaten: 'Vertragsnummer, Konditionen, Bestellhistorie. Aufbewahrung: Handelskorrespondenz 6 Jahre (\u00A7 257 HGB)', kontaktdaten: 'Rechnungsadresse, Ansprechpartner in der Debitorenbuchhaltung' } },
|
||||
{ id: 'zahlungsverkehr', name: 'Zahlungsverkehr', purpose: 'Abwicklung von ein- und ausgehenden Zahlungen', primary_categories: ['zahlungsdaten', 'stammdaten', 'kontaktdaten', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'mahnwesen', name: 'Mahnwesen / Inkasso', purpose: '\u00DCberwachung offener Forderungen und Mahnverfahren', primary_categories: ['stammdaten', 'kontaktdaten', 'zahlungsdaten', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'interest' },
|
||||
{ id: 'reisekostenabrechnung', name: 'Reisekostenabrechnung', purpose: 'Abrechnung und Erstattung von Dienstreisekosten', primary_categories: ['beschaeftigtendaten', 'zahlungsdaten', 'standortdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vertrieb', name: 'Vertrieb / Sales', icon: '\uD83D\uDCC8',
|
||||
activities: [
|
||||
{ id: 'crm', name: 'CRM / Kundenverwaltung', purpose: 'Verwaltung von Kundenbeziehungen, Kontakthistorie, Verkaufschancen', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'contract', categoryInfo: { stammdaten: 'Firmenname, Ansprechpartner-Name, Titel, Position, Kundennummer', kontaktdaten: 'Gesch\u00E4ftliche E-Mail, Telefon, B\u00FCroadresse des Ansprechpartners. B2B-Kontaktdaten sind personenbezogene Daten \u2014 Art. 13 DSGVO Informationspflicht gilt!', kommunikation: 'E-Mail-Korrespondenz, Gespr\u00E4chsnotizen, Support-Tickets, Meeting-Protokolle' } },
|
||||
{ id: 'angebotserstellung', name: 'Angebotserstellung', purpose: 'Erstellung und Nachverfolgung von Angeboten', primary_categories: ['stammdaten', 'kontaktdaten', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'vertragsmanagement', name: 'Vertragsmanagement', purpose: 'Verwaltung, Archivierung und Nachverfolgung von Vertr\u00E4gen', primary_categories: ['vertragsdaten', 'stammdaten', 'kontaktdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'marketing', name: 'Marketing', icon: '\uD83D\uDCE3',
|
||||
activities: [
|
||||
{ id: 'newsletter', name: 'Newsletter / E-Mail-Marketing', purpose: 'Versand von Newslettern und E-Mail-Marketing an Abonnenten', primary_categories: ['kontaktdaten', 'nutzungsdaten', 'stammdaten'], art9_relevant: [], default_legal_basis: 'consent' },
|
||||
{ id: 'website_tracking', name: 'Website-Tracking / Analytics', purpose: 'Analyse des Nutzerverhaltens auf der Website mittels Tracking-Tools', primary_categories: ['nutzungsdaten', 'standortdaten'], art9_relevant: [], default_legal_basis: 'consent' },
|
||||
{ id: 'social_media', name: 'Social-Media-Marketing', purpose: 'Betrieb von Unternehmensprofilen und Werbekampagnen', primary_categories: ['kontaktdaten', 'nutzungsdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'consent' },
|
||||
{ id: 'consent_management', name: 'Consent-Management (Cookies)', purpose: 'Verwaltung der Einwilligungen f\u00FCr Cookies und Tracking', primary_categories: ['nutzungsdaten'], art9_relevant: [], default_legal_basis: 'consent' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'it', name: 'IT / Administration', icon: '\uD83D\uDDA5\uFE0F',
|
||||
activities: [
|
||||
{ id: 'zugangsverwaltung', name: 'Zugangsverwaltung (IAM)', purpose: 'Verwaltung von Benutzerkonten, Passw\u00F6rtern und Zugriffsrechten', primary_categories: ['beschaeftigtendaten', 'stammdaten', 'nutzungsdaten'], art9_relevant: ['biometrie'], default_legal_basis: 'contract' },
|
||||
{ id: 'email_kommunikation', name: 'E-Mail-Kommunikation', purpose: 'Gesch\u00E4ftliche E-Mail-Korrespondenz', primary_categories: ['kontaktdaten', 'kommunikation', 'stammdaten'], art9_relevant: [], default_legal_basis: 'interest' },
|
||||
{ id: 'datensicherung', name: 'Datensicherung / Backup', purpose: 'Sicherung von Unternehmensdaten zum Schutz vor Datenverlust', primary_categories: ['nutzungsdaten', 'beschaeftigtendaten'], art9_relevant: [], default_legal_basis: 'interest' },
|
||||
{ id: 'website_betrieb', name: 'Website-Betrieb', purpose: 'Bereitstellung der Unternehmenswebsite und Kontaktformulare', primary_categories: ['nutzungsdaten', 'kontaktdaten', 'stammdaten'], art9_relevant: [], default_legal_basis: 'interest', hasServiceProvider: true, legalHint: 'Bei externem Website-Management: AVV nach Art. 28 DSGVO mit dem Dienstleister erforderlich. Cookies, Analytics und Kontaktformulare verarbeiten personenbezogene Daten \u2014 auch wenn der Dienstleister sie technisch betreibt, bleibt Ihr Unternehmen verantwortlich.' },
|
||||
{ id: 'it_sicherheit', name: 'IT-Sicherheit / Logging', purpose: '\u00DCberwachung der IT-Sicherheit, Log-Analyse, Vorfallbehandlung', primary_categories: ['nutzungsdaten', 'beschaeftigtendaten'], art9_relevant: [], default_legal_basis: 'interest' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'recht', name: 'Recht / Compliance', icon: '\u2696\uFE0F',
|
||||
activities: [
|
||||
{ id: 'datenschutzanfragen', name: 'Betroffenenrechte (DSGVO)', purpose: 'Bearbeitung von Auskunfts-, L\u00F6sch- und Berichtigungsanfragen', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'legal' },
|
||||
{ id: 'auftragsverarbeitung', name: 'Auftragsverarbeitung (AVV)', purpose: 'Dokumentation und Verwaltung von Auftragsverarbeitungsverh\u00E4ltnissen', primary_categories: ['stammdaten', 'kontaktdaten', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'legal' },
|
||||
{ id: 'whistleblowing', name: 'Hinweisgebersystem', purpose: 'Entgegennahme und Bearbeitung von Meldungen nach HinSchG', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'legal', categoryInfo: { stammdaten: 'Identit\u00E4t des Hinweisgebers (besonders sch\u00FCtzenswert! \u00A7 8 HinSchG Vertraulichkeitsgebot)', kontaktdaten: 'Kontaktdaten nur f\u00FCr zust\u00E4ndige Meldestelle zug\u00E4nglich', kommunikation: 'Meldungsinhalt, Kommunikationsverlauf, Zeugenaussagen. L\u00F6schfrist: 3 Jahre nach Abschluss (\u00A7 11 Abs. 5 HinSchG)' } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── Abteilungen die je nach Kontext relevant sind ──
|
||||
|
||||
const OPTIONAL_DEPARTMENTS: ActivityDepartment[] = [
|
||||
{
|
||||
id: 'einkauf', name: 'Einkauf / Beschaffung', icon: '\uD83D\uDED2',
|
||||
activities: [
|
||||
{ id: 'lieferantenverwaltung', name: 'Lieferantenverwaltung', purpose: 'Erfassung und Pflege von Lieferantenstammdaten', primary_categories: ['stammdaten', 'kontaktdaten', 'vertragsdaten', 'zahlungsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'bestellwesen', name: 'Bestellwesen', purpose: 'Abwicklung von Bestellungen bei Lieferanten', primary_categories: ['stammdaten', 'vertragsdaten', 'zahlungsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'lieferantenbewertung', name: 'Lieferantenbewertung', purpose: 'Bewertung und Qualifizierung von Lieferanten', primary_categories: ['stammdaten', 'kontaktdaten'], art9_relevant: [], default_legal_basis: 'interest' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'produktion', name: 'Produktion / Fertigung', icon: '\uD83C\uDFED',
|
||||
activities: [
|
||||
{ id: 'produktionsplanung', name: 'Produktionsplanung', purpose: 'Planung und Steuerung von Produktionsprozessen inkl. Personalzuordnung', primary_categories: ['beschaeftigtendaten', 'stammdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'qualitaetskontrolle', name: 'Qualit\u00E4tskontrolle', purpose: 'Pr\u00FCfung und Dokumentation der Produktqualit\u00E4t', primary_categories: ['beschaeftigtendaten', 'stammdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'arbeitssicherheit', name: 'Arbeitssicherheit / Arbeitsschutz', purpose: 'Dokumentation von Arbeitsschutzma\u00DFnahmen, Unf\u00E4llen, Gef\u00E4hrdungsbeurteilungen', primary_categories: ['beschaeftigtendaten'], art9_relevant: ['gesundheit'], default_legal_basis: 'legal' },
|
||||
{ id: 'schichtplanung', name: 'Schichtplanung', purpose: 'Erstellung und Verwaltung von Schichtpl\u00E4nen', primary_categories: ['beschaeftigtendaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'logistik', name: 'Logistik / Versand', icon: '\uD83D\uDE9A',
|
||||
activities: [
|
||||
{ id: 'versandabwicklung', name: 'Versandabwicklung', purpose: 'Verarbeitung von Empf\u00E4nger- und Versanddaten f\u00FCr den Warenversand', primary_categories: ['stammdaten', 'kontaktdaten', 'standortdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'lieferverfolgung', name: 'Lieferverfolgung / Sendungstracking', purpose: 'Nachverfolgung von Sendungen und Zustellung', primary_categories: ['stammdaten', 'kontaktdaten', 'standortdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'lagerverwaltung', name: 'Lagerverwaltung', purpose: 'Verwaltung von Lagerbest\u00E4nden und Warenbewegungen', primary_categories: ['stammdaten', 'beschaeftigtendaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'retouren', name: 'Retourenmanagement', purpose: 'Bearbeitung von Warenr\u00FCcksendungen', primary_categories: ['stammdaten', 'kontaktdaten', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'kundenservice', name: 'Kundenservice / Support', icon: '\uD83C\uDFA7',
|
||||
activities: [
|
||||
{ id: 'ticketsystem', name: 'Ticketsystem / Support', purpose: 'Erfassung und Bearbeitung von Kundenanfragen und Supportf\u00E4llen', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'beschwerdemanagement', name: 'Beschwerdemanagement', purpose: 'Bearbeitung und Dokumentation von Kundenbeschwerden', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'facility', name: 'Facility Management', icon: '\uD83C\uDFE2',
|
||||
activities: [
|
||||
{ id: 'zutrittskontrolle', name: 'Zutrittskontrolle', purpose: 'Kontrolle und Protokollierung des Zutritts zu Geb\u00E4uden und R\u00E4umen', primary_categories: ['beschaeftigtendaten', 'stammdaten', 'bilddaten'], art9_relevant: ['biometrie'], default_legal_basis: 'interest' },
|
||||
{ id: 'videoueberwachung', name: 'Video\u00FCberwachung', purpose: '\u00DCberwachung von Geb\u00E4uden und Gel\u00E4nden mittels Videokameras', primary_categories: ['bilddaten', 'beschaeftigtendaten'], art9_relevant: ['biometrie'], default_legal_basis: 'interest', categoryInfo: { bilddaten: 'Videoaufzeichnungen von Kameras. Speicherdauer: empfohlen max. 72h (BeschDG-Entwurf). Datenschutzhinweis-Schilder (Art. 13 DSGVO) sind Pflicht. Betriebsrat hat Mitbestimmungsrecht (\u00A7 87 Abs. 1 Nr. 6 BetrVG)' } },
|
||||
{ id: 'besuchermanagement', name: 'Besuchermanagement', purpose: 'Erfassung und Verwaltung von Besucherdaten', primary_categories: ['stammdaten', 'kontaktdaten'], art9_relevant: [], default_legal_basis: 'interest' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ── Branchenspezifische Abteilungen ──
|
||||
|
||||
const INDUSTRY_DEPARTMENTS: Record<string, ActivityDepartment[]> = {
|
||||
'E-Commerce / Handel': [{
|
||||
id: 'ecommerce', name: 'E-Commerce / Webshop', icon: '\uD83D\uDECD\uFE0F',
|
||||
activities: [
|
||||
{ id: 'bestellabwicklung', name: 'Bestellabwicklung (Webshop)', purpose: 'Verarbeitung von Kundenbestellungen im Online-Shop', primary_categories: ['stammdaten', 'kontaktdaten', 'zahlungsdaten', 'vertragsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'kundenkonto', name: 'Kundenkonto-Verwaltung', purpose: 'Verwaltung registrierter Kundenkonten im Online-Shop', primary_categories: ['stammdaten', 'kontaktdaten', 'nutzungsdaten', 'zahlungsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'webshop_analyse', name: 'Webshop-Analyse / Conversion', purpose: 'Analyse des Kaufverhaltens und Conversion-Rates', primary_categories: ['nutzungsdaten', 'standortdaten'], art9_relevant: [], default_legal_basis: 'consent' },
|
||||
{ id: 'produktbewertungen', name: 'Produktbewertungen / Reviews', purpose: 'Verwaltung von Kundenrezensionen und Produktbewertungen', primary_categories: ['stammdaten', 'kontaktdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'consent' },
|
||||
],
|
||||
}],
|
||||
'Gesundheitswesen': [{
|
||||
id: 'gesundheit_dept', name: 'Medizin / Patientenversorgung', icon: '\uD83C\uDFE5',
|
||||
activities: [
|
||||
{ id: 'patientenverwaltung', name: 'Patientenverwaltung', purpose: 'Verwaltung von Patientenstammdaten und Krankengeschichte', primary_categories: ['stammdaten', 'kontaktdaten', 'zahlungsdaten'], art9_relevant: ['gesundheit', 'genetik'], default_legal_basis: 'contract' },
|
||||
{ id: 'terminplanung_med', name: 'Terminplanung (Patienten)', purpose: 'Vergabe und Verwaltung von Patiententerminen', primary_categories: ['stammdaten', 'kontaktdaten'], art9_relevant: ['gesundheit'], default_legal_basis: 'contract' },
|
||||
{ id: 'kv_abrechnung', name: 'KV-Abrechnung', purpose: 'Abrechnung von Leistungen gegen\u00FCber Kassen\u00E4rztlichen Vereinigungen', primary_categories: ['stammdaten', 'zahlungsdaten'], art9_relevant: ['gesundheit'], default_legal_basis: 'legal' },
|
||||
{ id: 'med_dokumentation', name: 'Medizinische Dokumentation', purpose: 'Dokumentation von Diagnosen, Therapien und Behandlungsverl\u00E4ufen', primary_categories: ['stammdaten'], art9_relevant: ['gesundheit', 'genetik'], default_legal_basis: 'legal' },
|
||||
],
|
||||
}],
|
||||
'Finanzdienstleistungen': [{
|
||||
id: 'finanz_dept', name: 'Regulatorik / Finanzaufsicht', icon: '\uD83C\uDFE6',
|
||||
activities: [
|
||||
{ id: 'kyc', name: 'Know Your Customer (KYC)', purpose: 'Identifizierung und Verifizierung von Kunden gem\u00E4\u00DF GwG', primary_categories: ['stammdaten', 'kontaktdaten', 'bilddaten'], art9_relevant: [], default_legal_basis: 'legal' },
|
||||
{ id: 'kontoverwaltung', name: 'Kontoverwaltung', purpose: 'Verwaltung von Kundenkonten und Kontobewegungen', primary_categories: ['stammdaten', 'kontaktdaten', 'zahlungsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'geldwaeschepraevention', name: 'Geldw\u00E4schepr\u00E4vention (AML)', purpose: '\u00DCberwachung verd\u00E4chtiger Transaktionen nach GwG', primary_categories: ['stammdaten', 'zahlungsdaten'], art9_relevant: [], default_legal_basis: 'legal' },
|
||||
],
|
||||
}],
|
||||
'Bildung': [{
|
||||
id: 'bildung_dept', name: 'Bildung / Lehre', icon: '\uD83C\uDF93',
|
||||
activities: [
|
||||
{ id: 'schuelerverwaltung', name: 'Sch\u00FCler-/Teilnehmerverwaltung', purpose: 'Verwaltung von Lernenden, Noten, Anwesenheit', primary_categories: ['stammdaten', 'kontaktdaten', 'nutzungsdaten'], art9_relevant: ['gesundheit', 'religion'], default_legal_basis: 'contract' },
|
||||
{ id: 'lernplattform', name: 'Lernplattform / LMS', purpose: 'Bereitstellung und Nutzung digitaler Lernplattformen', primary_categories: ['stammdaten', 'nutzungsdaten', 'kommunikation'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'pruefungsverwaltung', name: 'Pr\u00FCfungsverwaltung', purpose: 'Verwaltung und Dokumentation von Pr\u00FCfungen und Noten', primary_categories: ['stammdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
],
|
||||
}],
|
||||
'Immobilien': [{
|
||||
id: 'immobilien_dept', name: 'Immobilienverwaltung', icon: '\uD83C\uDFE0',
|
||||
activities: [
|
||||
{ id: 'mieterverwaltung', name: 'Mieterverwaltung', purpose: 'Verwaltung von Mietvertr\u00E4gen und Mieterdaten', primary_categories: ['stammdaten', 'kontaktdaten', 'vertragsdaten', 'zahlungsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
{ id: 'nebenkostenabrechnung', name: 'Nebenkostenabrechnung', purpose: 'Erstellung und Versand von Nebenkostenabrechnungen', primary_categories: ['stammdaten', 'zahlungsdaten'], art9_relevant: [], default_legal_basis: 'contract' },
|
||||
],
|
||||
}],
|
||||
}
|
||||
|
||||
// Compute which departments to show based on company context
|
||||
export function getRelevantDepartments(industry: string | string[], businessModel: string | undefined, companySize: string | undefined): ActivityDepartment[] {
|
||||
const departments: ActivityDepartment[] = [...UNIVERSAL_DEPARTMENTS]
|
||||
|
||||
// Always show optional departments
|
||||
departments.push(...OPTIONAL_DEPARTMENTS)
|
||||
|
||||
// Add industry-specific departments (support multi-select)
|
||||
const industries = Array.isArray(industry) ? industry : [industry]
|
||||
const addedIds = new Set<string>()
|
||||
for (const ind of industries) {
|
||||
const industryDepts = INDUSTRY_DEPARTMENTS[ind]
|
||||
if (industryDepts) {
|
||||
for (const dept of industryDepts) {
|
||||
if (!addedIds.has(dept.id)) {
|
||||
addedIds.add(dept.id)
|
||||
departments.push(dept)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return departments
|
||||
}
|
||||
|
||||
// Helper: find template for an activity ID across all departments
|
||||
export function findTemplate(departments: ActivityDepartment[], activityId: string) {
|
||||
for (const dept of departments) {
|
||||
const t = dept.activities.find(a => a.id === activityId)
|
||||
if (t) return t
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { AISystemTemplate } from './types'
|
||||
|
||||
export const AI_SYSTEM_TEMPLATES: { category: string; icon: string; systems: AISystemTemplate[] }[] = [
|
||||
{
|
||||
category: 'Text-KI / Chatbots',
|
||||
icon: '\uD83D\uDCAC',
|
||||
systems: [
|
||||
{ id: 'chatgpt', name: 'ChatGPT', vendor: 'OpenAI', category: 'Text-KI / Chatbots', icon: '\uD83D\uDCAC', typicalPurposes: ['Textgenerierung', 'Kundensupport', 'Zusammenfassungen', 'Recherche'], dataWarning: 'Datenverarbeitung in den USA. Eingaben koennen fuer Training verwendet werden (opt-out moeglich).', processes_personal_data_likely: true },
|
||||
{ id: 'claude', name: 'Claude', vendor: 'Anthropic', category: 'Text-KI / Chatbots', icon: '\uD83D\uDCAC', typicalPurposes: ['Textgenerierung', 'Analyse', 'Zusammenfassungen', 'Code-Review'], dataWarning: 'Datenverarbeitung in den USA. Eingaben werden NICHT fuer Training verwendet.', processes_personal_data_likely: true },
|
||||
{ id: 'gemini', name: 'Google Gemini', vendor: 'Google', category: 'Text-KI / Chatbots', icon: '\uD83D\uDCAC', typicalPurposes: ['Textgenerierung', 'Recherche', 'Zusammenfassungen'], dataWarning: 'Datenverarbeitung in den USA/EU je nach Einstellung.', processes_personal_data_likely: true },
|
||||
{ id: 'perplexity', name: 'Perplexity', vendor: 'Perplexity AI', category: 'Text-KI / Chatbots', icon: '\uD83D\uDCAC', typicalPurposes: ['Websuche mit KI', 'Recherche', 'Zusammenfassungen'], dataWarning: 'Websuche + KI. Eingaben werden verarbeitet.', processes_personal_data_likely: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Office / Produktivitaet',
|
||||
icon: '\uD83D\uDCCE',
|
||||
systems: [
|
||||
{ id: 'copilot365', name: 'Microsoft 365 Copilot', vendor: 'Microsoft', category: 'Office / Produktivitaet', icon: '\uD83D\uDCCE', typicalPurposes: ['E-Mail-Entwuerfe', 'Dokument-Zusammenfassung', 'Praesentationen', 'Excel-Analysen'], dataWarning: 'In M365-Tenant integriert. Daten bleiben im Tenant, aber: KI-Verarbeitung ggf. in den USA.', processes_personal_data_likely: true },
|
||||
{ id: 'google-workspace-ai', name: 'Google Workspace AI', vendor: 'Google', category: 'Office / Produktivitaet', icon: '\uD83D\uDCCE', typicalPurposes: ['E-Mail-Entwuerfe', 'Dokument-Zusammenfassung', 'Tabellen-Analysen'], dataWarning: 'Duet AI in Docs, Sheets, Gmail. Datenverarbeitung je nach Workspace-Region.', processes_personal_data_likely: true },
|
||||
{ id: 'notion-ai', name: 'Notion AI', vendor: 'Notion', category: 'Office / Produktivitaet', icon: '\uD83D\uDCCE', typicalPurposes: ['Texterstellung', 'Zusammenfassungen', 'Aufgabenverwaltung'], dataWarning: 'Datenverarbeitung in den USA.', processes_personal_data_likely: false },
|
||||
{ id: 'grammarly', name: 'Grammarly', vendor: 'Grammarly', category: 'Office / Produktivitaet', icon: '\uD83D\uDCCE', typicalPurposes: ['Textkorrektur', 'Stiloptimierung', 'Tonalitaet'], dataWarning: 'Textanalyse, Datenverarbeitung in den USA.', processes_personal_data_likely: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Code-Assistenz',
|
||||
icon: '\uD83D\uDCBB',
|
||||
systems: [
|
||||
{ id: 'github-copilot', name: 'GitHub Copilot', vendor: 'Microsoft/GitHub', category: 'Code-Assistenz', icon: '\uD83D\uDCBB', typicalPurposes: ['Code-Vorschlaege', 'Code-Generierung', 'Dokumentation'], dataWarning: 'Code-Vorschlaege basierend auf Kontext. Code-Snippets werden verarbeitet.', processes_personal_data_likely: false },
|
||||
{ id: 'cursor', name: 'Cursor / Windsurf', vendor: 'Cursor Inc.', category: 'Code-Assistenz', icon: '\uD83D\uDCBB', typicalPurposes: ['Code-Generierung', 'Refactoring', 'Debugging'], dataWarning: 'KI-Code-Editor. Code wird an KI-Backend uebermittelt.', processes_personal_data_likely: false },
|
||||
{ id: 'codewhisperer', name: 'Amazon CodeWhisperer', vendor: 'AWS', category: 'Code-Assistenz', icon: '\uD83D\uDCBB', typicalPurposes: ['Code-Vorschlaege', 'Sicherheits-Scans'], dataWarning: 'Code-Vorschlaege. Opt-out fuer Code-Sharing moeglich.', processes_personal_data_likely: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Bildgenerierung',
|
||||
icon: '\uD83C\uDFA8',
|
||||
systems: [
|
||||
{ id: 'dalle', name: 'DALL-E / ChatGPT Bildgenerierung', vendor: 'OpenAI', category: 'Bildgenerierung', icon: '\uD83C\uDFA8', typicalPurposes: ['Bildgenerierung', 'Marketing-Material', 'Illustrationen'], dataWarning: 'Bildgenerierung. Prompts werden verarbeitet.', processes_personal_data_likely: false },
|
||||
{ id: 'midjourney', name: 'Midjourney', vendor: 'Midjourney Inc.', category: 'Bildgenerierung', icon: '\uD83C\uDFA8', typicalPurposes: ['Bildgenerierung', 'Design-Konzepte', 'Illustrationen'], dataWarning: 'Bildgenerierung via Discord. Prompts sind oeffentlich sichtbar (ausser Pro-Plan).', processes_personal_data_likely: false },
|
||||
{ id: 'firefly', name: 'Adobe Firefly', vendor: 'Adobe', category: 'Bildgenerierung', icon: '\uD83C\uDFA8', typicalPurposes: ['Bildgenerierung', 'Bildbearbeitung', 'Design'], dataWarning: 'In Creative Cloud integriert. Trainiert auf lizenzierten Inhalten.', processes_personal_data_likely: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Uebersetzung / Sprache',
|
||||
icon: '\uD83C\uDF10',
|
||||
systems: [
|
||||
{ id: 'deepl', name: 'DeepL', vendor: 'DeepL SE', category: 'Uebersetzung / Sprache', icon: '\uD83C\uDF10', typicalPurposes: ['Uebersetzung', 'Dokumentenuebersetzung'], dataWarning: 'Deutscher Anbieter, Server in EU. DeepL Pro: Texte werden NICHT gespeichert.', processes_personal_data_likely: false },
|
||||
{ id: 'deepl-write', name: 'DeepL Write', vendor: 'DeepL SE', category: 'Uebersetzung / Sprache', icon: '\uD83C\uDF10', typicalPurposes: ['Textoptimierung', 'Stilverbesserung'], dataWarning: 'Deutscher Anbieter, Server in EU. Gleiche Datenschutz-Bedingungen wie DeepL.', processes_personal_data_likely: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'CRM / Sales KI',
|
||||
icon: '\uD83D\uDCCA',
|
||||
systems: [
|
||||
{ id: 'salesforce-einstein', name: 'Salesforce Einstein', vendor: 'Salesforce', category: 'CRM / Sales KI', icon: '\uD83D\uDCCA', typicalPurposes: ['Lead-Scoring', 'Prognosen', 'Empfehlungen'], dataWarning: 'In Salesforce integriert. Verarbeitet CRM-Daten.', processes_personal_data_likely: true },
|
||||
{ id: 'hubspot-ai', name: 'HubSpot AI', vendor: 'HubSpot', category: 'CRM / Sales KI', icon: '\uD83D\uDCCA', typicalPurposes: ['E-Mail-Generierung', 'Lead-Scoring', 'Content-Erstellung'], dataWarning: 'KI-Features in HubSpot CRM. Datenverarbeitung in USA/EU.', processes_personal_data_likely: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Interne / Eigene Systeme',
|
||||
icon: '\uD83C\uDFE2',
|
||||
systems: [
|
||||
{ id: 'internal-ai', name: 'Eigenes KI-System', vendor: 'Intern', category: 'Interne / Eigene Systeme', icon: '\uD83C\uDFE2', typicalPurposes: ['Interne Analyse', 'Automatisierung', 'Prozessoptimierung'], dataWarning: undefined, processes_personal_data_likely: false },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -1,139 +0,0 @@
|
||||
import { LegalForm } from '@/lib/sdk/types'
|
||||
import { OfferingType } from '@/lib/sdk/types'
|
||||
|
||||
// =============================================================================
|
||||
// WIZARD STEPS
|
||||
// =============================================================================
|
||||
|
||||
export const BASE_WIZARD_STEPS = [
|
||||
{ id: 1, name: 'Basisinfos', description: 'Firmenname und Rechtsform' },
|
||||
{ id: 2, name: 'Geschaeftsmodell', description: 'B2B, B2C und Angebote' },
|
||||
{ id: 3, name: 'Firmengroesse', description: 'Mitarbeiter und Umsatz' },
|
||||
{ id: 4, name: 'Standorte', description: 'Hauptsitz und Zielmaerkte' },
|
||||
{ id: 5, name: 'Datenschutz', description: 'Rollen und DSB' },
|
||||
{ id: 6, name: 'Zertifizierungen & Kontakte', description: 'Bestehende und angestrebte Zertifizierungen' },
|
||||
]
|
||||
|
||||
export const MACHINE_BUILDER_STEP = { id: 7, name: 'Produkt & Maschine', description: 'Software, KI und CE in Ihrem Produkt' }
|
||||
|
||||
// =============================================================================
|
||||
// INDUSTRIES
|
||||
// =============================================================================
|
||||
|
||||
export const INDUSTRIES = [
|
||||
'Technologie / IT',
|
||||
'IT Dienstleistungen',
|
||||
'E-Commerce / Handel',
|
||||
'Finanzdienstleistungen',
|
||||
'Versicherungen',
|
||||
'Gesundheitswesen',
|
||||
'Pharma',
|
||||
'Bildung',
|
||||
'Beratung / Consulting',
|
||||
'Marketing / Agentur',
|
||||
'Produktion / Industrie',
|
||||
'Logistik / Transport',
|
||||
'Immobilien',
|
||||
'Bau',
|
||||
'Energie',
|
||||
'Automobil',
|
||||
'Luft- und Raumfahrt',
|
||||
'Maschinenbau',
|
||||
'Anlagenbau',
|
||||
'Automatisierung',
|
||||
'Robotik',
|
||||
'Messtechnik',
|
||||
'Agrar',
|
||||
'Chemie',
|
||||
'Minen / Bergbau',
|
||||
'Telekommunikation',
|
||||
'Medien / Verlage',
|
||||
'Gastronomie / Hotellerie',
|
||||
'Recht / Kanzlei',
|
||||
'Oeffentlicher Dienst',
|
||||
'Sonstige',
|
||||
]
|
||||
|
||||
const MACHINE_BUILDER_INDUSTRIES = [
|
||||
'Maschinenbau',
|
||||
'Anlagenbau',
|
||||
'Automatisierung',
|
||||
'Robotik',
|
||||
'Messtechnik',
|
||||
]
|
||||
|
||||
export const isMachineBuilderIndustry = (industry: string | string[]) => {
|
||||
const industries = Array.isArray(industry) ? industry : [industry]
|
||||
return industries.some(i => MACHINE_BUILDER_INDUSTRIES.includes(i))
|
||||
}
|
||||
|
||||
export function getWizardSteps(industry: string | string[]) {
|
||||
if (isMachineBuilderIndustry(industry)) {
|
||||
return [...BASE_WIZARD_STEPS, MACHINE_BUILDER_STEP]
|
||||
}
|
||||
return BASE_WIZARD_STEPS
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LEGAL FORMS
|
||||
// =============================================================================
|
||||
|
||||
export const LEGAL_FORM_LABELS: Record<LegalForm, string> = {
|
||||
einzelunternehmen: 'Einzelunternehmen',
|
||||
gbr: 'GbR',
|
||||
ohg: 'OHG',
|
||||
kg: 'KG',
|
||||
gmbh: 'GmbH',
|
||||
ug: 'UG (haftungsbeschränkt)',
|
||||
ag: 'AG',
|
||||
gmbh_co_kg: 'GmbH & Co. KG',
|
||||
ev: 'e.V. (Verein)',
|
||||
stiftung: 'Stiftung',
|
||||
other: 'Sonstige',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STEP EXPLANATIONS
|
||||
// =============================================================================
|
||||
|
||||
export const STEP_EXPLANATIONS: Record<number, string> = {
|
||||
1: 'Rechtsform und Gründungsjahr bestimmen, welche Meldepflichten und Schwellenwerte für Ihr Unternehmen gelten (z.B. NIS2, AI Act).',
|
||||
2: 'Ihr Geschäftsmodell und Ihre Angebote bestimmen, welche DSGVO-Pflichten greifen: B2C erfordert z.B. strengere Einwilligungsregeln, Webshops brauchen Cookie-Banner und Datenschutzerklärungen, SaaS-Angebote eine Auftragsverarbeitung.',
|
||||
3: 'Die Unternehmensgröße bestimmt, ob Sie einen DSB benennen müssen (ab 20 MA), ob NIS2-Pflichten greifen und welche Audit-Anforderungen gelten.',
|
||||
4: 'Standorte und Zielmärkte bestimmen, welche nationalen Datenschutzgesetze zusätzlich zur DSGVO greifen (z.B. BDSG, DSG-AT, UK GDPR, CCPA).',
|
||||
5: 'Ob Sie Verantwortlicher oder Auftragsverarbeiter sind, bestimmt Ihre DSGVO-Pflichten grundlegend.',
|
||||
6: 'Regulierungsrahmen und Prüfzyklen definieren, welche Compliance-Module für Sie aktiviert werden und in welchem Rhythmus Audits stattfinden.',
|
||||
7: 'Als Maschinenbauer gelten zusätzliche Anforderungen: CE-Kennzeichnung, Maschinenverordnung, Produktsicherheit und ggf. Hochrisiko-KI im Sinne des AI Act.',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// OFFERING URL CONFIG
|
||||
// =============================================================================
|
||||
|
||||
export const OFFERING_URL_CONFIG: Partial<Record<OfferingType, { label: string; placeholder: string; hint: string }>> = {
|
||||
website: { label: 'Website-Domain', placeholder: 'https://www.beispiel.de', hint: 'Ihre Unternehmenswebsite' },
|
||||
webshop: { label: 'Online-Shop URL', placeholder: 'https://shop.beispiel.de', hint: 'URL zu Ihrem Online-Shop' },
|
||||
app_mobile: { label: 'App-Store Links', placeholder: 'https://apps.apple.com/... oder https://play.google.com/...', hint: 'Apple App Store und/oder Google Play Store Link' },
|
||||
software_saas: { label: 'SaaS-Portal URL', placeholder: 'https://app.beispiel.de', hint: 'Login-/Registrierungsseite Ihres Kundenportals' },
|
||||
app_web: { label: 'Web-App URL', placeholder: 'https://app.beispiel.de', hint: 'URL zu Ihrer Web-Anwendung' },
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CERTIFICATIONS
|
||||
// =============================================================================
|
||||
|
||||
export const CERTIFICATIONS = [
|
||||
{ id: 'iso27001', label: 'ISO 27001', desc: 'Informationssicherheits-Managementsystem' },
|
||||
{ id: 'iso27701', label: 'ISO 27701', desc: 'Datenschutz-Managementsystem' },
|
||||
{ id: 'iso9001', label: 'ISO 9001', desc: 'Qualitaetsmanagement' },
|
||||
{ id: 'iso14001', label: 'ISO 14001', desc: 'Umweltmanagement' },
|
||||
{ id: 'iso22301', label: 'ISO 22301', desc: 'Business Continuity Management' },
|
||||
{ id: 'iso42001', label: 'ISO 42001', desc: 'KI-Managementsystem' },
|
||||
{ id: 'tisax', label: 'TISAX', desc: 'Trusted Information Security Assessment Exchange (Automotive)' },
|
||||
{ id: 'soc2', label: 'SOC 2', desc: 'Service Organization Controls (Typ I/II)' },
|
||||
{ id: 'c5', label: 'C5', desc: 'Cloud Computing Compliance Criteria Catalogue (BSI)' },
|
||||
{ id: 'bsi_grundschutz', label: 'BSI IT-Grundschutz', desc: 'IT-Grundschutz-Zertifikat oder Testat' },
|
||||
{ id: 'pci_dss', label: 'PCI DSS', desc: 'Payment Card Industry Data Security Standard' },
|
||||
{ id: 'hipaa', label: 'HIPAA', desc: 'Health Insurance Portability and Accountability Act' },
|
||||
{ id: 'other', label: 'Sonstige', desc: 'Andere Zertifizierungen' },
|
||||
]
|
||||
@@ -1,59 +0,0 @@
|
||||
export interface ProcessingActivity {
|
||||
id: string
|
||||
name: string
|
||||
purpose: string
|
||||
data_categories: string[]
|
||||
legal_basis: string
|
||||
department?: string
|
||||
custom?: boolean
|
||||
usesServiceProvider?: boolean
|
||||
serviceProviderName?: string
|
||||
}
|
||||
|
||||
export interface AISystem {
|
||||
id: string
|
||||
name: string
|
||||
vendor: string
|
||||
purpose: string
|
||||
purposes?: string[]
|
||||
processes_personal_data: boolean
|
||||
isCustom?: boolean
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface CertificationEntry {
|
||||
certId: string
|
||||
certifier?: string
|
||||
lastDate?: string
|
||||
customName?: string
|
||||
}
|
||||
|
||||
export interface ActivityTemplate {
|
||||
id: string
|
||||
name: string
|
||||
purpose: string
|
||||
primary_categories: string[]
|
||||
art9_relevant: string[]
|
||||
default_legal_basis: string
|
||||
legalHint?: string
|
||||
hasServiceProvider?: boolean
|
||||
categoryInfo?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ActivityDepartment {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
activities: ActivityTemplate[]
|
||||
}
|
||||
|
||||
export interface AISystemTemplate {
|
||||
id: string
|
||||
name: string
|
||||
vendor: string
|
||||
category: string
|
||||
icon: string
|
||||
typicalPurposes: string[]
|
||||
dataWarning?: string
|
||||
processes_personal_data_likely: boolean
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import React from 'react'
|
||||
import { CompanyProfile } from '@/lib/sdk/types'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { isMachineBuilderIndustry, getWizardSteps } from '../_components/constants'
|
||||
|
||||
const INITIAL_FORM_DATA: Partial<CompanyProfile> = {
|
||||
companyName: '', legalForm: undefined, industry: [], industryOther: '', foundedYear: null,
|
||||
businessModel: undefined, offerings: [], offeringUrls: {},
|
||||
companySize: undefined, employeeCount: '', annualRevenue: '',
|
||||
headquartersCountry: 'DE', headquartersCountryOther: '', headquartersStreet: '',
|
||||
headquartersZip: '', headquartersCity: '', headquartersState: '',
|
||||
hasInternationalLocations: false, internationalCountries: [], targetMarkets: [],
|
||||
primaryJurisdiction: 'DE', isDataController: true, isDataProcessor: false,
|
||||
dpoName: null, dpoEmail: null, legalContactName: null, legalContactEmail: null,
|
||||
isComplete: false, completedAt: null,
|
||||
}
|
||||
|
||||
function buildProfilePayload(formData: Partial<CompanyProfile>, projectId: string | null, isComplete: boolean) {
|
||||
return {
|
||||
project_id: projectId || null,
|
||||
company_name: formData.companyName || '',
|
||||
legal_form: formData.legalForm || 'GmbH',
|
||||
industry: formData.industry || [],
|
||||
industry_other: formData.industryOther || '',
|
||||
founded_year: formData.foundedYear || null,
|
||||
business_model: formData.businessModel || 'B2B',
|
||||
offerings: formData.offerings || [],
|
||||
offering_urls: formData.offeringUrls || {},
|
||||
company_size: formData.companySize || 'small',
|
||||
employee_count: formData.employeeCount || '',
|
||||
annual_revenue: formData.annualRevenue || '',
|
||||
headquarters_country: formData.headquartersCountry || 'DE',
|
||||
headquarters_country_other: formData.headquartersCountryOther || '',
|
||||
headquarters_street: formData.headquartersStreet || '',
|
||||
headquarters_zip: formData.headquartersZip || '',
|
||||
headquarters_city: formData.headquartersCity || '',
|
||||
headquarters_state: formData.headquartersState || '',
|
||||
has_international_locations: formData.hasInternationalLocations || false,
|
||||
international_countries: formData.internationalCountries || [],
|
||||
target_markets: formData.targetMarkets || [],
|
||||
primary_jurisdiction: formData.primaryJurisdiction || 'DE',
|
||||
is_data_controller: formData.isDataController ?? true,
|
||||
is_data_processor: formData.isDataProcessor ?? false,
|
||||
dpo_name: formData.dpoName || '',
|
||||
dpo_email: formData.dpoEmail || '',
|
||||
is_complete: isComplete,
|
||||
processing_systems: (formData as any).processingSystems || [],
|
||||
ai_systems: (formData as any).aiSystems || [],
|
||||
technical_contacts: (formData as any).technicalContacts || [],
|
||||
existing_certifications: (formData as any).existingCertifications || [],
|
||||
target_certifications: (formData as any).targetCertifications || [],
|
||||
target_certification_other: (formData as any).targetCertificationOther || '',
|
||||
review_cycle_months: (formData as any).reviewCycleMonths || 12,
|
||||
repos: (formData as any).repos || [],
|
||||
document_sources: (formData as any).documentSources || [],
|
||||
...(formData.machineBuilder ? {
|
||||
machine_builder: {
|
||||
product_types: formData.machineBuilder.productTypes || [],
|
||||
product_description: formData.machineBuilder.productDescription || '',
|
||||
product_pride: formData.machineBuilder.productPride || '',
|
||||
contains_software: formData.machineBuilder.containsSoftware || false,
|
||||
contains_firmware: formData.machineBuilder.containsFirmware || false,
|
||||
contains_ai: formData.machineBuilder.containsAI || false,
|
||||
ai_integration_type: formData.machineBuilder.aiIntegrationType || [],
|
||||
has_safety_function: formData.machineBuilder.hasSafetyFunction || false,
|
||||
safety_function_description: formData.machineBuilder.safetyFunctionDescription || '',
|
||||
autonomous_behavior: formData.machineBuilder.autonomousBehavior || false,
|
||||
human_oversight_level: formData.machineBuilder.humanOversightLevel || 'full',
|
||||
is_networked: formData.machineBuilder.isNetworked || false,
|
||||
has_remote_access: formData.machineBuilder.hasRemoteAccess || false,
|
||||
has_ota_updates: formData.machineBuilder.hasOTAUpdates || false,
|
||||
update_mechanism: formData.machineBuilder.updateMechanism || '',
|
||||
export_markets: formData.machineBuilder.exportMarkets || [],
|
||||
critical_sector_clients: formData.machineBuilder.criticalSectorClients || false,
|
||||
critical_sectors: formData.machineBuilder.criticalSectors || [],
|
||||
oem_clients: formData.machineBuilder.oemClients || false,
|
||||
ce_marking_required: formData.machineBuilder.ceMarkingRequired || false,
|
||||
existing_ce_process: formData.machineBuilder.existingCEProcess || false,
|
||||
has_risk_assessment: formData.machineBuilder.hasRiskAssessment || false,
|
||||
},
|
||||
} : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function useCompanyProfileForm() {
|
||||
const { state, dispatch, setCompanyProfile, goToNextStep, projectId } = useSDK()
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [formData, setFormData] = useState<Partial<CompanyProfile>>(INITIAL_FORM_DATA)
|
||||
const [draftSaveStatus, setDraftSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
|
||||
const showMachineBuilderStep = isMachineBuilderIndustry(formData.industry || [])
|
||||
const wizardSteps = getWizardSteps(formData.industry || [])
|
||||
const lastStep = wizardSteps[wizardSteps.length - 1].id
|
||||
|
||||
const profileApiUrl = (extra?: string) => {
|
||||
const params = new URLSearchParams()
|
||||
if (projectId) params.set('project_id', projectId)
|
||||
const qs = params.toString()
|
||||
const base = '/api/sdk/v1/company-profile' + (extra || '')
|
||||
return qs ? `${base}?${qs}` : base
|
||||
}
|
||||
|
||||
// Load existing profile
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function loadFromBackend() {
|
||||
try {
|
||||
const apiUrl = '/api/sdk/v1/company-profile' + (projectId ? `?project_id=${encodeURIComponent(projectId)}` : '')
|
||||
const response = await fetch(apiUrl)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data && !cancelled) {
|
||||
const backendProfile: Partial<CompanyProfile> = {
|
||||
companyName: data.company_name || '', legalForm: data.legal_form || undefined,
|
||||
industry: Array.isArray(data.industry) ? data.industry : (data.industry ? [data.industry] : []),
|
||||
industryOther: data.industry_other || '', foundedYear: data.founded_year || undefined,
|
||||
businessModel: data.business_model || undefined, offerings: data.offerings || [],
|
||||
offeringUrls: data.offering_urls || {}, companySize: data.company_size || undefined,
|
||||
employeeCount: data.employee_count || '', annualRevenue: data.annual_revenue || '',
|
||||
headquartersCountry: data.headquarters_country || 'DE',
|
||||
headquartersCountryOther: data.headquarters_country_other || '',
|
||||
headquartersStreet: data.headquarters_street || '',
|
||||
headquartersZip: data.headquarters_zip || '', headquartersCity: data.headquarters_city || '',
|
||||
headquartersState: data.headquarters_state || '',
|
||||
hasInternationalLocations: data.has_international_locations || false,
|
||||
internationalCountries: data.international_countries || [],
|
||||
targetMarkets: data.target_markets || [], primaryJurisdiction: data.primary_jurisdiction || 'DE',
|
||||
isDataController: data.is_data_controller ?? true, isDataProcessor: data.is_data_processor ?? false,
|
||||
dpoName: data.dpo_name || '', dpoEmail: data.dpo_email || '',
|
||||
isComplete: data.is_complete || false,
|
||||
processingSystems: data.processing_systems || [], aiSystems: data.ai_systems || [],
|
||||
technicalContacts: data.technical_contacts || [],
|
||||
existingCertifications: data.existing_certifications || [],
|
||||
targetCertifications: data.target_certifications || [],
|
||||
targetCertificationOther: data.target_certification_other || '',
|
||||
reviewCycleMonths: data.review_cycle_months || 12,
|
||||
repos: data.repos || [], documentSources: data.document_sources || [],
|
||||
} as any
|
||||
setFormData(backendProfile)
|
||||
setCompanyProfile(backendProfile as CompanyProfile)
|
||||
if (backendProfile.isComplete) {
|
||||
setCurrentStep(99)
|
||||
dispatch({ type: 'COMPLETE_STEP', payload: 'company-profile' })
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch { /* Backend not available, fall through to SDK state */ }
|
||||
|
||||
if (!cancelled && state.companyProfile) {
|
||||
setFormData(state.companyProfile)
|
||||
if (state.companyProfile.isComplete) setCurrentStep(99)
|
||||
}
|
||||
}
|
||||
loadFromBackend()
|
||||
return () => { cancelled = true }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId])
|
||||
|
||||
const updateFormData = (updates: Partial<CompanyProfile>) => {
|
||||
setFormData(prev => ({ ...prev, ...updates }))
|
||||
}
|
||||
|
||||
// Auto-save to SDK context (debounced)
|
||||
const autoSaveRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const initialLoadDone = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialLoadDone.current) {
|
||||
if (formData.companyName !== undefined) initialLoadDone.current = true
|
||||
return
|
||||
}
|
||||
if (currentStep === 99) return
|
||||
if (autoSaveRef.current) clearTimeout(autoSaveRef.current)
|
||||
autoSaveRef.current = setTimeout(() => {
|
||||
const hasData = formData.companyName || (formData.industry && formData.industry.length > 0)
|
||||
if (hasData) setCompanyProfile({ ...formData, isComplete: false, completedAt: null } as CompanyProfile)
|
||||
}, 500)
|
||||
return () => { if (autoSaveRef.current) clearTimeout(autoSaveRef.current) }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formData, currentStep])
|
||||
|
||||
// Auto-save draft to backend (debounced, 2s)
|
||||
const backendAutoSaveRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const draftSaveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialLoadDone.current) return
|
||||
if (currentStep === 99) return
|
||||
const hasData = formData.companyName || (formData.industry && formData.industry.length > 0)
|
||||
if (!hasData) return
|
||||
|
||||
if (backendAutoSaveRef.current) clearTimeout(backendAutoSaveRef.current)
|
||||
backendAutoSaveRef.current = setTimeout(async () => {
|
||||
try {
|
||||
await fetch(profileApiUrl(), {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildProfilePayload(formData, projectId, false)),
|
||||
})
|
||||
setDraftSaveStatus('saved')
|
||||
if (draftSaveTimerRef.current) clearTimeout(draftSaveTimerRef.current)
|
||||
draftSaveTimerRef.current = setTimeout(() => setDraftSaveStatus('idle'), 3000)
|
||||
} catch { /* Silent fail for auto-save */ }
|
||||
}, 2000)
|
||||
|
||||
return () => { if (backendAutoSaveRef.current) clearTimeout(backendAutoSaveRef.current) }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formData, currentStep])
|
||||
|
||||
const saveProfileDraft = async () => {
|
||||
setDraftSaveStatus('saving')
|
||||
try {
|
||||
await fetch(profileApiUrl(), {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildProfilePayload(formData, projectId, false)),
|
||||
})
|
||||
setCompanyProfile({ ...formData, isComplete: false, completedAt: null } as CompanyProfile)
|
||||
setDraftSaveStatus('saved')
|
||||
if (draftSaveTimerRef.current) clearTimeout(draftSaveTimerRef.current)
|
||||
draftSaveTimerRef.current = setTimeout(() => setDraftSaveStatus('idle'), 3000)
|
||||
} catch (err) {
|
||||
console.error('Draft save failed:', err)
|
||||
setDraftSaveStatus('error')
|
||||
if (draftSaveTimerRef.current) clearTimeout(draftSaveTimerRef.current)
|
||||
draftSaveTimerRef.current = setTimeout(() => setDraftSaveStatus('idle'), 5000)
|
||||
}
|
||||
}
|
||||
|
||||
const completeAndSaveProfile = async () => {
|
||||
if (autoSaveRef.current) clearTimeout(autoSaveRef.current)
|
||||
if (backendAutoSaveRef.current) clearTimeout(backendAutoSaveRef.current)
|
||||
|
||||
const completeProfile: CompanyProfile = { ...formData, isComplete: true, completedAt: new Date() } as CompanyProfile
|
||||
try {
|
||||
await fetch(profileApiUrl(), {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(buildProfilePayload(formData, projectId, true)),
|
||||
})
|
||||
} catch (err) { console.error('Failed to save company profile to backend:', err) }
|
||||
|
||||
setCompanyProfile(completeProfile)
|
||||
dispatch({ type: 'COMPLETE_STEP', payload: 'company-profile' })
|
||||
dispatch({ type: 'SET_STATE', payload: { projectVersion: (state.projectVersion || 0) + 1 } })
|
||||
setCurrentStep(99)
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < lastStep) {
|
||||
const nextStep = currentStep + 1
|
||||
if (nextStep === 7 && !showMachineBuilderStep) {
|
||||
completeAndSaveProfile()
|
||||
return
|
||||
}
|
||||
saveProfileDraft()
|
||||
setCurrentStep(nextStep)
|
||||
} else {
|
||||
completeAndSaveProfile()
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep > 1) { saveProfileDraft(); setCurrentStep(prev => prev - 1) }
|
||||
}
|
||||
|
||||
const handleDeleteProfile = async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
const response = await fetch(profileApiUrl(), { method: 'DELETE' })
|
||||
if (response.ok) {
|
||||
setFormData(INITIAL_FORM_DATA)
|
||||
setCurrentStep(1)
|
||||
dispatch({ type: 'SET_STATE', payload: { companyProfile: undefined } })
|
||||
}
|
||||
} catch (err) { console.error('Failed to delete company profile:', err) }
|
||||
finally { setIsDeleting(false); setShowDeleteConfirm(false) }
|
||||
}
|
||||
|
||||
const canProceed = () => {
|
||||
switch (currentStep) {
|
||||
case 1: return formData.companyName && formData.legalForm
|
||||
case 2: return formData.businessModel && (formData.offerings?.length || 0) > 0
|
||||
case 3: return formData.companySize
|
||||
case 4: return formData.headquartersCountry && (formData.targetMarkets?.length || 0) > 0
|
||||
case 5: return true
|
||||
case 6: return true
|
||||
case 7: return (formData.machineBuilder?.productDescription?.length || 0) > 0
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
const isLastStep = currentStep === lastStep || (currentStep === 6 && !showMachineBuilderStep)
|
||||
|
||||
return {
|
||||
formData, updateFormData, currentStep, setCurrentStep,
|
||||
wizardSteps, showMachineBuilderStep, isLastStep,
|
||||
draftSaveStatus, canProceed, handleNext, handleBack,
|
||||
handleDeleteProfile, showDeleteConfirm, setShowDeleteConfirm,
|
||||
isDeleting, goToNextStep,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,91 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { DashboardData, MappingsData, FindingsData } from '../_hooks/useComplianceHub'
|
||||
|
||||
export function MappingsAndFindings({
|
||||
dashboard,
|
||||
mappings,
|
||||
findings,
|
||||
}: {
|
||||
dashboard: DashboardData | null
|
||||
mappings: MappingsData | null
|
||||
findings: FindingsData | null
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Control-Mappings</h3>
|
||||
<Link href="/sdk/controls" className="text-sm text-purple-600 hover:text-purple-700">
|
||||
Alle anzeigen →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 mb-4">
|
||||
<div>
|
||||
<p className="text-4xl font-bold text-purple-600">{mappings?.total || 0}</p>
|
||||
<p className="text-sm text-slate-500">Mappings gesamt</p>
|
||||
</div>
|
||||
<div className="flex-1 h-16 bg-slate-50 rounded-lg p-3">
|
||||
<p className="text-xs text-slate-500 mb-1">Nach Verordnung</p>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{mappings?.by_regulation && Object.entries(mappings.by_regulation).slice(0, 5).map(([reg, count]) => (
|
||||
<span key={reg} className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
|
||||
{reg}: {count}
|
||||
</span>
|
||||
))}
|
||||
{!mappings?.by_regulation && (
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-xs">Keine Mappings vorhanden</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">
|
||||
Automatisch generierte Verknuepfungen zwischen {dashboard?.total_controls || 0} Controls
|
||||
und {dashboard?.total_requirements || 0} Anforderungen aus {dashboard?.total_regulations || 0} Verordnungen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Audit Findings</h3>
|
||||
<Link href="/sdk/audit-checklist" className="text-sm text-purple-600 hover:text-purple-700">
|
||||
Audit Checkliste →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full" />
|
||||
<span className="text-sm font-medium text-red-800">Hauptabweichungen</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-red-600">{findings?.open_majors || 0}</p>
|
||||
<p className="text-xs text-red-600">offen (blockiert Zertifizierung)</p>
|
||||
</div>
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded-full" />
|
||||
<span className="text-sm font-medium text-yellow-800">Nebenabweichungen</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-yellow-600">{findings?.open_minors || 0}</p>
|
||||
<p className="text-xs text-yellow-600">offen (erfordert CAPA)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">
|
||||
Gesamt: {findings?.total || 0} Findings ({findings?.major_count || 0} Major, {findings?.minor_count || 0} Minor, {findings?.ofi_count || 0} OFI)
|
||||
</span>
|
||||
{(findings?.open_majors || 0) === 0 ? (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs font-medium">
|
||||
Zertifizierung moeglich
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium">
|
||||
Zertifizierung blockiert
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { ModuleStatusData } from './types'
|
||||
import { MODULE_ICONS } from './types'
|
||||
|
||||
interface ModulesTabProps {
|
||||
moduleStatus: ModuleStatusData | null
|
||||
}
|
||||
|
||||
export function ModulesTab({ moduleStatus }: ModulesTabProps) {
|
||||
if (!moduleStatus) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6 text-center">
|
||||
<p className="text-sm text-slate-500">Gesamt-Fortschritt</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{moduleStatus.overall_progress.toFixed(0)}%</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6 text-center">
|
||||
<p className="text-sm text-slate-500">Module gestartet</p>
|
||||
<p className="text-3xl font-bold text-blue-600">{moduleStatus.started}/{moduleStatus.total}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6 text-center">
|
||||
<p className="text-sm text-slate-500">Module abgeschlossen</p>
|
||||
<p className="text-3xl font-bold text-green-600">{moduleStatus.complete}/{moduleStatus.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Module Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{moduleStatus.modules.map(mod => (
|
||||
<div key={mod.key} className="bg-white rounded-xl shadow-sm border p-5">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="text-2xl">{MODULE_ICONS[mod.key] || '📦'}</span>
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-900">{mod.label}</h4>
|
||||
<p className="text-xs text-slate-500">{mod.count} Eintraege</p>
|
||||
</div>
|
||||
<span className={`ml-auto px-2 py-0.5 text-xs rounded-full font-medium ${
|
||||
mod.status === 'complete' ? 'bg-green-100 text-green-700' :
|
||||
mod.status === 'in_progress' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-slate-100 text-slate-500'
|
||||
}`}>
|
||||
{mod.status === 'complete' ? 'Fertig' :
|
||||
mod.status === 'in_progress' ? 'In Arbeit' : 'Offen'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-500 ${
|
||||
mod.status === 'complete' ? 'bg-green-500' :
|
||||
mod.status === 'in_progress' ? 'bg-yellow-500' : 'bg-slate-300'
|
||||
}`}
|
||||
style={{ width: `${mod.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { DashboardData, MappingsData, FindingsData, NextAction } from './types'
|
||||
import { DOMAIN_LABELS } from './types'
|
||||
|
||||
interface OverviewTabProps {
|
||||
dashboard: DashboardData | null
|
||||
mappings: MappingsData | null
|
||||
findings: FindingsData | null
|
||||
nextActions: NextAction[]
|
||||
evidenceDistribution: {
|
||||
by_confidence: Record<string, number>
|
||||
four_eyes_pending: number
|
||||
total: number
|
||||
} | null
|
||||
score: number
|
||||
scoreColor: string
|
||||
scoreBgColor: string
|
||||
loadData: () => void
|
||||
regulations: Array<{ id: string; code: string; name: string; regulation_type: string; requirement_count: number }>
|
||||
}
|
||||
|
||||
export function OverviewTab({
|
||||
dashboard, mappings, findings, nextActions, evidenceDistribution,
|
||||
score, scoreColor, scoreBgColor, loadData, regulations,
|
||||
}: OverviewTabProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schnellzugriff</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-4">
|
||||
{[
|
||||
{ href: '/sdk/audit-checklist', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01', label: 'Audit Checkliste', sub: `${dashboard?.total_requirements || '...'} Anforderungen`, color: 'purple' },
|
||||
{ href: '/sdk/controls', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z', label: 'Controls', sub: `${dashboard?.total_controls || '...'} Massnahmen`, color: 'green' },
|
||||
{ href: '/sdk/evidence', icon: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z', label: 'Evidence', sub: 'Nachweise', color: 'blue' },
|
||||
{ href: '/sdk/risks', icon: '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', label: 'Risk Matrix', sub: '5x5 Risiken', color: 'red' },
|
||||
{ href: '/sdk/process-tasks', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4', label: 'Prozesse', sub: 'Aufgaben', color: 'indigo' },
|
||||
{ href: '/sdk/audit-report', icon: 'M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', label: 'Audit Report', sub: 'PDF Export', color: 'orange' },
|
||||
].map(item => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`p-4 rounded-lg border border-slate-200 hover:border-${item.color}-500 hover:bg-${item.color}-50 transition-colors text-center`}
|
||||
>
|
||||
<div className={`text-${item.color}-600 mb-2 flex justify-center`}>
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={item.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="font-medium text-slate-900 text-sm">{item.label}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{item.sub}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score and Stats Row */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Compliance Score</h3>
|
||||
<div className={`text-5xl font-bold ${scoreColor}`}>
|
||||
{score.toFixed(0)}%
|
||||
</div>
|
||||
<div className="mt-4 h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div className={`h-full transition-all duration-500 ${scoreBgColor}`} style={{ width: `${score}%` }} />
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
{dashboard?.controls_by_status?.pass || 0} von {dashboard?.total_controls || 0} Controls bestanden
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{[
|
||||
{ label: 'Verordnungen', value: dashboard?.total_regulations || 0, sub: `${dashboard?.total_requirements || 0} Anforderungen`, iconColor: 'blue', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
|
||||
{ label: 'Controls', value: dashboard?.total_controls || 0, sub: `${dashboard?.controls_by_status?.pass || 0} bestanden`, iconColor: 'green', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
{ label: 'Nachweise', value: dashboard?.total_evidence || 0, sub: `${dashboard?.evidence_by_status?.valid || 0} aktiv`, iconColor: 'purple', icon: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z' },
|
||||
{ label: 'Risiken', value: dashboard?.total_risks || 0, sub: `${(dashboard?.risks_by_level?.high || 0) + (dashboard?.risks_by_level?.critical || 0)} kritisch`, iconColor: 'red', icon: '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' },
|
||||
].map(stat => (
|
||||
<div key={stat.label} className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">{stat.label}</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{stat.value}</p>
|
||||
</div>
|
||||
<div className={`w-10 h-10 bg-${stat.iconColor}-100 rounded-lg flex items-center justify-center`}>
|
||||
<svg className={`w-5 h-5 text-${stat.iconColor}-600`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={stat.icon} />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-500">{stat.sub}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Anti-Fake-Evidence Section (Phase 3) */}
|
||||
{dashboard && (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Anti-Fake-Evidence Status</h3>
|
||||
|
||||
{/* Confidence Distribution Bar */}
|
||||
{evidenceDistribution && evidenceDistribution.total > 0 && (
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-slate-500 mb-2">Confidence-Verteilung ({evidenceDistribution.total} Nachweise)</p>
|
||||
<div className="flex h-6 rounded-full overflow-hidden">
|
||||
{(['E0', 'E1', 'E2', 'E3', 'E4'] as const).map(level => {
|
||||
const count = evidenceDistribution.by_confidence[level] || 0
|
||||
const pct = (count / evidenceDistribution.total) * 100
|
||||
if (pct === 0) return null
|
||||
const colors: Record<string, string> = {
|
||||
E0: 'bg-red-400', E1: 'bg-yellow-400', E2: 'bg-blue-400', E3: 'bg-green-400', E4: 'bg-emerald-400'
|
||||
}
|
||||
return (
|
||||
<div key={level} className={`${colors[level]} flex items-center justify-center text-xs text-white font-medium`}
|
||||
style={{ width: `${pct}%` }} title={`${level}: ${count}`}>
|
||||
{pct >= 10 ? `${level} (${count})` : ''}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
|
||||
{(['E0', 'E1', 'E2', 'E3', 'E4'] as const).map(level => {
|
||||
const count = evidenceDistribution.by_confidence[level] || 0
|
||||
const dotColors: Record<string, string> = {
|
||||
E0: 'bg-red-400', E1: 'bg-yellow-400', E2: 'bg-blue-400', E3: 'bg-green-400', E4: 'bg-emerald-400'
|
||||
}
|
||||
return (
|
||||
<span key={level} className="flex items-center gap-1">
|
||||
<span className={`w-2 h-2 rounded-full ${dotColors[level]}`} />
|
||||
{level}: {count}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Multi-Score Dimensions */}
|
||||
{dashboard.multi_score && (
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-slate-500 mb-2">Multi-dimensionaler Score</p>
|
||||
<div className="space-y-2">
|
||||
{([
|
||||
{ key: 'requirement_coverage', label: 'Anforderungsabdeckung', color: 'bg-blue-500' },
|
||||
{ key: 'evidence_strength', label: 'Evidence-Staerke', color: 'bg-green-500' },
|
||||
{ key: 'validation_quality', label: 'Validierungsqualitaet', color: 'bg-purple-500' },
|
||||
{ key: 'evidence_freshness', label: 'Aktualitaet', color: 'bg-yellow-500' },
|
||||
{ key: 'control_effectiveness', label: 'Control-Wirksamkeit', color: 'bg-indigo-500' },
|
||||
] as const).map(dim => {
|
||||
const value = (dashboard.multi_score as Record<string, number>)[dim.key] || 0
|
||||
return (
|
||||
<div key={dim.key} className="flex items-center gap-3">
|
||||
<span className="text-xs text-slate-600 w-44 truncate">{dim.label}</span>
|
||||
<div className="flex-1 h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div className={`h-full ${dim.color} rounded-full transition-all`} style={{ width: `${value}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-slate-600 w-12 text-right">{typeof value === 'number' ? value.toFixed(0) : value}%</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex items-center gap-3 pt-2 border-t border-slate-100">
|
||||
<span className="text-xs font-semibold text-slate-700 w-44">Audit-Readiness</span>
|
||||
<div className="flex-1 h-3 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all ${
|
||||
(dashboard.multi_score.overall_readiness || 0) >= 80 ? 'bg-green-500' :
|
||||
(dashboard.multi_score.overall_readiness || 0) >= 60 ? 'bg-yellow-500' : 'bg-red-500'
|
||||
}`} style={{ width: `${dashboard.multi_score.overall_readiness || 0}%` }} />
|
||||
</div>
|
||||
<span className="text-xs font-semibold text-slate-700 w-12 text-right">
|
||||
{typeof dashboard.multi_score.overall_readiness === 'number' ? dashboard.multi_score.overall_readiness.toFixed(0) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom row: Four-Eyes + Hard Blocks */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="text-center p-3 rounded-lg bg-yellow-50">
|
||||
<div className="text-2xl font-bold text-yellow-700">{evidenceDistribution?.four_eyes_pending || 0}</div>
|
||||
<div className="text-xs text-yellow-600 mt-1">Four-Eyes Reviews ausstehend</div>
|
||||
</div>
|
||||
{dashboard.multi_score?.hard_blocks && dashboard.multi_score.hard_blocks.length > 0 ? (
|
||||
<div className="p-3 rounded-lg bg-red-50">
|
||||
<div className="text-xs font-medium text-red-700 mb-1">Hard Blocks ({dashboard.multi_score.hard_blocks.length})</div>
|
||||
<ul className="space-y-1">
|
||||
{dashboard.multi_score.hard_blocks.slice(0, 3).map((block: string, i: number) => (
|
||||
<li key={i} className="text-xs text-red-600 flex items-start gap-1">
|
||||
<span className="text-red-400 mt-0.5">•</span>
|
||||
<span>{block}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-3 rounded-lg bg-green-50">
|
||||
<div className="text-2xl font-bold text-green-700">0</div>
|
||||
<div className="text-xs text-green-600 mt-1">Keine Hard Blocks</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Next Actions + Findings */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Next Actions */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Naechste Aktionen</h3>
|
||||
{nextActions.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">Keine offenen Aktionen.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{nextActions.map(action => (
|
||||
<div key={action.id} className="flex items-center gap-3 p-3 rounded-lg bg-slate-50">
|
||||
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
||||
action.days_overdue > 0 ? 'bg-red-500' : 'bg-yellow-500'
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900 truncate">{action.title}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{action.control_id} · {DOMAIN_LABELS[action.domain] || action.domain}
|
||||
{action.days_overdue > 0 && <span className="text-red-600 ml-2">{action.days_overdue}d ueberfaellig</span>}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
action.status === 'partial' ? 'bg-yellow-100 text-yellow-700' : 'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{action.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Audit Findings */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Audit Findings</h3>
|
||||
<Link href="/sdk/audit-checklist" className="text-sm text-purple-600 hover:text-purple-700">
|
||||
Audit Checkliste →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full" />
|
||||
<span className="text-sm font-medium text-red-800">Hauptabweichungen</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-red-600">{findings?.open_majors || 0}</p>
|
||||
<p className="text-xs text-red-600">offen (blockiert Zertifizierung)</p>
|
||||
</div>
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded-full" />
|
||||
<span className="text-sm font-medium text-yellow-800">Nebenabweichungen</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-yellow-600">{findings?.open_minors || 0}</p>
|
||||
<p className="text-xs text-yellow-600">offen (erfordert CAPA)</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">
|
||||
Gesamt: {findings?.total || 0} Findings ({findings?.major_count || 0} Major, {findings?.minor_count || 0} Minor, {findings?.ofi_count || 0} OFI)
|
||||
</span>
|
||||
{(findings?.open_majors || 0) === 0 ? (
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs font-medium">
|
||||
Zertifizierung moeglich
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium">
|
||||
Zertifizierung blockiert
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Control-Mappings & Domain Chart */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Control-Mappings</h3>
|
||||
<Link href="/sdk/controls" className="text-sm text-purple-600 hover:text-purple-700">
|
||||
Alle anzeigen →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 mb-4">
|
||||
<div>
|
||||
<p className="text-4xl font-bold text-purple-600">{mappings?.total || 0}</p>
|
||||
<p className="text-sm text-slate-500">Mappings gesamt</p>
|
||||
</div>
|
||||
<div className="flex-1 h-16 bg-slate-50 rounded-lg p-3">
|
||||
<p className="text-xs text-slate-500 mb-1">Nach Verordnung</p>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{mappings?.by_regulation && Object.entries(mappings.by_regulation).slice(0, 5).map(([reg, count]) => (
|
||||
<span key={reg} className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
|
||||
{reg}: {count}
|
||||
</span>
|
||||
))}
|
||||
{!mappings?.by_regulation && (
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-xs">Keine Mappings vorhanden</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Controls nach Domain</h3>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(dashboard?.controls_by_domain || {}).slice(0, 6).map(([domain, stats]) => {
|
||||
const total = stats.total || 0
|
||||
const pass = stats.pass || 0
|
||||
const partial = stats.partial || 0
|
||||
const passPercent = total > 0 ? ((pass + partial * 0.5) / total) * 100 : 0
|
||||
|
||||
return (
|
||||
<div key={domain} className="flex items-center gap-3">
|
||||
<span className="text-xs font-medium text-slate-600 w-24 truncate">
|
||||
{DOMAIN_LABELS[domain] || domain}
|
||||
</span>
|
||||
<div className="flex-1 h-2 bg-slate-200 rounded-full overflow-hidden flex">
|
||||
<div className="bg-green-500 h-full" style={{ width: `${(pass / (total || 1)) * 100}%` }} />
|
||||
<div className="bg-yellow-500 h-full" style={{ width: `${(partial / (total || 1)) * 100}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 w-16 text-right">{passPercent.toFixed(0)}%</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Regulations Table */}
|
||||
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Verordnungen & Standards ({regulations.length})</h3>
|
||||
<button onClick={loadData} className="text-sm text-purple-600 hover:text-purple-700">
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Code</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Typ</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Anforderungen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-200">
|
||||
{regulations.slice(0, 15).map((reg) => (
|
||||
<tr key={reg.id} className="hover:bg-slate-50">
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono font-medium text-purple-600">{reg.code}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-slate-900">{reg.name}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
reg.regulation_type === 'eu_regulation' ? 'bg-blue-100 text-blue-700' :
|
||||
reg.regulation_type === 'eu_directive' ? 'bg-purple-100 text-purple-700' :
|
||||
reg.regulation_type === 'bsi_standard' ? 'bg-green-100 text-green-700' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{reg.regulation_type === 'eu_regulation' ? 'EU-VO' :
|
||||
reg.regulation_type === 'eu_directive' ? 'EU-RL' :
|
||||
reg.regulation_type === 'bsi_standard' ? 'BSI' :
|
||||
reg.regulation_type === 'de_law' ? 'DE' : reg.regulation_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="font-medium">{reg.requirement_count}</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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