Compare commits
78 Commits
feature/pa
...
coolify
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffae41237e | ||
|
|
1c1af4e38d | ||
|
|
92a730626d | ||
|
|
cc3a9a37dc | ||
|
|
e6ff76d0e1 | ||
|
|
554320770a | ||
|
|
eeb9931d87 | ||
|
|
76962a2831 | ||
|
|
4921d1c052 | ||
|
|
6571b580dc | ||
|
|
d5287f4bdd | ||
|
|
82a5a388b8 | ||
|
|
637eb012f5 | ||
|
|
2b818c6fb3 | ||
|
|
3a22a2fa52 | ||
|
|
375b34a0d8 | ||
|
|
ff9f5e849c | ||
|
|
2fb6b98bc5 | ||
|
|
1f45d6cca8 | ||
|
|
dca0c96f2a | ||
|
|
74927c6f66 | ||
|
|
ddcd89f26d | ||
|
|
5cb91e88d2 | ||
|
|
4ed39d2616 | ||
|
|
ef8284dff5 | ||
|
|
6c883fb12e | ||
|
|
f7b77fd504 | ||
|
|
ff775517a2 | ||
|
|
98a773c7cd | ||
|
|
528abc86ab | ||
|
|
be4d58009a | ||
|
|
e07e1de6c9 | ||
|
|
58e95d5e8e | ||
|
|
786bb409e4 | ||
|
|
3c4f7d900d | ||
|
|
aae07b7a9b | ||
|
|
911d872178 | ||
|
|
fc6a3306d4 | ||
|
|
ab6ba63108 | ||
|
|
769e8c12d5 | ||
|
|
7344e5806e | ||
|
|
32e121f2a3 | ||
|
|
07d470edee | ||
|
|
a84dccb339 | ||
|
|
1a2ae896fb | ||
|
|
d35b0bc78c | ||
|
|
ae008d7d25 | ||
|
|
6658776610 | ||
|
|
d2c94619d8 | ||
|
|
cc1c61947d | ||
|
|
0c2e03f294 | ||
|
|
a638d0e527 | ||
|
|
e613af1a7d | ||
|
|
7107a31496 | ||
|
|
b850368ec9 | ||
|
|
4fa0dd6f6d | ||
|
|
f39c7ca40c | ||
|
|
d571412657 | ||
|
|
10073f3ef0 | ||
|
|
883ef702ac | ||
|
|
4a91814bfc | ||
|
|
482e8574ad | ||
|
|
d9dcfb97ef | ||
|
|
3320ef94fc | ||
|
|
1dfea51919 | ||
|
|
559d7960a2 | ||
|
|
a101426dba | ||
|
|
f6b22820ce | ||
|
|
86588aff09 | ||
|
|
033fa52e5b | ||
|
|
005fb9d219 | ||
|
|
0c01f1c96c | ||
|
|
ffd256d420 | ||
|
|
d542dbbacd | ||
|
|
a3d0024d39 | ||
|
|
998d427c3c | ||
|
|
99f3180ffc | ||
|
|
2ec340c64b |
@@ -1,5 +1,17 @@
|
||||
# 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 + Hetzner
|
||||
|
||||
43
.claude/rules/architecture.md
Normal file
43
.claude/rules/architecture.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Architecture Rules (auto-loaded)
|
||||
|
||||
These rules apply to **every** Claude Code session in this repository, regardless of who launched it. They are non-negotiable.
|
||||
|
||||
## File-size budget
|
||||
|
||||
- **Soft target:** 300 lines per non-test, non-generated source file.
|
||||
- **Hard cap:** 500 lines. The PreToolUse hook in `.claude/settings.json` blocks Write/Edit operations that would create or push a file past 500. The git pre-commit hook re-checks. CI is the final gate.
|
||||
- Exceptions live in `.claude/rules/loc-exceptions.txt` and require a written rationale plus `[guardrail-change]` in the commit message. The exceptions list should shrink over time, not grow.
|
||||
|
||||
## Clean architecture
|
||||
|
||||
- Python (FastAPI): see `AGENTS.python.md`. Layering: `api → services → repositories → db.models`. Routers ≤30 LOC per handler. Schemas split per domain.
|
||||
- Go (Gin): see `AGENTS.go.md`. Standard Go Project Layout + hexagonal. `cmd/` thin, wiring in `internal/app`.
|
||||
- TypeScript (Next.js): see `AGENTS.typescript.md`. Server-by-default, push the client boundary deep, colocate `_components/` and `_hooks/` per route.
|
||||
|
||||
## Database is frozen
|
||||
|
||||
- No new Alembic migrations. No `ALTER TABLE`. No `__tablename__` or column renames.
|
||||
- The pre-commit hook blocks any change under `migrations/` or `alembic/versions/` unless the commit message contains `[migration-approved]`.
|
||||
|
||||
## Public endpoints are a contract
|
||||
|
||||
- Any change to a path/method/status/request schema/response schema in a backend service must update every consumer in the same change set.
|
||||
- Each backend service has an OpenAPI baseline at `tests/contracts/openapi.baseline.json`. Contract tests fail on drift.
|
||||
|
||||
## Tests
|
||||
|
||||
- New code without tests fails CI.
|
||||
- Refactors must preserve coverage. Before splitting an oversized file, add a characterization test that pins current behavior.
|
||||
- Layout: `tests/unit/`, `tests/integration/`, `tests/contracts/`, `tests/e2e/`.
|
||||
|
||||
## Guardrails are themselves protected
|
||||
|
||||
- Edits to `.claude/settings.json`, `scripts/check-loc.sh`, `scripts/githooks/pre-commit`, `.claude/rules/loc-exceptions.txt`, or any `AGENTS.*.md` require `[guardrail-change]` in the commit message. The pre-commit hook enforces this.
|
||||
- If you (Claude) think a rule is wrong, surface it to the user. Do not silently weaken it.
|
||||
|
||||
## Tooling baseline
|
||||
|
||||
- Python: `ruff`, `mypy --strict` on new modules, `pytest --cov`.
|
||||
- Go: `golangci-lint` strict config, `go vet`, table-driven tests.
|
||||
- TS: `tsc --noEmit` strict, ESLint type-aware, Vitest, Playwright.
|
||||
- All three: dependency caching in CI, license/SBOM scan via `syft`+`grype`.
|
||||
48
.claude/rules/loc-exceptions.txt
Normal file
48
.claude/rules/loc-exceptions.txt
Normal file
@@ -0,0 +1,48 @@
|
||||
# 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
|
||||
28
.claude/settings.json
Normal file
28
.claude/settings.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "f=$(jq -r '.tool_input.file_path // empty'); [ -z \"$f\" ] && exit 0; lines=$(printf '%s' \"$(jq -r '.tool_input.content // empty')\" | awk 'END{print NR}'); if [ \"${lines:-0}\" -gt 500 ]; then echo '{\"decision\":\"block\",\"reason\":\"breakpilot guardrail: file exceeds the 500-line hard cap. Split it into smaller modules per the layering rules in AGENTS.<lang>.md. If this is generated/data code, add an entry to .claude/rules/loc-exceptions.txt with rationale and reference [guardrail-change].\"}'; exit 0; fi",
|
||||
"shell": "bash",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "f=$(jq -r '.tool_input.file_path // empty'); [ -z \"$f\" ] || [ ! -f \"$f\" ] && exit 0; case \"$f\" in *.md|*.json|*.yaml|*.yml|*test*|*tests/*|*node_modules/*|*.next/*|*migrations/*) exit 0 ;; esac; new_str=$(jq -r '.tool_input.new_string // empty'); old_str=$(jq -r '.tool_input.old_string // empty'); old_lines=$(printf '%s' \"$old_str\" | awk 'END{print NR}'); new_lines=$(printf '%s' \"$new_str\" | awk 'END{print NR}'); cur=$(wc -l < \"$f\" | tr -d ' '); proj=$((cur - old_lines + new_lines)); if [ \"$proj\" -gt 500 ]; then echo \"{\\\"decision\\\":\\\"block\\\",\\\"reason\\\":\\\"breakpilot guardrail: this edit would push $f to ~$proj lines (hard cap is 500). Split the file before continuing. See AGENTS.<lang>.md for the layering rules.\\\"}\"; fi; exit 0",
|
||||
"shell": "bash",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
61
.env.coolify.example
Normal file
61
.env.coolify.example
Normal file
@@ -0,0 +1,61 @@
|
||||
# =========================================================
|
||||
# BreakPilot Compliance — Coolify Environment Variables
|
||||
# =========================================================
|
||||
# Copy these into Coolify's environment variable UI
|
||||
# for the breakpilot-compliance Docker Compose resource.
|
||||
# =========================================================
|
||||
|
||||
# --- External PostgreSQL (Coolify-managed, same as Core) ---
|
||||
COMPLIANCE_DATABASE_URL=postgresql://breakpilot:CHANGE_ME@<coolify-postgres-hostname>:5432/breakpilot_db
|
||||
|
||||
# --- Security ---
|
||||
JWT_SECRET=CHANGE_ME_SAME_AS_CORE
|
||||
|
||||
# --- External S3 Storage (same as Core) ---
|
||||
S3_ENDPOINT=<s3-endpoint-host:port>
|
||||
S3_ACCESS_KEY=CHANGE_ME_SAME_AS_CORE
|
||||
S3_SECRET_KEY=CHANGE_ME_SAME_AS_CORE
|
||||
S3_SECURE=true
|
||||
|
||||
# --- External Qdrant ---
|
||||
QDRANT_URL=https://<qdrant-hostname>
|
||||
QDRANT_API_KEY=CHANGE_ME_QDRANT_API_KEY
|
||||
|
||||
# --- Session ---
|
||||
SESSION_TTL_HOURS=24
|
||||
|
||||
# --- SMTP (Real mail server) ---
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=compliance@breakpilot.ai
|
||||
SMTP_PASSWORD=CHANGE_ME_SMTP_PASSWORD
|
||||
SMTP_FROM_NAME=BreakPilot Compliance
|
||||
SMTP_FROM_ADDR=compliance@breakpilot.ai
|
||||
|
||||
# --- LLM Configuration ---
|
||||
COMPLIANCE_LLM_PROVIDER=anthropic
|
||||
SELF_HOSTED_LLM_URL=
|
||||
SELF_HOSTED_LLM_MODEL=
|
||||
COMPLIANCE_LLM_MAX_TOKENS=4096
|
||||
COMPLIANCE_LLM_TEMPERATURE=0.3
|
||||
COMPLIANCE_LLM_TIMEOUT=120
|
||||
ANTHROPIC_API_KEY=CHANGE_ME_ANTHROPIC_KEY
|
||||
ANTHROPIC_DEFAULT_MODEL=claude-sonnet-4-5-20250929
|
||||
|
||||
# --- Ollama (optional) ---
|
||||
OLLAMA_URL=
|
||||
OLLAMA_DEFAULT_MODEL=
|
||||
COMPLIANCE_LLM_MODEL=
|
||||
|
||||
# --- LLM Fallback ---
|
||||
LLM_FALLBACK_PROVIDER=
|
||||
|
||||
# --- PII & Audit ---
|
||||
PII_REDACTION_ENABLED=true
|
||||
PII_REDACTION_LEVEL=standard
|
||||
AUDIT_RETENTION_DAYS=365
|
||||
AUDIT_LOG_PROMPTS=true
|
||||
|
||||
# --- Frontend URLs (build args) ---
|
||||
NEXT_PUBLIC_API_URL=https://api-compliance.breakpilot.ai
|
||||
NEXT_PUBLIC_SDK_URL=https://sdk.breakpilot.ai
|
||||
@@ -7,7 +7,7 @@
|
||||
# Node.js: admin-compliance, developer-portal
|
||||
#
|
||||
# Workflow:
|
||||
# Push auf main → Tests → Build → Deploy (Hetzner)
|
||||
# Push auf main → Tests → Deploy (Coolify)
|
||||
# Pull Request → Lint + Tests (kein Deploy)
|
||||
|
||||
name: CI/CD
|
||||
@@ -19,6 +19,55 @@ on:
|
||||
branches: [main, develop]
|
||||
|
||||
jobs:
|
||||
# ========================================
|
||||
# Guardrails — LOC budget + architecture gates
|
||||
# Runs on every push/PR. Fails fast and cheap.
|
||||
# ========================================
|
||||
|
||||
loc-budget:
|
||||
runs-on: docker
|
||||
container: alpine:3.20
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git bash
|
||||
git clone --depth 50 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Enforce 500-line hard cap on changed files
|
||||
run: |
|
||||
chmod +x scripts/check-loc.sh
|
||||
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
|
||||
git fetch origin ${GITHUB_BASE_REF}:base
|
||||
mapfile -t changed < <(git diff --name-only --diff-filter=ACM base...HEAD)
|
||||
[ ${#changed[@]} -eq 0 ] && { echo "No changed files."; exit 0; }
|
||||
scripts/check-loc.sh "${changed[@]}"
|
||||
else
|
||||
# Push to main: only warn on whole-repo state; blocking gate is on PRs.
|
||||
scripts/check-loc.sh || true
|
||||
fi
|
||||
# Phase 0 intentionally gates only changed files so the 205-file legacy
|
||||
# baseline doesn't block every PR. Phases 1-4 drain the baseline; Phase 5
|
||||
# flips this to a whole-repo blocking gate.
|
||||
|
||||
guardrail-integrity:
|
||||
runs-on: docker
|
||||
container: alpine:3.20
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git bash
|
||||
git clone --depth 20 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
git fetch origin ${GITHUB_BASE_REF}:base
|
||||
- name: Require [guardrail-change] label in PR commits touching guardrails
|
||||
run: |
|
||||
changed=$(git diff --name-only base...HEAD)
|
||||
echo "$changed" | grep -E '^(\.claude/settings\.json|\.claude/rules/loc-exceptions\.txt|scripts/check-loc\.sh|scripts/githooks/pre-commit|AGENTS\.(python|go|typescript)\.md)$' || exit 0
|
||||
if ! git log base..HEAD --format=%B | grep -q '\[guardrail-change\]'; then
|
||||
echo "::error:: Guardrail files were modified but no commit in this PR carries [guardrail-change]."
|
||||
echo "If intentional, amend one commit message with [guardrail-change] and explain why in the body."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ========================================
|
||||
# Lint (nur bei PRs)
|
||||
# ========================================
|
||||
@@ -47,15 +96,28 @@ jobs:
|
||||
run: |
|
||||
apt-get update -qq && apt-get install -y -qq git > /dev/null 2>&1
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Lint Python services
|
||||
- name: Lint Python services (ruff)
|
||||
run: |
|
||||
pip install --quiet ruff
|
||||
for svc in backend-compliance document-crawler dsms-gateway; do
|
||||
fail=0
|
||||
for svc in backend-compliance document-crawler dsms-gateway compliance-tts-service; do
|
||||
if [ -d "$svc" ]; then
|
||||
echo "=== Linting $svc ==="
|
||||
ruff check "$svc/" --output-format=github || true
|
||||
echo "=== ruff: $svc ==="
|
||||
ruff check "$svc/" --output-format=github || fail=1
|
||||
fi
|
||||
done
|
||||
exit $fail
|
||||
- name: Type-check (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
|
||||
@@ -66,17 +128,20 @@ jobs:
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Lint Node.js services
|
||||
- name: Lint + type-check Node.js services
|
||||
run: |
|
||||
fail=0
|
||||
for svc in admin-compliance developer-portal; do
|
||||
if [ -d "$svc" ]; then
|
||||
echo "=== Linting $svc ==="
|
||||
cd "$svc"
|
||||
npm ci --silent 2>/dev/null || npm install --silent
|
||||
npx next lint || true
|
||||
cd ..
|
||||
echo "=== $svc: install ==="
|
||||
(cd "$svc" && (npm ci --silent 2>/dev/null || npm install --silent))
|
||||
echo "=== $svc: next lint ==="
|
||||
(cd "$svc" && npx next lint) || fail=1
|
||||
echo "=== $svc: tsc --noEmit ==="
|
||||
(cd "$svc" && npx tsc --noEmit) || fail=1
|
||||
fi
|
||||
done
|
||||
exit $fail
|
||||
|
||||
# ========================================
|
||||
# Unit Tests
|
||||
@@ -169,6 +234,32 @@ jobs:
|
||||
pip install --quiet --no-cache-dir pytest pytest-asyncio
|
||||
python -m pytest test_main.py -v --tb=short
|
||||
|
||||
# ========================================
|
||||
# SBOM + license scan (compliance product → we eat our own dog food)
|
||||
# ========================================
|
||||
|
||||
sbom-scan:
|
||||
runs-on: docker
|
||||
if: github.event_name == 'pull_request'
|
||||
container: alpine:3.20
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git curl bash
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Install syft + grype
|
||||
run: |
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
|
||||
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
|
||||
- name: Generate SBOM
|
||||
run: |
|
||||
mkdir -p sbom-out
|
||||
syft dir:. -o cyclonedx-json=sbom-out/sbom.cdx.json -q
|
||||
- name: Vulnerability scan (fail on high+)
|
||||
run: |
|
||||
grype sbom:sbom-out/sbom.cdx.json --fail-on high -q || true
|
||||
# Initially non-blocking ('|| true'). Flip to blocking after baseline is clean.
|
||||
|
||||
# ========================================
|
||||
# Validate Canonical Controls
|
||||
# ========================================
|
||||
@@ -186,104 +277,25 @@ jobs:
|
||||
python scripts/validate-controls.py
|
||||
|
||||
# ========================================
|
||||
# Build & Deploy auf Hetzner (nur main, kein PR)
|
||||
# Deploy via Coolify (nur main, kein PR)
|
||||
# ========================================
|
||||
|
||||
deploy-hetzner:
|
||||
deploy-coolify:
|
||||
name: Deploy
|
||||
runs-on: docker
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
needs:
|
||||
- loc-budget
|
||||
- test-go-ai-compliance
|
||||
- test-python-backend-compliance
|
||||
- test-python-document-crawler
|
||||
- test-python-dsms-gateway
|
||||
- validate-canonical-controls
|
||||
container: docker:27-cli
|
||||
container:
|
||||
image: alpine:latest
|
||||
steps:
|
||||
- name: Deploy
|
||||
- name: Trigger Coolify deploy
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DEPLOY_DIR="/opt/breakpilot-compliance"
|
||||
COMPOSE_FILES="-f docker-compose.yml -f docker-compose.hetzner.yml"
|
||||
COMMIT_SHA="${GITHUB_SHA:-unknown}"
|
||||
SHORT_SHA="${COMMIT_SHA:0:8}"
|
||||
REPO_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
|
||||
echo "=== BreakPilot Compliance Deploy ==="
|
||||
echo "Commit: ${SHORT_SHA}"
|
||||
echo "Deploy Dir: ${DEPLOY_DIR}"
|
||||
echo ""
|
||||
|
||||
# Der Runner laeuft in einem Container mit Docker-Socket-Zugriff,
|
||||
# hat aber KEINEN direkten Zugriff auf das Host-Dateisystem.
|
||||
# Loesung: Alpine-Helper-Container mit Host-Bind-Mount fuer Git-Ops.
|
||||
|
||||
# 1. Repo auf dem Host erstellen/aktualisieren via Helper-Container
|
||||
echo "=== Updating code on host ==="
|
||||
docker run --rm \
|
||||
-v "${DEPLOY_DIR}:${DEPLOY_DIR}" \
|
||||
--entrypoint sh \
|
||||
alpine/git:latest \
|
||||
-c "
|
||||
if [ ! -d '${DEPLOY_DIR}/.git' ]; then
|
||||
echo 'Erstmaliges Klonen nach ${DEPLOY_DIR}...'
|
||||
git clone '${REPO_URL}' '${DEPLOY_DIR}'
|
||||
else
|
||||
cd '${DEPLOY_DIR}'
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
fi
|
||||
"
|
||||
echo "Code aktualisiert auf ${SHORT_SHA}"
|
||||
|
||||
# 2. .env sicherstellen (muss einmalig manuell angelegt werden)
|
||||
docker run --rm -v "${DEPLOY_DIR}:${DEPLOY_DIR}" alpine \
|
||||
sh -c "
|
||||
if [ ! -f '${DEPLOY_DIR}/.env' ]; then
|
||||
echo 'WARNUNG: ${DEPLOY_DIR}/.env fehlt!'
|
||||
echo 'Bitte einmalig auf dem Host anlegen.'
|
||||
echo 'Deploy wird fortgesetzt (Services starten ggf. mit Defaults).'
|
||||
else
|
||||
echo '.env vorhanden'
|
||||
fi
|
||||
"
|
||||
|
||||
# 3. Build + Deploy via Helper-Container mit Docker-Socket + Deploy-Dir
|
||||
# docker compose muss die YAML-Dateien lesen koennen, daher
|
||||
# alles in einem Container mit beiden Mounts ausfuehren.
|
||||
echo ""
|
||||
echo "=== Building + Deploying ==="
|
||||
docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v "${DEPLOY_DIR}:${DEPLOY_DIR}" \
|
||||
-w "${DEPLOY_DIR}" \
|
||||
docker:27-cli \
|
||||
sh -c "
|
||||
COMPOSE_FILES='-f docker-compose.yml -f docker-compose.hetzner.yml'
|
||||
|
||||
echo '=== Building Docker Images ==='
|
||||
docker compose \${COMPOSE_FILES} build --parallel \
|
||||
admin-compliance \
|
||||
backend-compliance \
|
||||
ai-compliance-sdk \
|
||||
developer-portal
|
||||
|
||||
echo ''
|
||||
echo '=== Starting containers ==='
|
||||
docker compose \${COMPOSE_FILES} up -d --remove-orphans \
|
||||
admin-compliance \
|
||||
backend-compliance \
|
||||
ai-compliance-sdk \
|
||||
developer-portal
|
||||
|
||||
echo ''
|
||||
echo '=== Health Checks ==='
|
||||
sleep 10
|
||||
for svc in bp-compliance-admin bp-compliance-backend bp-compliance-ai-sdk bp-compliance-developer-portal; do
|
||||
STATUS=\$(docker inspect --format='{{.State.Status}}' \"\${svc}\" 2>/dev/null || echo 'not found')
|
||||
echo \"\${svc}: \${STATUS}\"
|
||||
done
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "=== Deploy abgeschlossen: ${SHORT_SHA} ==="
|
||||
apk add --no-cache curl
|
||||
curl -sf "${{ secrets.COOLIFY_WEBHOOK }}" \
|
||||
-H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}"
|
||||
|
||||
126
AGENTS.go.md
Normal file
126
AGENTS.go.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# AGENTS.go.md — Go Service Conventions
|
||||
|
||||
Applies to: `ai-compliance-sdk/`.
|
||||
|
||||
## Layered architecture (Gin)
|
||||
|
||||
Follows [Standard Go Project Layout](https://github.com/golang-standards/project-layout) + hexagonal/clean-arch.
|
||||
|
||||
```
|
||||
ai-compliance-sdk/
|
||||
├── cmd/server/main.go # Thin: parse flags → app.New → app.Run. <50 LOC.
|
||||
├── internal/
|
||||
│ ├── app/ # Wiring: config + DI graph + lifecycle.
|
||||
│ ├── domain/ # Pure types, interfaces, errors. No I/O imports.
|
||||
│ │ └── <aggregate>/
|
||||
│ ├── service/ # Business logic. Depends on domain interfaces only.
|
||||
│ │ └── <aggregate>/
|
||||
│ ├── repository/postgres/ # Concrete repo implementations.
|
||||
│ │ └── <aggregate>/
|
||||
│ ├── transport/http/ # Gin handlers. Thin. One handler per file group.
|
||||
│ │ ├── handler/<aggregate>/
|
||||
│ │ ├── middleware/
|
||||
│ │ └── router.go
|
||||
│ └── platform/ # DB pool, logger, config, tracing.
|
||||
└── pkg/ # Importable by other repos. Empty unless needed.
|
||||
```
|
||||
|
||||
**Dependency direction:** `transport → service → domain ← repository`. `domain` imports nothing from siblings.
|
||||
|
||||
## Handlers
|
||||
|
||||
- One handler = one Gin function. ≤40 LOC.
|
||||
- Bind → call service → map domain error to HTTP via `httperr.Write(c, err)` → respond.
|
||||
- Return early on errors. No business logic, no SQL.
|
||||
|
||||
```go
|
||||
func (h *IACEHandler) Create(c *gin.Context) {
|
||||
var req CreateIACERequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
httperr.Write(c, httperr.BadRequest(err))
|
||||
return
|
||||
}
|
||||
out, err := h.svc.Create(c.Request.Context(), req.ToInput())
|
||||
if err != nil {
|
||||
httperr.Write(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, out)
|
||||
}
|
||||
```
|
||||
|
||||
## Services
|
||||
|
||||
- Struct + constructor + interface methods. No package-level state.
|
||||
- Take `context.Context` as first arg always. Propagate to repos.
|
||||
- Return `(value, error)`. Wrap with `fmt.Errorf("create iace: %w", err)`.
|
||||
- Domain errors implemented as sentinel vars or typed errors; matched with `errors.Is` / `errors.As`.
|
||||
|
||||
## Repositories
|
||||
|
||||
- Interface lives in `domain/<aggregate>/repository.go`. Implementation in `repository/postgres/<aggregate>/`.
|
||||
- One file per query group; no file >500 LOC.
|
||||
- Use `pgx`/`sqlc` over hand-rolled string SQL when feasible. No ORM globals.
|
||||
- All queries take `ctx`. No background goroutines without explicit lifecycle.
|
||||
|
||||
## Errors
|
||||
|
||||
Single `internal/platform/httperr` package maps `error` → HTTP status:
|
||||
|
||||
```go
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrNotFound): return 404
|
||||
case errors.Is(err, domain.ErrConflict): return 409
|
||||
case errors.As(err, &validationErr): return 422
|
||||
default: return 500
|
||||
}
|
||||
```
|
||||
|
||||
Never `panic` in request handling. `recover` middleware logs and returns 500.
|
||||
|
||||
## Tests
|
||||
|
||||
- Co-located `*_test.go`.
|
||||
- **Table-driven** tests for service logic; use `t.Run(tt.name, ...)`.
|
||||
- Handlers tested with `httptest.NewRecorder`.
|
||||
- Repos tested with `testcontainers-go` (or the existing compose Postgres) — never mocks at the SQL boundary.
|
||||
- Coverage target: 80% on `service/`. CI fails on regression.
|
||||
|
||||
```go
|
||||
func TestIACEService_Create(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input service.CreateInput
|
||||
setup func(*mockRepo)
|
||||
wantErr error
|
||||
}{
|
||||
{"happy path", validInput(), func(r *mockRepo) { r.createReturns(nil) }, nil},
|
||||
{"conflict", validInput(), func(r *mockRepo) { r.createReturns(domain.ErrConflict) }, domain.ErrConflict},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) { /* ... */ })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tooling
|
||||
|
||||
- `golangci-lint` with: `errcheck, govet, staticcheck, revive, gosec, gocyclo (max 15), gocognit (max 20), unused, ineffassign, errorlint, nilerr, nolintlint, contextcheck`.
|
||||
- `gofumpt` formatting.
|
||||
- `go vet ./...` clean.
|
||||
- `go mod tidy` clean — no unused deps.
|
||||
|
||||
## Concurrency
|
||||
|
||||
- Goroutines must have a clear lifecycle owner (struct method that started them must stop them).
|
||||
- Pass `ctx` everywhere. Cancellation respected.
|
||||
- No global mutexes for request data. Use per-request context.
|
||||
|
||||
## What you may NOT do
|
||||
|
||||
- Touch DB schema/migrations.
|
||||
- Add a new top-level package directly under `internal/` without architectural review.
|
||||
- `import "C"`, unsafe, reflection-heavy code.
|
||||
- Use `init()` for non-trivial setup. Wire it in `internal/app`.
|
||||
- Create a file >500 lines.
|
||||
- Change a public route's contract without updating consumers.
|
||||
94
AGENTS.python.md
Normal file
94
AGENTS.python.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# AGENTS.python.md — Python Service Conventions
|
||||
|
||||
Applies to: `backend-compliance/`, `document-crawler/`, `dsms-gateway/`, `compliance-tts-service/`.
|
||||
|
||||
## Layered architecture (FastAPI)
|
||||
|
||||
```
|
||||
compliance/
|
||||
├── api/ # HTTP layer — routers only. Thin (≤30 LOC per handler).
|
||||
│ └── <domain>_routes.py
|
||||
├── services/ # Business logic. Pure-ish; no FastAPI imports.
|
||||
│ └── <domain>_service.py
|
||||
├── repositories/ # DB access. Owns SQLAlchemy session usage.
|
||||
│ └── <domain>_repository.py
|
||||
├── domain/ # Value objects, enums, domain exceptions.
|
||||
├── schemas/ # Pydantic models, split per domain. NEVER one giant schemas.py.
|
||||
│ └── <domain>.py
|
||||
└── db/
|
||||
└── models/ # SQLAlchemy ORM, one module per aggregate. __tablename__ frozen.
|
||||
```
|
||||
|
||||
**Dependency direction:** `api → services → repositories → db.models`. Lower layers must not import upper layers.
|
||||
|
||||
## Routers
|
||||
|
||||
- One `APIRouter` per domain file.
|
||||
- Handlers do exactly: parse request → call service → map domain errors to HTTPException → return response model.
|
||||
- Inject services via `Depends`. No globals.
|
||||
- Tag routes; document with summary + response_model.
|
||||
|
||||
```python
|
||||
@router.post("/dsr/requests", response_model=DSRRequestRead, status_code=201)
|
||||
async def create_dsr_request(
|
||||
payload: DSRRequestCreate,
|
||||
service: DSRService = Depends(get_dsr_service),
|
||||
tenant_id: UUID = Depends(get_tenant_id),
|
||||
) -> DSRRequestRead:
|
||||
try:
|
||||
return await service.create(tenant_id, payload)
|
||||
except DSRConflict as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
```
|
||||
|
||||
## Services
|
||||
|
||||
- Constructor takes the repository (interface, not concrete).
|
||||
- No `Request`, `Response`, or HTTP knowledge.
|
||||
- Raise domain exceptions (e.g. `DSRConflict`, `DSRNotFound`), never `HTTPException`.
|
||||
- Return domain objects or Pydantic schemas — pick one and stay consistent inside a service.
|
||||
|
||||
## Repositories
|
||||
|
||||
- Methods are intent-named (`get_pending_for_tenant`), not CRUD-named (`select_where`).
|
||||
- Sessions injected, not constructed inside.
|
||||
- No business logic. No cross-aggregate joins for unrelated workflows — that belongs in a service.
|
||||
- Return ORM models or domain VOs; never `Row`.
|
||||
|
||||
## Schemas (Pydantic v2)
|
||||
|
||||
- One module per domain. Module ≤300 lines.
|
||||
- Use `model_config = ConfigDict(from_attributes=True, frozen=True)` for read models.
|
||||
- Separate `*Create`, `*Update`, `*Read`. No giant union schemas.
|
||||
|
||||
## Tests (`pytest`)
|
||||
|
||||
- Layout: `tests/unit/`, `tests/integration/`, `tests/contracts/`.
|
||||
- Unit tests mock the repository. Use `pytest.fixture` + `unittest.mock.AsyncMock`.
|
||||
- Integration tests run against the real Postgres from `docker-compose.yml` via a transactional fixture (rollback after each test).
|
||||
- Contract tests diff `/openapi.json` against `tests/contracts/openapi.baseline.json`.
|
||||
- Naming: `test_<unit>_<scenario>_<expected>.py::TestClass::test_method`.
|
||||
- `pytest-asyncio` mode = `auto`. Mark slow tests with `@pytest.mark.slow`.
|
||||
- Coverage target: 80% for new code; never decrease the service baseline.
|
||||
|
||||
## Tooling
|
||||
|
||||
- `ruff check` + `ruff format` (line length 100).
|
||||
- `mypy --strict` on `services/`, `repositories/`, `domain/`. Expand outward.
|
||||
- `pip-audit` in CI.
|
||||
- Async-first: prefer `httpx.AsyncClient`, `asyncpg`/`SQLAlchemy 2.x async`.
|
||||
|
||||
## Errors & logging
|
||||
|
||||
- Domain errors inherit from a single `DomainError` base per service.
|
||||
- Log via `structlog` with bound context (`tenant_id`, `request_id`). Never log secrets, PII, or full request bodies.
|
||||
- Audit-relevant actions go through the audit logger, not the application logger.
|
||||
|
||||
## What you may NOT do
|
||||
|
||||
- Add a new Alembic migration.
|
||||
- Rename a `__tablename__`, column, or enum value.
|
||||
- Change a public route's path/method/status/schema without simultaneous dashboard fix.
|
||||
- Catch `Exception` broadly — catch the specific domain or library error.
|
||||
- Put business logic in a router or in a Pydantic validator.
|
||||
- Create a new file >500 lines. Period.
|
||||
85
AGENTS.typescript.md
Normal file
85
AGENTS.typescript.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# AGENTS.typescript.md — TypeScript / Next.js Conventions
|
||||
|
||||
Applies to: `admin-compliance/`, `developer-portal/`, `breakpilot-compliance-sdk/`, `consent-sdk/`, `dsms-node/` (where applicable).
|
||||
|
||||
## Layered architecture (Next.js 15 App Router)
|
||||
|
||||
```
|
||||
app/
|
||||
├── <route>/
|
||||
│ ├── page.tsx # Server Component by default. ≤200 LOC.
|
||||
│ ├── layout.tsx
|
||||
│ ├── _components/ # Private folder; not routable. Colocated UI.
|
||||
│ │ └── <Component>.tsx # Each file ≤300 LOC.
|
||||
│ ├── _hooks/ # Client hooks for this route.
|
||||
│ ├── _server/ # Server actions, data loaders for this route.
|
||||
│ └── loading.tsx / error.tsx
|
||||
├── api/
|
||||
│ └── <domain>/route.ts # Thin handler. Delegates to lib/server/<domain>/.
|
||||
lib/
|
||||
├── <domain>/ # Pure helpers, types, schemas (zod). Reusable.
|
||||
└── server/<domain>/ # Server-only logic; uses "server-only" import.
|
||||
components/ # Truly shared, app-wide components.
|
||||
```
|
||||
|
||||
**Server vs Client:** Default is Server Component. Add `"use client"` only when you need state, effects, or browser APIs. Push the boundary as deep as possible.
|
||||
|
||||
## API routes (route.ts)
|
||||
|
||||
- One handler per HTTP method, ≤40 LOC.
|
||||
- Validate input with `zod`. Reject invalid → 400.
|
||||
- Delegate to `lib/server/<domain>/`. No business logic in `route.ts`.
|
||||
- Always return `NextResponse.json(..., { status })`. Never throw to the framework.
|
||||
|
||||
```ts
|
||||
export async function POST(req: Request) {
|
||||
const parsed = CreateDSRSchema.safeParse(await req.json());
|
||||
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
|
||||
const result = await dsrService.create(parsed.data);
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
}
|
||||
```
|
||||
|
||||
## Page components
|
||||
|
||||
- Pages >300 lines must be split into colocated `_components/`.
|
||||
- Server Components fetch data; pass plain objects to Client Components.
|
||||
- No data fetching in `useEffect` for server-renderable data.
|
||||
- State management: prefer URL state (`searchParams`) and Server Components over global stores.
|
||||
|
||||
## Types
|
||||
|
||||
- `lib/sdk/types.ts` is being split into `lib/sdk/types/<domain>.ts`. Mirror backend domain boundaries.
|
||||
- All API DTOs are zod schemas; infer types via `z.infer`.
|
||||
- No `any`. No `as unknown as`. If you reach for it, the type is wrong.
|
||||
|
||||
## Tests
|
||||
|
||||
- Unit: **Vitest** (`*.test.ts`/`*.test.tsx`), colocated.
|
||||
- Hooks: `@testing-library/react` `renderHook`.
|
||||
- E2E: **Playwright** (`tests/e2e/`), one spec per top-level page, smoke happy path minimum.
|
||||
- Snapshot tests sparingly — only for stable output (CSV, JSON-LD).
|
||||
- Coverage target: 70% on `lib/`, smoke coverage on `app/`.
|
||||
|
||||
## Tooling
|
||||
|
||||
- `tsc --noEmit` clean (strict mode, `noUncheckedIndexedAccess: true`).
|
||||
- ESLint with `@typescript-eslint`, `eslint-config-next`, type-aware rules on.
|
||||
- `prettier`.
|
||||
- `next build` clean. No `// @ts-ignore`. `// @ts-expect-error` only with a comment explaining why.
|
||||
|
||||
## Performance
|
||||
|
||||
- Use `next/dynamic` for heavy client-only components.
|
||||
- Image: `next/image` with explicit width/height.
|
||||
- Avoid waterfalls — `Promise.all` for parallel data fetches in Server Components.
|
||||
|
||||
## What you may NOT do
|
||||
|
||||
- Put business logic in a `page.tsx` or `route.ts`.
|
||||
- Reach across module boundaries (e.g. `admin-compliance` importing from `developer-portal`).
|
||||
- Use `dangerouslySetInnerHTML` without explicit sanitization.
|
||||
- Call backend APIs directly from Client Components when a Server Component or Server Action would do.
|
||||
- Change a public API route's path/method/schema without updating SDK consumers in the same change.
|
||||
- Create a file >500 lines.
|
||||
- Disable a lint or type rule globally to silence a finding — fix the root cause.
|
||||
@@ -37,8 +37,8 @@ WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
RUN addgroup -S -g 1001 nodejs
|
||||
RUN adduser -S -u 1001 -G nodejs nextjs
|
||||
|
||||
# Copy built assets
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
51
admin-compliance/README.md
Normal file
51
admin-compliance/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# admin-compliance
|
||||
|
||||
Next.js 15 dashboard for BreakPilot Compliance — SDK module UI, company profile, DSR, DSFA, VVT, TOM, consent, AI Act, training, audit, change requests, etc. Also hosts 96+ API routes that proxy/orchestrate backend services.
|
||||
|
||||
**Port:** `3007` (container: `bp-compliance-admin`)
|
||||
**Stack:** Next.js 15 App Router, React 18, TailwindCSS, TypeScript strict.
|
||||
|
||||
## Architecture (target — Phase 3)
|
||||
|
||||
```
|
||||
app/
|
||||
├── <route>/
|
||||
│ ├── page.tsx # Server Component (≤200 LOC)
|
||||
│ ├── _components/ # Colocated UI, each ≤300 LOC
|
||||
│ ├── _hooks/ # Client hooks
|
||||
│ └── _server/ # Server actions
|
||||
├── api/<domain>/route.ts # Thin handlers → lib/server/<domain>/
|
||||
lib/
|
||||
├── <domain>/ # Pure helpers, zod schemas
|
||||
└── server/<domain>/ # "server-only" logic
|
||||
components/ # App-wide shared UI
|
||||
```
|
||||
|
||||
See `../AGENTS.typescript.md`.
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
cd admin-compliance
|
||||
npm install
|
||||
npm run dev # http://localhost:3007
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
npm test # Vitest unit + component tests
|
||||
npx playwright test # E2E
|
||||
npx tsc --noEmit # Type-check
|
||||
npx next lint
|
||||
```
|
||||
|
||||
## Known debt (Phase 3 targets)
|
||||
|
||||
- `app/sdk/company-profile/page.tsx` (3017 LOC), `tom-generator/controls/loader.ts` (2521), `lib/sdk/types.ts` (2511), `app/sdk/loeschfristen/page.tsx` (2322), `app/sdk/dsb-portal/page.tsx` (2068) — all must be split.
|
||||
- 0 test files for 182 monolithic pages. Phase 3 adds Playwright smoke + Vitest unit coverage.
|
||||
|
||||
## Don't touch
|
||||
|
||||
- Backend API paths without updating `backend-compliance/` in the same change.
|
||||
- `lib/sdk/types.ts` in large contiguous chunks — it's being domain-split.
|
||||
139
admin-compliance/app/sdk/academy/_components/CertificatesTab.tsx
Normal file
139
admin-compliance/app/sdk/academy/_components/CertificatesTab.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { Certificate } from '@/lib/sdk/academy/types'
|
||||
|
||||
// =============================================================================
|
||||
// CERTIFICATE ROW
|
||||
// =============================================================================
|
||||
|
||||
function CertificateRow({ cert }: { cert: Certificate }) {
|
||||
const now = new Date()
|
||||
const validUntil = new Date(cert.validUntil)
|
||||
const daysLeft = Math.ceil((validUntil.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
||||
const isExpired = daysLeft <= 0
|
||||
const isExpiringSoon = daysLeft > 0 && daysLeft <= 30
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium text-gray-900">{cert.userName}</td>
|
||||
<td className="px-4 py-3 text-gray-600">{cert.courseName}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{new Date(cert.issuedAt).toLocaleDateString('de-DE')}</td>
|
||||
<td className="px-4 py-3 text-gray-500">{validUntil.toLocaleDateString('de-DE')}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{isExpired ? (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">Abgelaufen</span>
|
||||
) : isExpiringSoon ? (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-orange-100 text-orange-700">Laeuft bald ab</span>
|
||||
) : (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">Gueltig</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{cert.pdfUrl ? (
|
||||
<a
|
||||
href={cert.pdfUrl}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
PDF Download
|
||||
</a>
|
||||
) : (
|
||||
<span className="px-3 py-1 text-xs bg-gray-100 text-gray-400 rounded cursor-not-allowed">
|
||||
Nicht verfuegbar
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CERTIFICATES TAB
|
||||
// =============================================================================
|
||||
|
||||
export function CertificatesTab({
|
||||
certificates,
|
||||
certSearch,
|
||||
onSearchChange
|
||||
}: {
|
||||
certificates: Certificate[]
|
||||
certSearch: string
|
||||
onSearchChange: (s: string) => void
|
||||
}) {
|
||||
const now = new Date()
|
||||
const total = certificates.length
|
||||
const valid = certificates.filter(c => new Date(c.validUntil) > now).length
|
||||
const expired = certificates.filter(c => new Date(c.validUntil) <= now).length
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">{total}</div>
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{valid}</div>
|
||||
<div className="text-sm text-gray-500">Gueltig</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-red-200 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-red-600">{expired}</div>
|
||||
<div className="text-sm text-gray-500">Abgelaufen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nach Mitarbeiter oder Kurs suchen..."
|
||||
value={certSearch}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
{certificates.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Noch keine Zertifikate ausgestellt</h3>
|
||||
<p className="mt-2 text-gray-500">Zertifikate werden automatisch nach Kursabschluss generiert.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-700">Mitarbeiter</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-700">Kurs</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-700">Ausgestellt am</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-gray-700">Gueltig bis</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-700">Status</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-gray-700">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{certificates
|
||||
.filter(c =>
|
||||
!certSearch ||
|
||||
c.userName.toLowerCase().includes(certSearch.toLowerCase()) ||
|
||||
c.courseName.toLowerCase().includes(certSearch.toLowerCase())
|
||||
)
|
||||
.map(cert => <CertificateRow key={cert.id} cert={cert} />)
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
85
admin-compliance/app/sdk/academy/_components/CourseCard.tsx
Normal file
85
admin-compliance/app/sdk/academy/_components/CourseCard.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Course, COURSE_CATEGORY_INFO } from '@/lib/sdk/academy/types'
|
||||
|
||||
export function CourseCard({ course, enrollmentCount, onEdit }: { course: Course; enrollmentCount: number; onEdit?: (course: Course) => void }) {
|
||||
const categoryInfo = COURSE_CATEGORY_INFO[course.category] || COURSE_CATEGORY_INFO['custom']
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<Link href={`/sdk/academy/${course.id}`}>
|
||||
<div className="bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer border-gray-200 hover:border-purple-300">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
|
||||
{categoryInfo.label}
|
||||
</span>
|
||||
{course.status === 'published' && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">Veroeffentlicht</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">{course.title}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1 line-clamp-2">{course.description}</p>
|
||||
<div className="mt-3 flex items-center gap-4 text-sm text-gray-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
{course.lessons.length} Lektionen
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{course.durationMinutes} Min.
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
{enrollmentCount} Teilnehmer
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Bestehensgrenze: {course.passingScore}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right ml-4 text-gray-500">
|
||||
<div className="text-sm font-medium">
|
||||
{course.requiredForRoles.includes('all') ? 'Pflicht fuer alle' : `${course.requiredForRoles.length} Rollen`}
|
||||
</div>
|
||||
<div className="text-xs mt-0.5">
|
||||
{new Date(course.updatedAt).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
Erstellt: {new Date(course.createdAt).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
|
||||
Details
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); onEdit(course) }}
|
||||
className="absolute top-3 right-3 p-1.5 bg-white rounded-lg shadow border border-gray-200 text-gray-400 hover:text-purple-600 hover:border-purple-300 opacity-0 group-hover:opacity-100 transition-all z-10"
|
||||
title="Kurs bearbeiten"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
129
admin-compliance/app/sdk/academy/_components/CourseEditModal.tsx
Normal file
129
admin-compliance/app/sdk/academy/_components/CourseEditModal.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Course, CourseCategory, COURSE_CATEGORY_INFO } from '@/lib/sdk/academy/types'
|
||||
import { updateCourse } from '@/lib/sdk/academy/api'
|
||||
|
||||
export function CourseEditModal({ course, onClose, onSaved }: {
|
||||
course: Course
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [title, setTitle] = useState(course.title)
|
||||
const [description, setDescription] = useState(course.description)
|
||||
const [category, setCategory] = useState<CourseCategory>(course.category)
|
||||
const [durationMinutes, setDurationMinutes] = useState(course.durationMinutes)
|
||||
const [passingScore, setPassingScore] = useState(course.passingScore ?? 70)
|
||||
const [status, setStatus] = useState<'draft' | 'published'>(course.status ?? 'draft')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await updateCourse(course.id, { title, description, category, durationMinutes, passingScore, status })
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Kurs bearbeiten</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value as CourseCategory)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
{Object.entries(COURSE_CATEGORY_INFO).map(([key, info]) => (
|
||||
<option key={key} value={key}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select
|
||||
value={status}
|
||||
onChange={e => setStatus(e.target.value as 'draft' | 'published')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="draft">Entwurf</option>
|
||||
<option value="published">Veroeffentlicht</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Dauer (Minuten)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={durationMinutes}
|
||||
onChange={e => setDurationMinutes(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Bestehensgrenze (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={passingScore}
|
||||
onChange={e => setPassingScore(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !title}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Aenderungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
133
admin-compliance/app/sdk/academy/_components/EnrollmentCard.tsx
Normal file
133
admin-compliance/app/sdk/academy/_components/EnrollmentCard.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
Enrollment,
|
||||
ENROLLMENT_STATUS_INFO,
|
||||
isEnrollmentOverdue,
|
||||
getDaysUntilDeadline
|
||||
} from '@/lib/sdk/academy/types'
|
||||
|
||||
export function EnrollmentCard({ enrollment, courseName, onEdit, onComplete, onDelete }: {
|
||||
enrollment: Enrollment
|
||||
courseName: string
|
||||
onEdit?: (enrollment: Enrollment) => void
|
||||
onComplete?: (id: string) => void
|
||||
onDelete?: (id: string) => void
|
||||
}) {
|
||||
const statusInfo = ENROLLMENT_STATUS_INFO[enrollment.status]
|
||||
const overdue = isEnrollmentOverdue(enrollment)
|
||||
const daysUntil = getDaysUntilDeadline(enrollment.deadline)
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
bg-white rounded-xl border-2 p-6
|
||||
${overdue ? 'border-red-300' :
|
||||
enrollment.status === 'completed' ? 'border-green-200' :
|
||||
enrollment.status === 'in_progress' ? 'border-yellow-200' :
|
||||
'border-gray-200'
|
||||
}
|
||||
`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Status Badge */}
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusInfo.bgColor} ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
{overdue && (
|
||||
<span className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded-full flex items-center gap-1">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
Ueberfaellig
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{enrollment.userName}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">{enrollment.userEmail}</p>
|
||||
<p className="text-sm text-gray-600 mt-1 font-medium">{courseName}</p>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-gray-500">Fortschritt</span>
|
||||
<span className="font-medium text-gray-700">{enrollment.progress}%</span>
|
||||
</div>
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
enrollment.progress === 100 ? 'bg-green-500' :
|
||||
overdue ? 'bg-red-500' :
|
||||
'bg-purple-500'
|
||||
}`}
|
||||
style={{ width: `${enrollment.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Deadline */}
|
||||
<div className={`text-right ml-4 ${
|
||||
overdue ? 'text-red-600' :
|
||||
daysUntil <= 7 ? 'text-orange-600' :
|
||||
'text-gray-500'
|
||||
}`}>
|
||||
<div className="text-sm font-medium">
|
||||
{enrollment.status === 'completed'
|
||||
? 'Abgeschlossen'
|
||||
: overdue
|
||||
? `${Math.abs(daysUntil)} Tage ueberfaellig`
|
||||
: `${daysUntil} Tage verbleibend`
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs mt-0.5">
|
||||
Frist: {new Date(enrollment.deadline).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
Gestartet: {new Date(enrollment.startedAt).toLocaleDateString('de-DE')}
|
||||
{enrollment.completedAt && (
|
||||
<span className="ml-3 text-green-600">
|
||||
Abgeschlossen: {new Date(enrollment.completedAt).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{enrollment.status === 'in_progress' && onComplete && (
|
||||
<button
|
||||
onClick={() => onComplete(enrollment.id)}
|
||||
className="px-3 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Abschliessen
|
||||
</button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={() => onEdit(enrollment)}
|
||||
className="px-3 py-1 text-xs bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={() => onDelete(enrollment.id)}
|
||||
className="px-3 py-1 text-xs bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { Enrollment } from '@/lib/sdk/academy/types'
|
||||
import { updateEnrollment } from '@/lib/sdk/academy/api'
|
||||
|
||||
export function EnrollmentEditModal({ enrollment, onClose, onSaved }: {
|
||||
enrollment: Enrollment
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [deadline, setDeadline] = useState(enrollment.deadline ? enrollment.deadline.split('T')[0] : '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await updateEnrollment(enrollment.id, { deadline: new Date(deadline).toISOString() })
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Einschreibung bearbeiten</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Teilnehmer: <span className="font-medium text-gray-900">{enrollment.userName}</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Deadline</label>
|
||||
<input
|
||||
type="date"
|
||||
value={deadline}
|
||||
onChange={e => setDeadline(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !deadline}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
224
admin-compliance/app/sdk/academy/_components/PageSections.tsx
Normal file
224
admin-compliance/app/sdk/academy/_components/PageSections.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
// =============================================================================
|
||||
// HEADER ACTIONS
|
||||
// =============================================================================
|
||||
|
||||
export function HeaderActions({
|
||||
isGenerating,
|
||||
onGenerateAll
|
||||
}: {
|
||||
isGenerating: boolean
|
||||
onGenerateAll: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onGenerateAll}
|
||||
disabled={isGenerating}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
)}
|
||||
{isGenerating ? 'Generiere...' : 'Alle Kurse generieren'}
|
||||
</button>
|
||||
<Link
|
||||
href="/sdk/academy/new"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Kurs erstellen
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GENERATION RESULT BAR
|
||||
// =============================================================================
|
||||
|
||||
export function GenerationResultBar({
|
||||
result
|
||||
}: {
|
||||
result: { generated: number; skipped: number; errors: string[] }
|
||||
}) {
|
||||
return (
|
||||
<div className={`p-4 rounded-lg border ${result.errors.length > 0 ? 'bg-yellow-50 border-yellow-200' : 'bg-green-50 border-green-200'}`}>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-green-700 font-medium">{result.generated} Kurse generiert</span>
|
||||
<span className="text-gray-500">{result.skipped} uebersprungen</span>
|
||||
{result.errors.length > 0 && (
|
||||
<span className="text-red-600">{result.errors.length} Fehler</span>
|
||||
)}
|
||||
</div>
|
||||
{result.errors.length > 0 && (
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
{result.errors.map((err, i) => <div key={i}>{err}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LOADING SPINNER
|
||||
// =============================================================================
|
||||
|
||||
export function LoadingSpinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<svg className="animate-spin w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// OVERDUE ALERT
|
||||
// =============================================================================
|
||||
|
||||
export function OverdueAlert({ count, onShow }: { count: number; onShow: () => void }) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-red-800">
|
||||
Achtung: {count} ueberfaellige Schulung(en)
|
||||
</h4>
|
||||
<p className="text-sm text-red-600">
|
||||
Mitarbeiter haben Pflichtschulungen nicht fristgerecht abgeschlossen. Handeln Sie umgehend.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onShow}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
Anzeigen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// INFO BOX
|
||||
// =============================================================================
|
||||
|
||||
export function InfoBox() {
|
||||
return (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-800">Schulungspflicht nach Art. 39 DSGVO</h4>
|
||||
<p className="text-sm text-blue-600 mt-1">
|
||||
Gemaess Art. 39 Abs. 1 lit. b DSGVO gehoert die Sensibilisierung und Schulung
|
||||
der an den Verarbeitungsvorgaengen beteiligten Mitarbeiter zu den Aufgaben des
|
||||
Datenschutzbeauftragten. Nachweisbare Compliance-Schulungen sind Pflicht und
|
||||
sollten mindestens jaehrlich aufgefrischt werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// EMPTY STATES
|
||||
// =============================================================================
|
||||
|
||||
export function EmptyCourses({
|
||||
selectedCategory,
|
||||
onClearFilters
|
||||
}: {
|
||||
selectedCategory: string
|
||||
onClearFilters: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Kurse gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">
|
||||
{selectedCategory !== 'all'
|
||||
? 'Passen Sie die Filter an oder'
|
||||
: 'Es sind noch keine Kurse vorhanden.'
|
||||
}
|
||||
</p>
|
||||
{selectedCategory !== 'all' ? (
|
||||
<button
|
||||
onClick={onClearFilters}
|
||||
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||||
>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
href="/sdk/academy/new"
|
||||
className="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Ersten Kurs erstellen
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyEnrollments({
|
||||
selectedStatus,
|
||||
onClearFilters
|
||||
}: {
|
||||
selectedStatus: string
|
||||
onClearFilters: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Einschreibungen gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">
|
||||
{selectedStatus !== 'all'
|
||||
? 'Passen Sie die Filter an.'
|
||||
: 'Es sind noch keine Mitarbeiter in Kurse eingeschrieben.'
|
||||
}
|
||||
</p>
|
||||
{selectedStatus !== 'all' && (
|
||||
<button
|
||||
onClick={onClearFilters}
|
||||
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||||
>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
admin-compliance/app/sdk/academy/_components/SettingsTab.tsx
Normal file
110
admin-compliance/app/sdk/academy/_components/SettingsTab.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
export function SettingsTab({ onSaved, saved }: { onSaved: () => void; saved: boolean }) {
|
||||
const SETTINGS_KEY = 'bp_academy_settings'
|
||||
|
||||
const loadSettings = () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(SETTINGS_KEY)
|
||||
if (raw) return JSON.parse(raw)
|
||||
} catch { /* ignore */ }
|
||||
return {}
|
||||
}
|
||||
|
||||
const defaults = { emailReminders: true, reminderDays: 7, defaultPassingScore: 70, defaultValidityDays: 365 }
|
||||
const saved_settings = loadSettings()
|
||||
const [emailReminders, setEmailReminders] = useState<boolean>(saved_settings.emailReminders ?? defaults.emailReminders)
|
||||
const [reminderDays, setReminderDays] = useState<number>(saved_settings.reminderDays ?? defaults.reminderDays)
|
||||
const [defaultPassingScore, setDefaultPassingScore] = useState<number>(saved_settings.defaultPassingScore ?? defaults.defaultPassingScore)
|
||||
const [defaultValidityDays, setDefaultValidityDays] = useState<number>(saved_settings.defaultValidityDays ?? defaults.defaultValidityDays)
|
||||
|
||||
const handleSave = () => {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ emailReminders, reminderDays, defaultPassingScore, defaultValidityDays }))
|
||||
onSaved()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
{/* Notifications */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-4">Benachrichtigungen</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700">E-Mail-Erinnerung bei ueberfaelligen Kursen</div>
|
||||
<div className="text-xs text-gray-500">Mitarbeiter per E-Mail an ausstehende Schulungen erinnern</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEmailReminders(!emailReminders)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${emailReminders ? 'bg-purple-600' : 'bg-gray-200'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${emailReminders ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Tage vor Ablauf erinnern</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={90}
|
||||
value={reminderDays}
|
||||
onChange={e => setReminderDays(Number(e.target.value))}
|
||||
className="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Course Defaults */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-4">Standard-Einstellungen fuer neue Kurse</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Standard-Bestehensgrenze (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={defaultPassingScore}
|
||||
onChange={e => setDefaultPassingScore(Number(e.target.value))}
|
||||
className="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Gueltigkeitsdauer (Tage)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={defaultValidityDays}
|
||||
onChange={e => setDefaultValidityDays(Number(e.target.value))}
|
||||
className="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-sm text-blue-700">
|
||||
Zertifikate werden automatisch nach erfolgreichem Kursabschluss generiert. Die Gueltigkeitsdauer gilt ab dem Ausstellungsdatum.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className={`px-6 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
saved ? 'bg-green-600 text-white' : 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
{saved ? 'Gespeichert ✓' : 'Einstellungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
168
admin-compliance/app/sdk/academy/_components/shared.tsx
Normal file
168
admin-compliance/app/sdk/academy/_components/shared.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
CourseCategory,
|
||||
EnrollmentStatus,
|
||||
COURSE_CATEGORY_INFO,
|
||||
ENROLLMENT_STATUS_INFO
|
||||
} from '@/lib/sdk/academy/types'
|
||||
import { Tab, TabId } from '../_types'
|
||||
|
||||
// =============================================================================
|
||||
// TAB NAVIGATION
|
||||
// =============================================================================
|
||||
|
||||
export function TabNavigation({
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange
|
||||
}: {
|
||||
tabs: Tab[]
|
||||
activeTab: TabId
|
||||
onTabChange: (tab: TabId) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-1 -mb-px" aria-label="Tabs">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`
|
||||
px-4 py-3 text-sm font-medium border-b-2 transition-colors
|
||||
${activeTab === tab.id
|
||||
? 'border-purple-600 text-purple-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{tab.label}
|
||||
{tab.count !== undefined && tab.count > 0 && (
|
||||
<span className={`
|
||||
px-2 py-0.5 text-xs rounded-full
|
||||
${tab.countColor || 'bg-gray-100 text-gray-600'}
|
||||
`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STAT CARD
|
||||
// =============================================================================
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
color = 'gray',
|
||||
icon,
|
||||
trend
|
||||
}: {
|
||||
label: string
|
||||
value: number | string
|
||||
color?: 'gray' | 'blue' | 'yellow' | 'red' | 'green' | 'purple'
|
||||
icon?: React.ReactNode
|
||||
trend?: { value: number; label: string }
|
||||
}) {
|
||||
const colorClasses = {
|
||||
gray: 'border-gray-200 text-gray-900',
|
||||
blue: 'border-blue-200 text-blue-600',
|
||||
yellow: 'border-yellow-200 text-yellow-600',
|
||||
red: 'border-red-200 text-red-600',
|
||||
green: 'border-green-200 text-green-600',
|
||||
purple: 'border-purple-200 text-purple-600'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border ${colorClasses[color]} p-6`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className={`text-sm ${color === 'gray' ? 'text-gray-500' : `text-${color}-600`}`}>
|
||||
{label}
|
||||
</div>
|
||||
<div className={`text-3xl font-bold mt-1 ${colorClasses[color].split(' ')[1]}`}>
|
||||
{value}
|
||||
</div>
|
||||
{trend && (
|
||||
<div className={`text-xs mt-1 ${trend.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{trend.value >= 0 ? '+' : ''}{trend.value} {trend.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{icon && (
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center bg-${color}-50`}>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FILTER BAR
|
||||
// =============================================================================
|
||||
|
||||
export function FilterBar({
|
||||
selectedCategory,
|
||||
selectedStatus,
|
||||
onCategoryChange,
|
||||
onStatusChange,
|
||||
onClear
|
||||
}: {
|
||||
selectedCategory: CourseCategory | 'all'
|
||||
selectedStatus: EnrollmentStatus | 'all'
|
||||
onCategoryChange: (category: CourseCategory | 'all') => void
|
||||
onStatusChange: (status: EnrollmentStatus | 'all') => void
|
||||
onClear: () => void
|
||||
}) {
|
||||
const hasFilters = selectedCategory !== 'all' || selectedStatus !== 'all'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
|
||||
{/* Category Filter */}
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => onCategoryChange(e.target.value as CourseCategory | 'all')}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="all">Alle Kategorien</option>
|
||||
{Object.entries(COURSE_CATEGORY_INFO).map(([cat, info]) => (
|
||||
<option key={cat} value={cat}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Enrollment Status Filter */}
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => onStatusChange(e.target.value as EnrollmentStatus | 'all')}
|
||||
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="all">Alle Status</option>
|
||||
{Object.entries(ENROLLMENT_STATUS_INFO).map(([status, info]) => (
|
||||
<option key={status} value={status}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
admin-compliance/app/sdk/academy/_types.ts
Normal file
12
admin-compliance/app/sdk/academy/_types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export type TabId = 'overview' | 'courses' | 'enrollments' | 'certificates' | 'settings'
|
||||
|
||||
export interface Tab {
|
||||
id: TabId
|
||||
label: string
|
||||
count?: number
|
||||
countColor?: string
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface Props {
|
||||
currentStep: number
|
||||
isSubmitting: boolean
|
||||
isEditMode: boolean
|
||||
titleEmpty: boolean
|
||||
onBack: () => void
|
||||
onNext: () => void
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
export function NavigationButtons({ currentStep, isSubmitting, isEditMode, titleEmpty, onBack, onNext, onSubmit }: Props) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{currentStep === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||
</button>
|
||||
|
||||
{currentStep < 8 ? (
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={currentStep === 1 && titleEmpty}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting || titleEmpty}
|
||||
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Bewerte...
|
||||
</>
|
||||
) : (
|
||||
isEditMode ? 'Speichern & neu bewerten' : 'Assessment starten'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { AssessmentResultCard } from '@/components/sdk/use-case-assessment/AssessmentResultCard'
|
||||
|
||||
interface Props {
|
||||
result: unknown
|
||||
onGoToAssessment: (id: string) => void
|
||||
onGoToOverview: () => void
|
||||
}
|
||||
|
||||
export function ResultView({ result, onGoToAssessment, onGoToOverview }: Props) {
|
||||
const r = result as { assessment?: { id: string }; result?: Record<string, unknown> }
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Assessment Ergebnis</h1>
|
||||
<div className="flex gap-2">
|
||||
{r.assessment?.id && (
|
||||
<button
|
||||
onClick={() => onGoToAssessment(r.assessment!.id)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
Zum Assessment
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onGoToOverview}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
Zur Uebersicht
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{r.result && (
|
||||
<AssessmentResultCard result={r.result as unknown as Parameters<typeof AssessmentResultCard>[0]['result']} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { StepProps } from '../_types'
|
||||
import { AI_USE_CATEGORIES } from '../_data'
|
||||
|
||||
interface Props extends StepProps {
|
||||
profileIndustry: string | string[] | undefined
|
||||
}
|
||||
|
||||
export function Step1Basics({ form, updateForm, profileIndustry }: Props) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Grundlegende Informationen</h2>
|
||||
|
||||
{/* Branche aus Profil (nur Anzeige) */}
|
||||
{profileIndustry && (Array.isArray(profileIndustry) ? profileIndustry.length > 0 : true) && (
|
||||
<div className="bg-gray-50 rounded-lg border border-gray-200 px-4 py-3">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">Branche (aus Unternehmensprofil)</span>
|
||||
<p className="text-sm text-gray-900 mt-0.5">
|
||||
{Array.isArray(profileIndustry) ? profileIndustry.join(', ') : profileIndustry}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel des Anwendungsfalls</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={e => updateForm({ title: e.target.value })}
|
||||
placeholder="z.B. Chatbot fuer Kundenservice"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
value={form.use_case_text}
|
||||
onChange={e => updateForm({ use_case_text: e.target.value })}
|
||||
rows={4}
|
||||
placeholder="Beschreiben Sie den Anwendungsfall..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* KI-Anwendungskategorie als Kacheln */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
In welchem Bereich kommt KI zum Einsatz?
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 mb-3">Waehlen Sie die passende Kategorie fuer Ihren Anwendungsfall.</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{AI_USE_CATEGORIES.map(cat => (
|
||||
<button
|
||||
key={cat.value}
|
||||
type="button"
|
||||
onClick={() => updateForm({ category: cat.value })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
form.category === cat.value
|
||||
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{cat.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{cat.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-tight">{cat.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { StepProps } from '../_types'
|
||||
import { DATA_CATEGORY_GROUPS } from '../_data-categories'
|
||||
import { toggleInArray } from '../_data'
|
||||
|
||||
export function Step2DataCategories({ form, updateForm }: StepProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Welche Daten werden verarbeitet?</h2>
|
||||
<p className="text-sm text-gray-500">Waehlen Sie alle Datenkategorien, die in diesem Use Case verarbeitet werden.</p>
|
||||
|
||||
{DATA_CATEGORY_GROUPS.map(group => (
|
||||
<div key={group.group}>
|
||||
<h3 className={`text-sm font-semibold mb-2 ${group.art9 ? 'text-orange-700' : 'text-gray-700'}`}>
|
||||
{group.art9 && '⚠️ '}{group.group}
|
||||
</h3>
|
||||
{group.art9 && (
|
||||
<p className="text-xs text-orange-600 mb-2">Besonders schutzwuerdig — erhoehte Anforderungen an Rechtsgrundlage und TOM</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 mb-4">
|
||||
{group.items.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => updateForm({ data_categories: toggleInArray(form.data_categories, item.value) })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
form.data_categories.includes(item.value)
|
||||
? group.art9
|
||||
? 'border-orange-500 bg-orange-50 ring-1 ring-orange-300'
|
||||
: 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Sonstige Datentypen */}
|
||||
<div className="border border-gray-200 rounded-lg p-4 space-y-3">
|
||||
<div className="font-medium text-gray-900">Sonstige Datentypen</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Falls Ihre Datenkategorie oben nicht aufgefuehrt ist, koennen Sie sie hier ergaenzen.
|
||||
</p>
|
||||
{form.custom_data_types.map((dt, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={dt}
|
||||
onChange={e => {
|
||||
const updated = [...form.custom_data_types]
|
||||
updated[idx] = e.target.value
|
||||
updateForm({ custom_data_types: updated })
|
||||
}}
|
||||
placeholder="Datentyp eingeben..."
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateForm({ custom_data_types: form.custom_data_types.filter((_, i) => i !== idx) })}
|
||||
className="p-2 text-red-500 hover:bg-red-50 rounded-lg"
|
||||
title="Entfernen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => updateForm({ custom_data_types: [...form.custom_data_types, ''] })}
|
||||
className="flex items-center gap-1 text-sm text-purple-600 hover:text-purple-700 font-medium"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
|
||||
Weiteren Datentyp hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{form.data_categories.length > 0 && (
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg px-4 py-3 text-sm text-purple-800">
|
||||
<span className="font-medium">{form.data_categories.length}</span> Datenkategorie{form.data_categories.length !== 1 ? 'n' : ''} ausgewaehlt
|
||||
{form.data_categories.some(c => DATA_CATEGORY_GROUPS.find(g => g.art9)?.items.some(i => i.value === c)) && (
|
||||
<span className="ml-2 text-orange-700 font-medium">— inkl. besonderer Kategorien (Art. 9)</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { StepProps } from '../_types'
|
||||
import { PURPOSE_TILES } from '../_tiles'
|
||||
import { toggleInArray } from '../_data'
|
||||
|
||||
export function Step3Purposes({ form, updateForm }: StepProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Zweck der Verarbeitung</h2>
|
||||
<p className="text-sm text-gray-500">Waehlen Sie alle zutreffenden Verarbeitungszwecke. Die passende Rechtsgrundlage wird vom SDK automatisch ermittelt.</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{PURPOSE_TILES.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => updateForm({ purposes: toggleInArray(form.purposes, item.value) })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
form.purposes.includes(item.value)
|
||||
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{form.purposes.includes('profiling') && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
|
||||
<div className="font-medium mb-1">Hinweis: Profiling</div>
|
||||
<p>Profiling unterliegt besonderen Anforderungen nach Art. 22 DSGVO. Betroffene haben das Recht auf Information und Widerspruch.</p>
|
||||
</div>
|
||||
)}
|
||||
{form.purposes.includes('automated_decision') && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-800">
|
||||
<div className="font-medium mb-1">Achtung: Automatisierte Entscheidung</div>
|
||||
<p>Art. 22 DSGVO: Vollautomatisierte Entscheidungen mit rechtlicher Wirkung erfordern besondere Schutzmassnahmen, Informationspflichten und das Recht auf menschliche Ueberpruefung.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { StepProps } from '../_types'
|
||||
import { AUTOMATION_TILES } from '../_tiles'
|
||||
|
||||
export function Step4Automation({ form, updateForm }: StepProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Grad der Automatisierung</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Wie stark greift die KI in Entscheidungen ein? Je hoeher der Automatisierungsgrad, desto strenger die regulatorischen Anforderungen.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{AUTOMATION_TILES.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => updateForm({ automation: item.value })}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
form.automation === item.value
|
||||
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="text-2xl">{item.icon}</span>
|
||||
<span className="text-sm font-semibold text-gray-900">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 ml-11">{item.desc}</p>
|
||||
<p className="text-xs text-gray-400 ml-11 mt-1">Beispiele: {item.examples}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
|
||||
<div className="font-medium mb-1">Warum ist das wichtig?</div>
|
||||
<p>
|
||||
Art. 22 DSGVO regelt automatisierte Einzelentscheidungen. Vollautomatisierte Systeme, die Personen
|
||||
erheblich beeinflussen (z.B. Kreditvergabe, Bewerbungsauswahl), unterliegen strengen Auflagen:
|
||||
Informationspflicht, Recht auf menschliche Ueberpruefung und Anfechtungsmoeglichkeit.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { StepProps } from '../_types'
|
||||
import { HOSTING_PROVIDER_TILES, HOSTING_REGION_TILES, MODEL_USAGE_TILES } from '../_tiles'
|
||||
import { toggleInArray } from '../_data'
|
||||
|
||||
export function Step5Hosting({ form, updateForm }: StepProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Technische Details</h2>
|
||||
|
||||
{/* Hosting Provider */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">Hosting-Anbieter</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{HOSTING_PROVIDER_TILES.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => updateForm({ hosting_provider: item.value })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
form.hosting_provider === item.value
|
||||
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hosting Region */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">Hosting-Region</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{HOSTING_REGION_TILES.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => updateForm({ hosting_region: item.value })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
form.hosting_region === item.value
|
||||
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Usage */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-1">Wie wird das KI-Modell genutzt?</h3>
|
||||
<p className="text-sm text-gray-500 mb-3">Waehlen Sie alle zutreffenden Optionen.</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{MODEL_USAGE_TILES.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => updateForm({ model_usage: toggleInArray(form.model_usage, item.value) })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
form.model_usage.includes(item.value)
|
||||
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info-Box: Begriffe erklaert */}
|
||||
<details className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden">
|
||||
<summary className="px-4 py-3 text-sm font-medium text-amber-800 cursor-pointer hover:bg-amber-100">
|
||||
Begriffe erklaert: ML, DL, NLP, LLM — Was bedeutet das?
|
||||
</summary>
|
||||
<div className="px-4 pb-4 space-y-3 text-sm text-amber-900">
|
||||
<div><span className="font-semibold">ML (Machine Learning)</span> — Computer lernt Muster aus Daten. Beispiel: Spam-Filter.</div>
|
||||
<div><span className="font-semibold">DL (Deep Learning)</span> — ML mit neuronalen Netzen. Beispiel: Bilderkennung, Spracherkennung.</div>
|
||||
<div><span className="font-semibold">NLP (Natural Language Processing)</span> — KI versteht Sprache. Beispiel: ChatGPT, DeepL.</div>
|
||||
<div><span className="font-semibold">LLM (Large Language Model)</span> — Grosses Sprachmodell. Beispiel: GPT-4, Claude, Llama.</div>
|
||||
<div><span className="font-semibold">RAG</span> — LLM erhaelt Kontext aus eigener Datenbank. Vorteil: Aktuelle, firmenspezifische Antworten.</div>
|
||||
<div><span className="font-semibold">Fine-Tuning</span> — Bestehendes Modell mit eigenen Daten weitertrainieren. Achtung: Daten werden Teil des Modells.</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { StepProps } from '../_types'
|
||||
import { TRANSFER_TARGET_TILES, TRANSFER_MECHANISM_TILES } from '../_tiles'
|
||||
import { toggleInArray } from '../_data'
|
||||
|
||||
export function Step6Transfer({ form, updateForm }: StepProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Internationaler Datentransfer</h2>
|
||||
<p className="text-sm text-gray-500">Wohin werden die Daten uebermittelt? Waehlen Sie alle zutreffenden Ziellaender/-regionen.</p>
|
||||
|
||||
{/* Transfer Targets */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">Datentransfer-Ziele</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{TRANSFER_TARGET_TILES.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => updateForm({ transfer_targets: toggleInArray(form.transfer_targets, item.value) })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
form.transfer_targets.includes(item.value)
|
||||
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transfer Mechanism — only if not "no_transfer" only */}
|
||||
{form.transfer_targets.length > 0 && !form.transfer_targets.every(t => t === 'no_transfer') && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">Transfer-Mechanismus</h3>
|
||||
<p className="text-sm text-gray-500 mb-3">Welche Schutzgarantie nutzen Sie fuer den Drittlandtransfer?</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{TRANSFER_MECHANISM_TILES.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => updateForm({ transfer_mechanism: item.value })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
form.transfer_mechanism === item.value
|
||||
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Specific countries text input */}
|
||||
{form.transfer_targets.some(t => !['no_transfer'].includes(t)) && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Konkrete Ziellaender (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.transfer_countries.join(', ')}
|
||||
onChange={e => updateForm({ transfer_countries: e.target.value.split(',').map(s => s.trim()).filter(Boolean) })}
|
||||
placeholder="z.B. USA, UK, Schweiz, Japan"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Kommagetrennte Laendernamen oder -kuerzel</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { StepProps } from '../_types'
|
||||
import { RETENTION_TILES } from '../_tiles'
|
||||
|
||||
export function Step7Retention({ form, updateForm }: StepProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Datenhaltung & Aufbewahrung</h2>
|
||||
<p className="text-sm text-gray-500">Wie lange sollen die Daten gespeichert werden?</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{RETENTION_TILES.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => updateForm({ retention_period: item.value })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
form.retention_period === item.value
|
||||
? 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Zweck der Aufbewahrung (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={form.retention_purpose}
|
||||
onChange={e => updateForm({ retention_purpose: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="z.B. Vertragliche Pflichten, gesetzliche Aufbewahrungsfristen..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{form.retention_period === 'indefinite' && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
|
||||
<div className="font-medium mb-1">Hinweis: Unbefristete Speicherung</div>
|
||||
<p>Die DSGVO fordert Datenminimierung und Speicherbegrenzung (Art. 5 Abs. 1e). Unbefristete Speicherung muss besonders gut begruendet sein.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { StepProps } from '../_types'
|
||||
import { CONTRACT_TILES } from '../_tiles'
|
||||
import { toggleInArray } from '../_data'
|
||||
|
||||
export function Step8Contracts({ form, updateForm }: StepProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Vertraege & Compliance-Dokumentation</h2>
|
||||
<p className="text-sm text-gray-500">Welche Compliance-Dokumente liegen bereits vor? (Mehrfachauswahl moeglich)</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{CONTRACT_TILES.map(item => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => updateForm({ contracts: toggleInArray(form.contracts, item.value) })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
form.contracts.includes(item.value)
|
||||
? item.value === 'none'
|
||||
? 'border-amber-500 bg-amber-50 ring-1 ring-amber-300'
|
||||
: 'border-purple-500 bg-purple-50 ring-1 ring-purple-300'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{item.label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 leading-tight">{item.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Subprozessoren (optional)</label>
|
||||
<textarea
|
||||
value={form.subprocessors}
|
||||
onChange={e => updateForm({ subprocessors: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="z.B. OpenAI (USA, SCC), Hetzner Cloud (DE)..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { WIZARD_STEPS } from '../_data'
|
||||
|
||||
interface Props {
|
||||
currentStep: number
|
||||
onStepClick: (id: number) => void
|
||||
}
|
||||
|
||||
export function StepIndicator({ currentStep, onStepClick }: Props) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{WIZARD_STEPS.map((step, idx) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<button
|
||||
onClick={() => onStepClick(step.id)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
currentStep === step.id
|
||||
? 'bg-purple-600 text-white'
|
||||
: currentStep > step.id
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<span className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center text-xs font-bold">
|
||||
{currentStep > step.id ? '✓' : step.id}
|
||||
</span>
|
||||
<span className="hidden md:inline">{step.title}</span>
|
||||
</button>
|
||||
{idx < WIZARD_STEPS.length - 1 && <div className="flex-1 h-px bg-gray-200" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
112
admin-compliance/app/sdk/advisory-board/_data-categories.ts
Normal file
112
admin-compliance/app/sdk/advisory-board/_data-categories.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
// =============================================================================
|
||||
// DATA CATEGORIES (Step 2) — grouped tile selection
|
||||
// =============================================================================
|
||||
|
||||
export const DATA_CATEGORY_GROUPS = [
|
||||
{
|
||||
group: 'Stamm- & Kontaktdaten',
|
||||
items: [
|
||||
{ value: 'basic_identity', label: 'Name & Identitaet', icon: '👤', desc: 'Vor-/Nachname, Geburtsdatum, Geschlecht' },
|
||||
{ value: 'contact_data', label: 'Kontaktdaten', icon: '📧', desc: 'E-Mail, Telefon, Fax' },
|
||||
{ value: 'address_data', label: 'Adressdaten', icon: '🏠', desc: 'Wohn-/Meldeadresse, PLZ, Lieferadresse' },
|
||||
{ value: 'government_ids', label: 'Ausweisdaten', icon: '🪪', desc: 'Personalausweis-Nr., Reisepass, Fuehrerschein' },
|
||||
{ value: 'customer_ids', label: 'Kundennummern', icon: '🏷️', desc: 'Kunden-ID, Vertrags-Nr., Mitgliedsnummer' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Besondere Kategorien (Art. 9 DSGVO)',
|
||||
art9: true,
|
||||
items: [
|
||||
{ value: 'health_data', label: 'Gesundheitsdaten', icon: '🏥', desc: 'Diagnosen, Medikation, AU, Pflegegrad' },
|
||||
{ value: 'biometric_data', label: 'Biometrische Daten', icon: '🔐', desc: 'Fingerabdruck, Gesichtserkennung, Iris-Scan' },
|
||||
{ value: 'genetic_data', label: 'Genetische Daten', icon: '🧬', desc: 'DNA-Profil, Genomsequenzen, Erbkrankheitstests' },
|
||||
{ value: 'racial_ethnic', label: 'Ethnische Herkunft', icon: '🌍', desc: 'Rassische/ethnische Zugehoerigkeit' },
|
||||
{ value: 'political_opinions', label: 'Politische Meinungen', icon: '🗳️', desc: 'Politische Ueberzeugungen, Parteizugehoerigkeit' },
|
||||
{ value: 'religious_beliefs', label: 'Religion', icon: '🕊️', desc: 'Religionszugehoerigkeit, Weltanschauung' },
|
||||
{ value: 'trade_union', label: 'Gewerkschaft', icon: '🤝', desc: 'Gewerkschaftsmitgliedschaft' },
|
||||
{ value: 'sexual_orientation', label: 'Sexuelle Orientierung', icon: '🏳️🌈', desc: 'Sexualleben und Orientierung' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Finanz- & Steuerdaten',
|
||||
items: [
|
||||
{ value: 'bank_account', label: 'Bankverbindung', icon: '🏦', desc: 'IBAN, BIC, Kontonummer' },
|
||||
{ value: 'payment_card', label: 'Zahlungskarten', icon: '💳', desc: 'Kreditkarten-Nr., CVV (PCI-DSS)' },
|
||||
{ value: 'transaction_data', label: 'Transaktionsdaten', icon: '🧾', desc: 'Zahlungshistorie, Ueberweisungen, Kaufhistorie' },
|
||||
{ value: 'credit_score', label: 'Bonitaet / Schufa', icon: '📈', desc: 'Kreditwuerdigkeit, Schuldenhistorie' },
|
||||
{ value: 'income_salary', label: 'Einkommen & Gehalt', icon: '💰', desc: 'Bruttogehalt, Nettolohn, Boni' },
|
||||
{ value: 'tax_ids', label: 'Steuer-IDs', icon: '📋', desc: 'Steuer-ID, Steuernummer, USt-IdNr.' },
|
||||
{ value: 'insurance_data', label: 'Versicherungsdaten', icon: '☂️', desc: 'Versicherungsnummern, Policen, Schadenmeldungen' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Fahrzeug- & Mobilitaetsdaten',
|
||||
items: [
|
||||
{ value: 'vehicle_ids', label: 'Fahrzeug-IDs (VIN)', icon: '🚗', desc: 'Fahrgestellnummer (VIN/FIN), Fahrzeugschein' },
|
||||
{ value: 'license_plates', label: 'Kennzeichen', icon: '🔢', desc: 'Amtliches Kennzeichen, Wunschkennzeichen' },
|
||||
{ value: 'gps_tracking', label: 'GPS & Routen', icon: '📍', desc: 'Echtzeitposition, Fahrtenprotokolle' },
|
||||
{ value: 'telematics', label: 'Telematikdaten', icon: '📡', desc: 'Fahrverhalten, Geschwindigkeit, Motordiagnose' },
|
||||
{ value: 'fleet_data', label: 'Fuhrpark / Logistik', icon: '🚛', desc: 'Einsatzzeiten, Kilometerstand, Fahrerzuweisung' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Technische Identifikatoren',
|
||||
items: [
|
||||
{ value: 'ip_address', label: 'IP-Adresse', icon: '🌐', desc: 'IPv4/IPv6 (EuGH: personenbezogen)' },
|
||||
{ value: 'device_ids', label: 'Geraete-IDs', icon: '📱', desc: 'IMEI, UUID, Advertising-ID, Seriennummer' },
|
||||
{ value: 'cookies_tracking', label: 'Cookies & Tracking', icon: '🍪', desc: 'Session-/Persistent Cookies, Pixel-Tags' },
|
||||
{ value: 'browser_fingerprint', label: 'Browser-Fingerprint', icon: '🔎', desc: 'Browser-Typ, OS, Plugins, Canvas-Fingerprint' },
|
||||
{ value: 'mac_address', label: 'MAC-Adresse', icon: '📶', desc: 'Netzwerkadapter-Kennung, WLAN-Praesenz' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Verhaltens- & Nutzungsdaten',
|
||||
items: [
|
||||
{ value: 'clickstream', label: 'Klick- & Nutzungspfade', icon: '🖱️', desc: 'Klickpfade, Scrolltiefe, Verweildauer, Heatmaps' },
|
||||
{ value: 'purchase_history', label: 'Kaufverhalten', icon: '🛒', desc: 'Bestellhistorie, Warenkorb, Wunschlisten' },
|
||||
{ value: 'app_usage', label: 'App-Nutzung', icon: '📲', desc: 'Genutzte Apps, Nutzungsdauer, In-App-Aktivitaeten' },
|
||||
{ value: 'profiling_scores', label: 'Profiling / Scoring', icon: '📊', desc: 'KI-generierte Profile, Segmente, Affinitaetsscores' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Kommunikation & Medien',
|
||||
items: [
|
||||
{ value: 'email_content', label: 'E-Mail-Inhalte', icon: '✉️', desc: 'E-Mail-Texte, Anhaenge, Metadaten' },
|
||||
{ value: 'chat_messages', label: 'Chat & Messaging', icon: '💬', desc: 'Textnachrichten, Messenger, Teams, Slack' },
|
||||
{ value: 'call_recordings', label: 'Telefonaufzeichnungen', icon: '📞', desc: 'Gespraeche, Transkripte, Anrufmetadaten' },
|
||||
{ value: 'video_conference', label: 'Videokonferenzen', icon: '📹', desc: 'Meeting-Aufzeichnungen, Teilnehmerlisten' },
|
||||
{ value: 'photographs', label: 'Fotos & Bilder', icon: '📷', desc: 'Portraitfotos, Profilbilder, Produktfotos' },
|
||||
{ value: 'cctv_surveillance', label: 'Videoueberwachung', icon: '📹', desc: 'CCTV-Aufnahmen, Zutrittskontrolle' },
|
||||
{ value: 'voice_recordings', label: 'Sprachaufnahmen', icon: '🎙️', desc: 'Voicemails, Sprachmemos, Diktate' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'HR & Beschaeftigung',
|
||||
items: [
|
||||
{ value: 'employment_data', label: 'Beschaeftigungsdaten', icon: '💼', desc: 'Arbeitgeber, Berufsbezeichnung, Vertragsart' },
|
||||
{ value: 'performance_data', label: 'Leistungsbeurteilungen', icon: '🏆', desc: 'Zielerreichung, Feedback, Abmahnungen' },
|
||||
{ value: 'work_time', label: 'Arbeitszeit', icon: '⏰', desc: 'Zeiterfassung, Ueberstunden, Schichtplaene' },
|
||||
{ value: 'candidate_data', label: 'Bewerberdaten', icon: '📝', desc: 'Lebenslaeufe, Interviews, Assessment-Ergebnisse' },
|
||||
{ value: 'social_security', label: 'Sozialversicherungs-Nr.', icon: '🛡️', desc: 'RVNR (Art. 9 — kodiert Geburtsdatum/Geschlecht)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'IoT & Sensordaten',
|
||||
items: [
|
||||
{ value: 'industrial_sensor', label: 'Industriesensoren', icon: '🏭', desc: 'Maschinendaten, Fehlerprotokolle, Produktionsmesswerte' },
|
||||
{ value: 'wearable_data', label: 'Wearable-Daten', icon: '⌚', desc: 'Herzfrequenz, Schritte, Schlaf (Art. 9 — Gesundheit)' },
|
||||
{ value: 'smart_home', label: 'Smart-Home', icon: '🏡', desc: 'Heizung, Licht, Bewegungsmelder, Nutzungszeiten' },
|
||||
{ value: 'energy_data', label: 'Energieverbrauch', icon: '🔌', desc: 'Smart-Meter, Verbrauchsprofil (enthuellt Verhalten)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: 'Sonstige Kategorien',
|
||||
items: [
|
||||
{ value: 'children_data', label: 'Kinderdaten (unter 16)', icon: '👶', desc: 'Besonderer Schutz, Eltern-Einwilligung erforderlich' },
|
||||
{ value: 'criminal_data', label: 'Strafrechtliche Daten', icon: '⚖️', desc: 'Vorstrafen, Ermittlungsverfahren (Art. 10 DSGVO)' },
|
||||
{ value: 'location_data', label: 'Standortdaten', icon: '📍', desc: 'GPS, Mobilfunk, WLAN-Ortung, Bewegungsprofile' },
|
||||
{ value: 'social_media', label: 'Social-Media-Daten', icon: '📱', desc: 'Profile, Posts, Follower, Interaktionen' },
|
||||
{ value: 'auth_credentials', label: 'Login & Zugangsdaten', icon: '🔑', desc: 'Passwoerter, 2FA, Session-Tokens, Zugriffsprotokolle' },
|
||||
],
|
||||
},
|
||||
]
|
||||
62
admin-compliance/app/sdk/advisory-board/_data.ts
Normal file
62
admin-compliance/app/sdk/advisory-board/_data.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// =============================================================================
|
||||
// WIZARD STEPS CONFIG
|
||||
// =============================================================================
|
||||
|
||||
export const WIZARD_STEPS = [
|
||||
{ id: 1, title: 'Grundlegendes', description: 'Titel, Beschreibung und KI-Kategorie' },
|
||||
{ id: 2, title: 'Datenkategorien', description: 'Welche Daten werden verarbeitet?' },
|
||||
{ id: 3, title: 'Verarbeitungszweck', description: 'Zweck der Datenverarbeitung' },
|
||||
{ id: 4, title: 'Automatisierung', description: 'Grad der Automatisierung' },
|
||||
{ id: 5, title: 'Hosting & Modell', description: 'Technische Details' },
|
||||
{ id: 6, title: 'Datentransfer', description: 'Internationaler Datentransfer' },
|
||||
{ id: 7, title: 'Datenhaltung', description: 'Aufbewahrung und Speicherung' },
|
||||
{ id: 8, title: 'Vertraege', description: 'Compliance und Vereinbarungen' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// KI-Anwendungskategorien als Auswahlkacheln
|
||||
// =============================================================================
|
||||
|
||||
export const AI_USE_CATEGORIES = [
|
||||
{ value: 'content_generation', label: 'Content-Erstellung', icon: '✍️', desc: 'Texte, Berichte, E-Mails, Dokumentation automatisch erstellen' },
|
||||
{ value: 'image_generation', label: 'Bilder erstellen', icon: '🎨', desc: 'KI-generierte Bilder, Grafiken, Produktfotos' },
|
||||
{ value: 'marketing_material', label: 'Marketingmaterial', icon: '📢', desc: 'Werbetexte, Social Media Posts, Newsletter generieren' },
|
||||
{ value: 'customer_service', label: 'Kundenservice / Chatbot', icon: '💬', desc: 'Automatisierte Kundenanfragen, FAQ-Bots, Support-Tickets' },
|
||||
{ value: 'crm_analytics', label: 'CRM & Kundenanalyse', icon: '👥', desc: 'Kundensegmentierung, Churn-Vorhersage, Lead-Scoring' },
|
||||
{ value: 'hr_recruiting', label: 'Bewerberauswahl / HR', icon: '🧑💼', desc: 'CV-Screening, Matching, Mitarbeiteranalysen' },
|
||||
{ value: 'financial_analysis', label: 'Finanzdaten analysieren', icon: '📊', desc: 'Buchhaltung, Forecasting, Betrugserkennung, Risikobewertung' },
|
||||
{ value: 'predictive_maintenance', label: 'Predictive Maintenance', icon: '🔧', desc: 'Vorausschauende Wartung, Ausfallvorhersage, IoT-Sensoranalyse' },
|
||||
{ value: 'production_analytics', label: 'Produktionsdatenauswertung', icon: '🏭', desc: 'Qualitaetskontrolle, Prozessoptimierung, OEE-Analyse' },
|
||||
{ value: 'document_analysis', label: 'Dokumentenanalyse', icon: '📄', desc: 'Vertraege, Rechnungen, PDFs automatisch auswerten und klassifizieren' },
|
||||
{ value: 'code_development', label: 'Softwareentwicklung', icon: '💻', desc: 'Code-Generierung, Code-Review, Test-Erstellung, Dokumentation' },
|
||||
{ value: 'translation', label: 'Uebersetzung', icon: '🌍', desc: 'Automatische Uebersetzung von Texten, Dokumenten, Webinhalten' },
|
||||
{ value: 'search_knowledge', label: 'Wissensmanagement / Suche', icon: '🔍', desc: 'Interne Wissensdatenbank, RAG-basierte Suche, FAQ-Systeme' },
|
||||
{ value: 'data_extraction', label: 'Datenextraktion', icon: '⛏️', desc: 'OCR, Formularerkennung, strukturierte Daten aus Freitext' },
|
||||
{ value: 'risk_compliance', label: 'Risiko & Compliance', icon: '⚖️', desc: 'Compliance-Pruefung, Risikobewertung, Audit-Unterstuetzung' },
|
||||
{ value: 'supply_chain', label: 'Lieferkette & Logistik', icon: '🚛', desc: 'Bedarfsprognose, Routenoptimierung, Bestandsmanagement' },
|
||||
{ value: 'medical_health', label: 'Medizin & Gesundheit', icon: '🏥', desc: 'Diagnoseunterstuetzung, Bildanalyse, Patientendaten' },
|
||||
{ value: 'security_monitoring', label: 'Sicherheit & Monitoring', icon: '🛡️', desc: 'Anomalieerkennung, Bedrohungsanalyse, Zugriffskontrolle' },
|
||||
{ value: 'personalization', label: 'Personalisierung', icon: '🎯', desc: 'Produktempfehlungen, dynamische Preisgestaltung, A/B-Testing' },
|
||||
{ value: 'voice_speech', label: 'Sprache & Audio', icon: '🎙️', desc: 'Spracherkennung, Text-to-Speech, Meeting-Transkription' },
|
||||
{ value: 'other', label: 'Sonstiges', icon: '➕', desc: 'Anderer KI-Anwendungsfall' },
|
||||
]
|
||||
|
||||
// Map Profil-Branche to domain value for backend compatibility
|
||||
export function industryToDomain(industries: string[]): string {
|
||||
if (!industries || industries.length === 0) return 'general'
|
||||
const first = industries[0].toLowerCase()
|
||||
if (first.includes('gesundheit') || first.includes('pharma')) return 'healthcare'
|
||||
if (first.includes('finanz') || first.includes('versicherung')) return 'finance'
|
||||
if (first.includes('bildung')) return 'education'
|
||||
if (first.includes('handel') || first.includes('commerce')) return 'retail'
|
||||
if (first.includes('it') || first.includes('technologie')) return 'it_services'
|
||||
if (first.includes('beratung') || first.includes('consulting')) return 'consulting'
|
||||
if (first.includes('produktion') || first.includes('industrie') || first.includes('maschinenbau')) return 'manufacturing'
|
||||
if (first.includes('marketing') || first.includes('agentur')) return 'marketing'
|
||||
if (first.includes('recht')) return 'legal'
|
||||
return 'general'
|
||||
}
|
||||
|
||||
export function toggleInArray(arr: string[], value: string): string[] {
|
||||
return arr.includes(value) ? arr.filter(v => v !== value) : [...arr, value]
|
||||
}
|
||||
110
admin-compliance/app/sdk/advisory-board/_tiles.ts
Normal file
110
admin-compliance/app/sdk/advisory-board/_tiles.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
// =============================================================================
|
||||
// PROCESSING PURPOSES (Step 3) — tile selection
|
||||
// =============================================================================
|
||||
|
||||
export const PURPOSE_TILES = [
|
||||
{ value: 'service_delivery', label: 'Serviceerbringung', icon: '⚙️', desc: 'Kernfunktion des Produkts oder Services' },
|
||||
{ value: 'analytics', label: 'Analyse & BI', icon: '📊', desc: 'Statistische Auswertung, Business Intelligence, Reporting' },
|
||||
{ value: 'marketing', label: 'Marketing & Werbung', icon: '📢', desc: 'Werbung, Personalisierung, Targeting, Newsletter' },
|
||||
{ value: 'profiling', label: 'Profiling', icon: '🎯', desc: 'Automatisierte Analyse personenbezogener Aspekte' },
|
||||
{ value: 'automated_decision', label: 'Automatisierte Entscheidung', icon: '🤖', desc: 'Art. 22 DSGVO — Entscheidung ohne menschliches Zutun' },
|
||||
{ value: 'customer_support', label: 'Kundensupport', icon: '🎧', desc: 'Anfragenbearbeitung, Ticketsystem, Chatbot' },
|
||||
{ value: 'quality_control', label: 'Qualitaetskontrolle', icon: '✅', desc: 'Produktpruefung, Fehleranalyse, Prozessoptimierung' },
|
||||
{ value: 'hr_management', label: 'Personalverwaltung', icon: '👥', desc: 'Recruiting, Onboarding, Mitarbeiterentwicklung' },
|
||||
{ value: 'fraud_detection', label: 'Betrugserkennung', icon: '🕵️', desc: 'Anomalieerkennung, Transaktionsueberwachung' },
|
||||
{ value: 'research', label: 'Forschung & Entwicklung', icon: '🔬', desc: 'Wissenschaftliche Auswertung, Produktentwicklung' },
|
||||
{ value: 'compliance_audit', label: 'Compliance & Audit', icon: '📜', desc: 'Regulatorische Pruefung, Dokumentation, Audit-Trail' },
|
||||
{ value: 'communication', label: 'Kommunikation', icon: '💬', desc: 'Interne/externe Kommunikation, Uebersetzung' },
|
||||
{ value: 'content_creation', label: 'Content-Erstellung', icon: '✍️', desc: 'Text-, Bild-, Video-Generierung' },
|
||||
{ value: 'predictive', label: 'Vorhersage & Prognose', icon: '🔮', desc: 'Demand Forecasting, Predictive Analytics, Wartungsvorhersage' },
|
||||
{ value: 'security', label: 'IT-Sicherheit', icon: '🛡️', desc: 'Bedrohungserkennung, Zugriffskontrolle, Monitoring' },
|
||||
{ value: 'archiving', label: 'Archivierung', icon: '🗄️', desc: 'Gesetzliche Aufbewahrung, Dokumentenarchiv' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// AUTOMATION LEVELS (Step 4) — single-select tiles
|
||||
// =============================================================================
|
||||
|
||||
export const AUTOMATION_TILES = [
|
||||
{ value: 'assistive', label: 'Assistiv (Mensch entscheidet)', icon: '🧑💻', desc: 'KI liefert Vorschlaege, Mensch trifft Entscheidung', examples: 'Rechtschreibkorrektur, Suchvorschlaege, Zusammenfassungen' },
|
||||
{ value: 'semi_automated', label: 'Teilautomatisiert (Mensch prueft)', icon: '🤝', desc: 'KI erstellt Ergebnisse, Mensch prueft und bestaetigt', examples: 'E-Mail-Entwuerfe mit Freigabe, KI-Vertraege mit juristischer Pruefung' },
|
||||
{ value: 'fully_automated', label: 'Vollautomatisiert (KI entscheidet)', icon: '🤖', desc: 'KI trifft Entscheidungen eigenstaendig', examples: 'Automatische Kreditentscheidungen, autonome Chatbot-Antworten' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// HOSTING & MODEL (Step 5) — tiles
|
||||
// =============================================================================
|
||||
|
||||
export const HOSTING_PROVIDER_TILES = [
|
||||
{ value: 'self_hosted', label: 'Eigenes Hosting', icon: '🏢', desc: 'On-Premise oder eigene Server' },
|
||||
{ value: 'hetzner', label: 'Hetzner (DE)', icon: '🇩🇪', desc: 'Deutsche Cloud-Infrastruktur' },
|
||||
{ value: 'aws', label: 'AWS', icon: '☁️', desc: 'Amazon Web Services' },
|
||||
{ value: 'azure', label: 'Microsoft Azure', icon: '🔷', desc: 'Microsoft Cloud' },
|
||||
{ value: 'gcp', label: 'Google Cloud', icon: '🔵', desc: 'Google Cloud Platform' },
|
||||
{ value: 'other', label: 'Anderer Anbieter', icon: '🌐', desc: 'Sonstiger Cloud-Anbieter' },
|
||||
]
|
||||
|
||||
export const HOSTING_REGION_TILES = [
|
||||
{ value: 'de', label: 'Deutschland', icon: '🇩🇪', desc: 'Rechenzentrum in Deutschland' },
|
||||
{ value: 'eu', label: 'EU / EWR', icon: '🇪🇺', desc: 'Innerhalb der Europaeischen Union' },
|
||||
{ value: 'us', label: 'USA', icon: '🇺🇸', desc: 'Vereinigte Staaten' },
|
||||
{ value: 'other', label: 'Andere Region', icon: '🌍', desc: 'Drittland ausserhalb EU/USA' },
|
||||
]
|
||||
|
||||
export const MODEL_USAGE_TILES = [
|
||||
{ value: 'inference', label: 'Inferenz', icon: '⚡', desc: 'Fertiges Modell direkt nutzen (z.B. ChatGPT, Claude, DeepL)' },
|
||||
{ value: 'rag', label: 'RAG', icon: '📚', desc: 'Modell erhaelt Kontext aus eigenen Dokumenten' },
|
||||
{ value: 'finetune', label: 'Fine-Tuning', icon: '🎛️', desc: 'Bestehendes Modell mit eigenen Daten nachtrainieren' },
|
||||
{ value: 'training', label: 'Eigenes Modell trainieren', icon: '🧠', desc: 'Komplett eigenes KI-Modell von Grund auf' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// DATA TRANSFER (Step 6) — tiles
|
||||
// =============================================================================
|
||||
|
||||
export const TRANSFER_TARGET_TILES = [
|
||||
{ value: 'no_transfer', label: 'Kein Drittlandtransfer', icon: '🇪🇺', desc: 'Daten verbleiben in der EU/EWR' },
|
||||
{ value: 'usa', label: 'USA', icon: '🇺🇸', desc: 'Datentransfer in die USA' },
|
||||
{ value: 'uk', label: 'Grossbritannien', icon: '🇬🇧', desc: 'Datentransfer nach UK (Angemessenheitsbeschluss)' },
|
||||
{ value: 'switzerland', label: 'Schweiz', icon: '🇨🇭', desc: 'Datentransfer in die Schweiz (Angemessenheitsbeschluss)' },
|
||||
{ value: 'other_adequate', label: 'Anderes Land (Angemessenheit)', icon: '✅', desc: 'Land mit Angemessenheitsbeschluss der EU' },
|
||||
{ value: 'other_third', label: 'Sonstiges Drittland', icon: '🌍', desc: 'Land ohne Angemessenheitsbeschluss' },
|
||||
]
|
||||
|
||||
export const TRANSFER_MECHANISM_TILES = [
|
||||
{ value: 'not_needed', label: 'Nicht erforderlich', icon: '✅', desc: 'Kein Drittlandtransfer oder Angemessenheit' },
|
||||
{ value: 'scc', label: 'Standardvertragsklauseln', icon: '📝', desc: 'SCC nach Art. 46 Abs. 2c DSGVO' },
|
||||
{ value: 'bcr', label: 'Binding Corporate Rules', icon: '🏛️', desc: 'BCR nach Art. 47 DSGVO' },
|
||||
{ value: 'adequacy', label: 'Angemessenheitsbeschluss', icon: '🤝', desc: 'EU-Kommissionsbeschluss (z.B. EU-US DPF)' },
|
||||
{ value: 'derogation', label: 'Ausnahme (Art. 49)', icon: '⚠️', desc: 'Einwilligung oder zwingende Interessen' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// RETENTION (Step 7) — tiles
|
||||
// =============================================================================
|
||||
|
||||
export const RETENTION_TILES = [
|
||||
{ value: 'session', label: 'Nur waehrend Session', icon: '⏱️', desc: 'Daten werden nach Sitzungsende geloescht' },
|
||||
{ value: '30_days', label: '30 Tage', icon: '📅', desc: 'Kurzfristige Aufbewahrung' },
|
||||
{ value: '90_days', label: '90 Tage', icon: '📅', desc: 'Standardaufbewahrung' },
|
||||
{ value: '1_year', label: '1 Jahr', icon: '📆', desc: 'Jaehrliche Aufbewahrung' },
|
||||
{ value: '3_years', label: '3 Jahre', icon: '📆', desc: 'Mittelfristige Aufbewahrung' },
|
||||
{ value: '6_years', label: '6 Jahre', icon: '📆', desc: 'Handelsrechtliche Aufbewahrungsfrist' },
|
||||
{ value: '10_years', label: '10 Jahre', icon: '📆', desc: 'Steuerrechtliche Aufbewahrungsfrist' },
|
||||
{ value: 'indefinite', label: 'Unbefristet', icon: '♾️', desc: 'Keine zeitliche Begrenzung' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// CONTRACTS (Step 8) — tiles
|
||||
// =============================================================================
|
||||
|
||||
export const CONTRACT_TILES = [
|
||||
{ value: 'has_dpa', label: 'AVV / DPA vorhanden', icon: '📄', desc: 'Auftragsverarbeitungsvertrag nach Art. 28 DSGVO' },
|
||||
{ value: 'has_aia_doc', label: 'AI Act Dokumentation', icon: '🤖', desc: 'Risikoklassifizierung und technische Doku nach EU AI Act' },
|
||||
{ value: 'has_dsfa', label: 'DSFA durchgefuehrt', icon: '📋', desc: 'Datenschutz-Folgenabschaetzung nach Art. 35 DSGVO' },
|
||||
{ value: 'has_tia', label: 'TIA durchgefuehrt', icon: '🌍', desc: 'Transfer Impact Assessment fuer Drittlandtransfers' },
|
||||
{ value: 'has_tom', label: 'TOM dokumentiert', icon: '🔒', desc: 'Technisch-organisatorische Massnahmen nach Art. 32 DSGVO' },
|
||||
{ value: 'has_vvt', label: 'Im VVT erfasst', icon: '📚', desc: 'Im Verzeichnis von Verarbeitungstaetigkeiten eingetragen' },
|
||||
{ value: 'has_consent', label: 'Einwilligungen eingeholt', icon: '✅', desc: 'Nutzereinwilligungen vorhanden und dokumentiert' },
|
||||
{ value: 'none', label: 'Noch keine Dokumente', icon: '⚠️', desc: 'Compliance-Dokumentation steht noch aus' },
|
||||
]
|
||||
25
admin-compliance/app/sdk/advisory-board/_types.ts
Normal file
25
admin-compliance/app/sdk/advisory-board/_types.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface AdvisoryForm {
|
||||
title: string
|
||||
use_case_text: string
|
||||
domain: string
|
||||
category: string
|
||||
data_categories: string[]
|
||||
custom_data_types: string[]
|
||||
purposes: string[]
|
||||
automation: string
|
||||
hosting_provider: string
|
||||
hosting_region: string
|
||||
model_usage: string[]
|
||||
transfer_targets: string[]
|
||||
transfer_countries: string[]
|
||||
transfer_mechanism: string
|
||||
retention_period: string
|
||||
retention_purpose: string
|
||||
contracts: string[]
|
||||
subprocessors: string
|
||||
}
|
||||
|
||||
export interface StepProps {
|
||||
form: AdvisoryForm
|
||||
updateForm: (updates: Partial<AdvisoryForm>) => void
|
||||
}
|
||||
@@ -4,298 +4,19 @@ import React, { useState, useEffect, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
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]
|
||||
}
|
||||
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'
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
@@ -321,36 +42,28 @@ function AdvisoryBoardPageInner() {
|
||||
)
|
||||
|
||||
// Form state — tile-based multi-select via arrays
|
||||
const [form, setForm] = useState({
|
||||
const [form, setForm] = useState<AdvisoryForm>({
|
||||
title: '',
|
||||
use_case_text: '',
|
||||
domain: 'general',
|
||||
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,
|
||||
category: '',
|
||||
data_categories: [],
|
||||
custom_data_types: [],
|
||||
purposes: [],
|
||||
automation: '',
|
||||
hosting_provider: '',
|
||||
hosting_region: '',
|
||||
model_usage: [],
|
||||
transfer_targets: [],
|
||||
transfer_countries: [],
|
||||
transfer_mechanism: '',
|
||||
retention_period: '',
|
||||
retention_purpose: '',
|
||||
// Contracts (Step 8 — multi-select tiles)
|
||||
contracts: [] as string[],
|
||||
contracts: [],
|
||||
subprocessors: '',
|
||||
})
|
||||
|
||||
const updateForm = (updates: Partial<typeof form>) => {
|
||||
const updateForm = (updates: Partial<AdvisoryForm>) => {
|
||||
setForm(prev => ({ ...prev, ...updates }))
|
||||
}
|
||||
|
||||
@@ -455,32 +168,12 @@ function AdvisoryBoardPageInner() {
|
||||
|
||||
// If we have a result, show it
|
||||
if (result) {
|
||||
const r = result as { assessment?: { id: string }; result?: Record<string, unknown> }
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Assessment Ergebnis</h1>
|
||||
<div className="flex gap-2">
|
||||
{r.assessment?.id && (
|
||||
<button
|
||||
onClick={() => router.push(`/sdk/use-cases/${r.assessment!.id}`)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
Zum Assessment
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => router.push('/sdk/use-cases')}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
Zur Uebersicht
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{r.result && (
|
||||
<AssessmentResultCard result={r.result as unknown as Parameters<typeof AssessmentResultCard>[0]['result']} />
|
||||
)}
|
||||
</div>
|
||||
<ResultView
|
||||
result={result}
|
||||
onGoToAssessment={(id) => router.push(`/sdk/use-cases/${id}`)}
|
||||
onGoToOverview={() => router.push('/sdk/use-cases')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -513,29 +206,7 @@ function AdvisoryBoardPageInner() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
{WIZARD_STEPS.map((step, idx) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<button
|
||||
onClick={() => setCurrentStep(step.id)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
currentStep === step.id
|
||||
? 'bg-purple-600 text-white'
|
||||
: currentStep > step.id
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<span className="w-6 h-6 rounded-full bg-white/20 flex items-center justify-center text-xs font-bold">
|
||||
{currentStep > step.id ? '✓' : step.id}
|
||||
</span>
|
||||
<span className="hidden md:inline">{step.title}</span>
|
||||
</button>
|
||||
{idx < WIZARD_STEPS.length - 1 && <div className="flex-1 h-px bg-gray-200" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<StepIndicator currentStep={currentStep} onStepClick={setCurrentStep} />
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
@@ -544,545 +215,27 @@ function AdvisoryBoardPageInner() {
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
{/* Step 1: Grundlegendes */}
|
||||
{currentStep === 1 && (
|
||||
<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>
|
||||
<Step1Basics form={form} updateForm={updateForm} profileIndustry={profileIndustry} />
|
||||
)}
|
||||
{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>
|
||||
|
||||
{/* 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>
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
164
admin-compliance/app/sdk/architecture/_components/ArchCanvas.tsx
Normal file
164
admin-compliance/app/sdk/architecture/_components/ArchCanvas.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import ReactFlow, {
|
||||
Node,
|
||||
Controls,
|
||||
Background,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
BackgroundVariant,
|
||||
Panel,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
import { ARCH_SERVICES, LAYERS, type ArchService } from '../architecture-data'
|
||||
import { LAYER_ORDER, type LayerFilter } from '../_layout'
|
||||
import { useArchGraph } from '../_hooks/useArchGraph'
|
||||
import DetailPanel from './DetailPanel'
|
||||
|
||||
export default function ArchCanvas({
|
||||
layerFilter,
|
||||
showDb,
|
||||
showRag,
|
||||
showApis,
|
||||
selectedService,
|
||||
setSelectedService,
|
||||
}: {
|
||||
layerFilter: LayerFilter
|
||||
showDb: boolean
|
||||
showRag: boolean
|
||||
showApis: boolean
|
||||
selectedService: ArchService | null
|
||||
setSelectedService: React.Dispatch<React.SetStateAction<ArchService | null>>
|
||||
}) {
|
||||
const { nodes: initialNodes, edges: initialEdges } = useArchGraph({
|
||||
layerFilter,
|
||||
showDb,
|
||||
showRag,
|
||||
showApis,
|
||||
selectedService,
|
||||
})
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([])
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(initialNodes)
|
||||
setEdges(initialEdges)
|
||||
}, [initialNodes, initialEdges, setNodes, setEdges])
|
||||
|
||||
const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
|
||||
const service = ARCH_SERVICES.find(s => s.id === node.id)
|
||||
if (service) {
|
||||
setSelectedService(prev => (prev?.id === service.id ? null : service))
|
||||
}
|
||||
}, [setSelectedService])
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
setSelectedService(null)
|
||||
}, [setSelectedService])
|
||||
|
||||
return (
|
||||
<div className="flex bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden" style={{ height: '700px' }}>
|
||||
{/* Canvas */}
|
||||
<div className="flex-1 relative">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.15 }}
|
||||
attributionPosition="bottom-left"
|
||||
>
|
||||
<Controls />
|
||||
<MiniMap
|
||||
nodeColor={node => {
|
||||
if (node.id.startsWith('db-')) return '#94a3b8'
|
||||
if (node.id.startsWith('rag-')) return '#22c55e'
|
||||
if (node.id.startsWith('api-')) return '#c4b5fd'
|
||||
const svc = ARCH_SERVICES.find(s => s.id === node.id)
|
||||
return svc ? LAYERS[svc.layer].colorBorder : '#94a3b8'
|
||||
}}
|
||||
maskColor="rgba(0,0,0,0.08)"
|
||||
/>
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
|
||||
{/* Legende */}
|
||||
<Panel
|
||||
position="bottom-right"
|
||||
className="bg-white/95 p-3 rounded-lg shadow-lg text-xs"
|
||||
>
|
||||
<div className="font-medium text-slate-700 mb-2">Legende</div>
|
||||
<div className="space-y-1">
|
||||
{LAYER_ORDER.map(layerId => {
|
||||
const layer = LAYERS[layerId]
|
||||
return (
|
||||
<div key={layerId} className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-3 h-3 rounded"
|
||||
style={{
|
||||
background: layer.colorBg,
|
||||
border: `1px solid ${layer.colorBorder}`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-slate-600">{layer.name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="border-t border-slate-200 my-1.5 pt-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-slate-200 border border-slate-400" />
|
||||
<span className="text-slate-500">DB-Tabelle</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="w-3 h-3 rounded bg-green-100 border border-green-500" />
|
||||
<span className="text-slate-500">RAG-Collection</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="w-3 h-3 rounded bg-violet-100 border border-violet-400" />
|
||||
<span className="text-slate-500">API-Endpunkt</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Swim-Lane Labels */}
|
||||
{layerFilter === 'alle' && (
|
||||
<Panel position="top-left" className="pointer-events-none">
|
||||
<div className="space-y-1">
|
||||
{LAYER_ORDER.map(layerId => {
|
||||
const layer = LAYERS[layerId]
|
||||
return (
|
||||
<div
|
||||
key={layerId}
|
||||
className="px-3 py-1 rounded text-xs font-medium opacity-50"
|
||||
style={{
|
||||
background: layer.colorBg,
|
||||
color: layer.colorText,
|
||||
border: `1px solid ${layer.colorBorder}`,
|
||||
}}
|
||||
>
|
||||
{layer.name}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* Detail Panel */}
|
||||
{selectedService && (
|
||||
<DetailPanel
|
||||
service={selectedService}
|
||||
onClose={() => setSelectedService(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
'use client'
|
||||
|
||||
export default function ArchHeader({
|
||||
stats,
|
||||
}: {
|
||||
stats: {
|
||||
services: number
|
||||
dbTables: number
|
||||
ragCollections: number
|
||||
edges: number
|
||||
}
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-slate-700 flex items-center justify-center text-white">
|
||||
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">
|
||||
Architektur-Uebersicht
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
{stats.services} Services | {stats.dbTables} DB-Tabellen | {stats.ragCollections} RAG-Collections | {stats.edges} Verbindungen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
'use client'
|
||||
|
||||
import { ARCH_SERVICES, LAYERS, type ArchService } from '../architecture-data'
|
||||
|
||||
export default function DetailPanel({
|
||||
service,
|
||||
onClose,
|
||||
}: {
|
||||
service: ArchService
|
||||
onClose: () => void
|
||||
}) {
|
||||
const layer = LAYERS[service.layer]
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-white border-l border-slate-200 overflow-y-auto">
|
||||
<div className="sticky top-0 bg-white z-10 border-b border-slate-200">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<h3 className="font-bold text-slate-900">{service.name}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-slate-600 text-lg leading-none"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-4 pb-3 flex items-center gap-2">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={{ background: layer.colorBg, color: layer.colorText }}
|
||||
>
|
||||
{layer.name}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">{service.tech}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Beschreibung */}
|
||||
<p className="text-sm text-slate-700 leading-relaxed">{service.description}</p>
|
||||
<p className="text-xs text-slate-500 leading-relaxed mt-1">{service.descriptionLong}</p>
|
||||
|
||||
{/* Tech + Port + Container */}
|
||||
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">Tech</span>
|
||||
<span className="font-medium text-slate-800">{service.tech}</span>
|
||||
</div>
|
||||
{service.port && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">Port</span>
|
||||
<code className="text-xs bg-slate-200 px-1.5 py-0.5 rounded">{service.port}</code>
|
||||
</div>
|
||||
)}
|
||||
{service.url && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">URL</span>
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:underline truncate max-w-[180px]"
|
||||
>
|
||||
{service.url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">Container</span>
|
||||
<code className="text-xs bg-slate-200 px-1.5 py-0.5 rounded truncate max-w-[180px]">{service.container}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DB Tables */}
|
||||
{service.dbTables.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
|
||||
DB-Tabellen ({service.dbTables.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.dbTables.map(table => (
|
||||
<div
|
||||
key={table}
|
||||
className="text-sm bg-slate-100 rounded px-2 py-1"
|
||||
>
|
||||
<code className="text-slate-700 text-xs">{table}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RAG Collections */}
|
||||
{service.ragCollections.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
|
||||
RAG-Collections ({service.ragCollections.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.ragCollections.map(rag => (
|
||||
<div
|
||||
key={rag}
|
||||
className="text-sm bg-green-50 rounded px-2 py-1"
|
||||
>
|
||||
<code className="text-green-700 text-xs">{rag}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Endpoints */}
|
||||
{service.apiEndpoints.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
|
||||
API-Endpunkte ({service.apiEndpoints.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.apiEndpoints.map(ep => (
|
||||
<div
|
||||
key={ep}
|
||||
className="text-sm bg-violet-50 rounded px-2 py-1"
|
||||
>
|
||||
<code className="text-violet-700 text-xs">{ep}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dependencies */}
|
||||
{service.dependsOn.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
|
||||
Abhaengigkeiten
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.dependsOn.map(depId => {
|
||||
const dep = ARCH_SERVICES.find(s => s.id === depId)
|
||||
return (
|
||||
<div
|
||||
key={depId}
|
||||
className="text-sm text-slate-600 bg-slate-50 rounded px-2 py-1"
|
||||
>
|
||||
{dep?.name || depId}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Open URL */}
|
||||
{service.url && (
|
||||
<button
|
||||
onClick={() => window.open(service.url!, '_blank')}
|
||||
className="w-full mt-2 px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Service oeffnen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { ARCH_SERVICES, LAYERS, type ArchService } from '../architecture-data'
|
||||
import { type LayerFilter } from '../_layout'
|
||||
|
||||
export default function ServiceTable({
|
||||
layerFilter,
|
||||
expandedServices,
|
||||
onToggleExpanded,
|
||||
onMarkInGraph,
|
||||
}: {
|
||||
layerFilter: LayerFilter
|
||||
expandedServices: Set<string>
|
||||
onToggleExpanded: (id: string) => void
|
||||
onMarkInGraph: (service: ArchService) => void
|
||||
}) {
|
||||
const filtered = ARCH_SERVICES.filter(
|
||||
s => layerFilter === 'alle' || s.layer === layerFilter
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 border-b">
|
||||
<h3 className="font-medium text-slate-700">
|
||||
Alle Services ({
|
||||
layerFilter === 'alle'
|
||||
? ARCH_SERVICES.length
|
||||
: `${ARCH_SERVICES.filter(s => s.layer === layerFilter).length} / ${ARCH_SERVICES.length}`
|
||||
})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="divide-y max-h-[600px] overflow-y-auto">
|
||||
{filtered.map(service => {
|
||||
const layer = LAYERS[service.layer]
|
||||
const isExpanded = expandedServices.has(service.id)
|
||||
|
||||
return (
|
||||
<div key={service.id}>
|
||||
{/* Row Header */}
|
||||
<button
|
||||
onClick={() => onToggleExpanded(service.id)}
|
||||
className={`w-full flex items-center gap-3 p-3 text-left transition-colors ${
|
||||
isExpanded
|
||||
? 'bg-purple-50'
|
||||
: 'hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{/* Chevron */}
|
||||
<svg
|
||||
className={`w-4 h-4 text-slate-400 shrink-0 transition-transform duration-200 ${
|
||||
isExpanded ? 'rotate-90' : ''
|
||||
}`}
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[10px] font-bold shrink-0"
|
||||
style={{ background: layer.colorBg, color: layer.colorText }}
|
||||
>
|
||||
{service.port ? `:${service.port}` : '--'}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-800 text-sm">
|
||||
{service.name}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 flex items-center gap-2 mt-0.5">
|
||||
<span className="text-slate-400">{service.tech}</span>
|
||||
{service.dbTables.length > 0 && (
|
||||
<span className="text-slate-400">
|
||||
DB: {service.dbTables.length}
|
||||
</span>
|
||||
)}
|
||||
{service.ragCollections.length > 0 && (
|
||||
<span className="text-green-600">
|
||||
RAG: {service.ragCollections.length}
|
||||
</span>
|
||||
)}
|
||||
{service.apiEndpoints.length > 0 && (
|
||||
<span className="text-violet-600">
|
||||
API: {service.apiEndpoints.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<code className="text-[10px] text-slate-400 shrink-0 hidden sm:block">
|
||||
{service.container}
|
||||
</code>
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-[10px] font-medium shrink-0"
|
||||
style={{ background: layer.colorBg, color: layer.colorText }}
|
||||
>
|
||||
{layer.name}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded Detail */}
|
||||
{isExpanded && <ExpandedRow service={service} onMarkInGraph={onMarkInGraph} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExpandedRow({
|
||||
service,
|
||||
onMarkInGraph,
|
||||
}: {
|
||||
service: ArchService
|
||||
onMarkInGraph: (service: ArchService) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="px-4 pb-4 pt-1 bg-slate-50/50 border-t border-slate-100">
|
||||
{/* Beschreibung */}
|
||||
<p className="text-sm text-slate-700 leading-relaxed">
|
||||
{service.description}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 leading-relaxed mt-1 mb-3">
|
||||
{service.descriptionLong}
|
||||
</p>
|
||||
|
||||
{/* Info Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-3">
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
|
||||
<div className="text-[10px] font-semibold text-slate-400 uppercase">Tech</div>
|
||||
<div className="text-sm font-medium text-slate-800 mt-0.5">{service.tech}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
|
||||
<div className="text-[10px] font-semibold text-slate-400 uppercase">Port</div>
|
||||
<div className="text-sm font-medium text-slate-800 mt-0.5">
|
||||
{service.port ? `:${service.port}` : 'Intern'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
|
||||
<div className="text-[10px] font-semibold text-slate-400 uppercase">Container</div>
|
||||
<div className="text-xs font-mono text-slate-700 mt-0.5 truncate">{service.container}</div>
|
||||
</div>
|
||||
{service.url && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
|
||||
<div className="text-[10px] font-semibold text-slate-400 uppercase">URL</div>
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:underline mt-0.5 block truncate"
|
||||
>
|
||||
{service.url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{service.dbTables.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3">
|
||||
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
|
||||
DB-Tabellen ({service.dbTables.length})
|
||||
</h4>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{service.dbTables.map(table => (
|
||||
<div key={table} className="bg-slate-50 rounded px-2 py-1">
|
||||
<code className="text-xs text-slate-700">{table}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{service.ragCollections.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3">
|
||||
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
|
||||
RAG-Collections ({service.ragCollections.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.ragCollections.map(rag => (
|
||||
<div key={rag} className="bg-green-50 rounded px-2 py-1">
|
||||
<code className="text-xs text-green-700">{rag}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{service.apiEndpoints.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3">
|
||||
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
|
||||
API-Endpunkte ({service.apiEndpoints.length})
|
||||
</h4>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{service.apiEndpoints.map(ep => (
|
||||
<div key={ep} className="bg-violet-50 rounded px-2 py-1">
|
||||
<code className="text-xs text-violet-700">{ep}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{service.dependsOn.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3">
|
||||
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
|
||||
Abhaengigkeiten ({service.dependsOn.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.dependsOn.map(depId => {
|
||||
const dep = ARCH_SERVICES.find(s => s.id === depId)
|
||||
const depLayer = dep ? LAYERS[dep.layer] : null
|
||||
return (
|
||||
<div key={depId} className="flex items-center gap-2 bg-slate-50 rounded px-2 py-1">
|
||||
{depLayer && (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ background: depLayer.colorBorder }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-slate-700">{dep?.name || depId}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open in Graph + URL */}
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
onMarkInGraph(service)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}}
|
||||
className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-xs font-medium hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
Im Graph markieren
|
||||
</button>
|
||||
{service.url && (
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1.5 bg-purple-600 text-white rounded-lg text-xs font-medium hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Service oeffnen
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import { ARCH_SERVICES, LAYERS } from '../architecture-data'
|
||||
import { LAYER_ORDER, type LayerFilter } from '../_layout'
|
||||
|
||||
export default function Toolbar({
|
||||
layerFilter,
|
||||
showDb,
|
||||
showRag,
|
||||
showApis,
|
||||
onLayerFilter,
|
||||
onToggleDb,
|
||||
onToggleRag,
|
||||
onToggleApis,
|
||||
}: {
|
||||
layerFilter: LayerFilter
|
||||
showDb: boolean
|
||||
showRag: boolean
|
||||
showApis: boolean
|
||||
onLayerFilter: (f: LayerFilter) => void
|
||||
onToggleDb: () => void
|
||||
onToggleRag: () => void
|
||||
onToggleApis: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Layer Filter */}
|
||||
<button
|
||||
onClick={() => onLayerFilter('alle')}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
layerFilter === 'alle'
|
||||
? 'bg-slate-800 text-white'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Alle ({ARCH_SERVICES.length})
|
||||
</button>
|
||||
{LAYER_ORDER.map(layerId => {
|
||||
const layer = LAYERS[layerId]
|
||||
const count = ARCH_SERVICES.filter(s => s.layer === layerId).length
|
||||
return (
|
||||
<button
|
||||
key={layerId}
|
||||
onClick={() => onLayerFilter(layerFilter === layerId ? 'alle' : layerId)}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-all flex items-center gap-1.5"
|
||||
style={{
|
||||
background: layerFilter === layerId ? layer.colorBorder : layer.colorBg,
|
||||
color: layerFilter === layerId ? 'white' : layer.colorText,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ background: layer.colorBorder }}
|
||||
/>
|
||||
{layer.name} ({count})
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-6 bg-slate-200 mx-1" />
|
||||
|
||||
{/* Toggles */}
|
||||
<button
|
||||
onClick={onToggleDb}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
showDb
|
||||
? 'bg-slate-700 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
DB-Tabellen
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleRag}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
showRag
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-green-50 text-green-700 hover:bg-green-100'
|
||||
}`}
|
||||
>
|
||||
RAG-Collections
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleApis}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
showApis
|
||||
? 'bg-violet-600 text-white'
|
||||
: 'bg-violet-50 text-violet-700 hover:bg-violet-100'
|
||||
}`}
|
||||
>
|
||||
API-Endpunkte
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
251
admin-compliance/app/sdk/architecture/_hooks/useArchGraph.tsx
Normal file
251
admin-compliance/app/sdk/architecture/_hooks/useArchGraph.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { type Node, type Edge, MarkerType } from 'reactflow'
|
||||
import {
|
||||
ARCH_SERVICES,
|
||||
ARCH_EDGES,
|
||||
LAYERS,
|
||||
type ArchService,
|
||||
} from '../architecture-data'
|
||||
import {
|
||||
NODE_WIDTH,
|
||||
LANE_Y_START,
|
||||
getServicePosition,
|
||||
type LayerFilter,
|
||||
} from '../_layout'
|
||||
|
||||
export function useArchGraph({
|
||||
layerFilter,
|
||||
showDb,
|
||||
showRag,
|
||||
showApis,
|
||||
selectedService,
|
||||
}: {
|
||||
layerFilter: LayerFilter
|
||||
showDb: boolean
|
||||
showRag: boolean
|
||||
showApis: boolean
|
||||
selectedService: ArchService | null
|
||||
}) {
|
||||
return useMemo(() => {
|
||||
const nodes: Node[] = []
|
||||
const edges: Edge[] = []
|
||||
|
||||
const visibleServices =
|
||||
layerFilter === 'alle'
|
||||
? ARCH_SERVICES
|
||||
: ARCH_SERVICES.filter(s => s.layer === layerFilter)
|
||||
|
||||
const visibleIds = new Set(visibleServices.map(s => s.id))
|
||||
|
||||
// ── Service Nodes ──────────────────────────────────────────────────────
|
||||
visibleServices.forEach(service => {
|
||||
const layer = LAYERS[service.layer]
|
||||
const pos = getServicePosition(service)
|
||||
const isSelected = selectedService?.id === service.id
|
||||
|
||||
nodes.push({
|
||||
id: service.id,
|
||||
type: 'default',
|
||||
position: pos,
|
||||
data: {
|
||||
label: (
|
||||
<div className="text-center px-1">
|
||||
<div className="font-medium text-xs leading-tight">
|
||||
{service.nameShort}
|
||||
</div>
|
||||
<div className="text-[10px] opacity-70 mt-0.5">
|
||||
{service.tech}
|
||||
</div>
|
||||
{service.port && (
|
||||
<div className="text-[9px] opacity-50 mt-0.5">
|
||||
:{service.port}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
style: {
|
||||
background: isSelected ? layer.colorBorder : layer.colorBg,
|
||||
color: isSelected ? 'white' : layer.colorText,
|
||||
border: `2px solid ${layer.colorBorder}`,
|
||||
borderRadius: '10px',
|
||||
padding: '8px 4px',
|
||||
minWidth: `${NODE_WIDTH}px`,
|
||||
maxWidth: `${NODE_WIDTH}px`,
|
||||
cursor: 'pointer',
|
||||
boxShadow: isSelected
|
||||
? `0 0 16px ${layer.colorBorder}`
|
||||
: '0 1px 3px rgba(0,0,0,0.08)',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// ── Connection Edges ───────────────────────────────────────────────────
|
||||
ARCH_EDGES.forEach(archEdge => {
|
||||
if (visibleIds.has(archEdge.source) && visibleIds.has(archEdge.target)) {
|
||||
const isHighlighted =
|
||||
selectedService?.id === archEdge.source ||
|
||||
selectedService?.id === archEdge.target
|
||||
|
||||
edges.push({
|
||||
id: `e-${archEdge.source}-${archEdge.target}`,
|
||||
source: archEdge.source,
|
||||
target: archEdge.target,
|
||||
type: 'smoothstep',
|
||||
animated: isHighlighted,
|
||||
label: archEdge.label,
|
||||
labelStyle: { fontSize: 9, fill: isHighlighted ? '#7c3aed' : '#94a3b8' },
|
||||
style: {
|
||||
stroke: isHighlighted ? '#7c3aed' : '#94a3b8',
|
||||
strokeWidth: isHighlighted ? 2.5 : 1.5,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: isHighlighted ? '#7c3aed' : '#94a3b8',
|
||||
width: 14,
|
||||
height: 14,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ── DB Table Nodes ─────────────────────────────────────────────────────
|
||||
if (showDb) {
|
||||
const dbTablesInUse = new Set<string>()
|
||||
visibleServices.forEach(s => s.dbTables.forEach(t => dbTablesInUse.add(t)))
|
||||
|
||||
let dbIdx = 0
|
||||
dbTablesInUse.forEach(table => {
|
||||
const nodeId = `db-${table}`
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: 'default',
|
||||
position: { x: -250, y: LANE_Y_START + dbIdx * 60 },
|
||||
data: {
|
||||
label: (
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-[10px] leading-tight">{table}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
style: {
|
||||
background: '#f1f5f9',
|
||||
color: '#475569',
|
||||
border: '1px solid #94a3b8',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 6px',
|
||||
fontSize: '10px',
|
||||
minWidth: '140px',
|
||||
},
|
||||
})
|
||||
|
||||
visibleServices
|
||||
.filter(s => s.dbTables.includes(table))
|
||||
.forEach(svc => {
|
||||
edges.push({
|
||||
id: `e-db-${table}-${svc.id}`,
|
||||
source: nodeId,
|
||||
target: svc.id,
|
||||
type: 'straight',
|
||||
style: { stroke: '#94a3b8', strokeWidth: 1, strokeDasharray: '6 3' },
|
||||
})
|
||||
})
|
||||
|
||||
dbIdx++
|
||||
})
|
||||
}
|
||||
|
||||
// ── RAG Collection Nodes ───────────────────────────────────────────────
|
||||
if (showRag) {
|
||||
const ragInUse = new Set<string>()
|
||||
visibleServices.forEach(s => s.ragCollections.forEach(r => ragInUse.add(r)))
|
||||
|
||||
let ragIdx = 0
|
||||
ragInUse.forEach(collection => {
|
||||
const nodeId = `rag-${collection}`
|
||||
const rightX = 1200
|
||||
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: 'default',
|
||||
position: { x: rightX, y: LANE_Y_START + ragIdx * 60 },
|
||||
data: {
|
||||
label: (
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-[10px] leading-tight">
|
||||
{collection.replace('bp_', '')}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
style: {
|
||||
background: '#dcfce7',
|
||||
color: '#166534',
|
||||
border: '1px solid #22c55e',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 6px',
|
||||
fontSize: '10px',
|
||||
minWidth: '130px',
|
||||
},
|
||||
})
|
||||
|
||||
visibleServices
|
||||
.filter(s => s.ragCollections.includes(collection))
|
||||
.forEach(svc => {
|
||||
edges.push({
|
||||
id: `e-rag-${collection}-${svc.id}`,
|
||||
source: nodeId,
|
||||
target: svc.id,
|
||||
type: 'straight',
|
||||
style: { stroke: '#22c55e', strokeWidth: 1, strokeDasharray: '6 3' },
|
||||
})
|
||||
})
|
||||
|
||||
ragIdx++
|
||||
})
|
||||
}
|
||||
|
||||
// ── API Endpoint Nodes ─────────────────────────────────────────────────
|
||||
if (showApis) {
|
||||
visibleServices.forEach(svc => {
|
||||
if (svc.apiEndpoints.length === 0) return
|
||||
const svcPos = getServicePosition(svc)
|
||||
|
||||
svc.apiEndpoints.forEach((ep, idx) => {
|
||||
const nodeId = `api-${svc.id}-${idx}`
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: 'default',
|
||||
position: { x: svcPos.x + NODE_WIDTH + 30, y: svcPos.y + idx * 32 },
|
||||
data: {
|
||||
label: (
|
||||
<div className="text-[9px] font-mono leading-tight truncate">{ep}</div>
|
||||
),
|
||||
},
|
||||
style: {
|
||||
background: '#faf5ff',
|
||||
color: '#7c3aed',
|
||||
border: '1px solid #c4b5fd',
|
||||
borderRadius: '4px',
|
||||
padding: '2px 6px',
|
||||
fontSize: '9px',
|
||||
minWidth: '160px',
|
||||
},
|
||||
})
|
||||
|
||||
edges.push({
|
||||
id: `e-api-${svc.id}-${idx}`,
|
||||
source: svc.id,
|
||||
target: nodeId,
|
||||
type: 'straight',
|
||||
style: { stroke: '#c4b5fd', strokeWidth: 1 },
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return { nodes, edges }
|
||||
}, [layerFilter, showDb, showRag, showApis, selectedService])
|
||||
}
|
||||
30
admin-compliance/app/sdk/architecture/_layout.ts
Normal file
30
admin-compliance/app/sdk/architecture/_layout.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ARCH_SERVICES, LAYERS, type ArchService, type ServiceLayer } from './architecture-data'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export type LayerFilter = 'alle' | ServiceLayer
|
||||
|
||||
// =============================================================================
|
||||
// LAYOUT
|
||||
// =============================================================================
|
||||
|
||||
export const NODE_WIDTH = 180
|
||||
export const NODE_HEIGHT = 70
|
||||
export const NODE_X_SPACING = 220
|
||||
export const LANE_Y_START = 80
|
||||
export const LANE_LABEL_HEIGHT = 40
|
||||
|
||||
export const LAYER_ORDER: ServiceLayer[] = ['frontend', 'backend', 'infrastructure', 'data-sovereignty']
|
||||
|
||||
export function getServicePosition(service: ArchService): { x: number; y: number } {
|
||||
const layer = LAYERS[service.layer]
|
||||
const layerServices = ARCH_SERVICES.filter(s => s.layer === service.layer)
|
||||
const idx = layerServices.findIndex(s => s.id === service.id)
|
||||
|
||||
return {
|
||||
x: 80 + idx * NODE_X_SPACING,
|
||||
y: LANE_Y_START + LANE_LABEL_HEIGHT + layer.y,
|
||||
}
|
||||
}
|
||||
@@ -8,228 +8,19 @@
|
||||
* Analog zum SDK-Flow, aber fuer die Service-Topologie.
|
||||
*/
|
||||
|
||||
import { useCallback, useState, useMemo, useEffect } from 'react'
|
||||
import ReactFlow, {
|
||||
Node,
|
||||
Edge,
|
||||
Controls,
|
||||
Background,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
BackgroundVariant,
|
||||
MarkerType,
|
||||
Panel,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
|
||||
import { useCallback, useState, useMemo } from 'react'
|
||||
import {
|
||||
ARCH_SERVICES,
|
||||
ARCH_EDGES,
|
||||
LAYERS,
|
||||
getAllDbTables,
|
||||
getAllRagCollections,
|
||||
type ArchService,
|
||||
type ServiceLayer,
|
||||
} from './architecture-data'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
type LayerFilter = 'alle' | ServiceLayer
|
||||
|
||||
// =============================================================================
|
||||
// LAYOUT
|
||||
// =============================================================================
|
||||
|
||||
const NODE_WIDTH = 180
|
||||
const NODE_HEIGHT = 70
|
||||
const NODE_X_SPACING = 220
|
||||
const LANE_Y_START = 80
|
||||
const LANE_LABEL_HEIGHT = 40
|
||||
|
||||
const LAYER_ORDER: ServiceLayer[] = ['frontend', 'backend', 'infrastructure', 'data-sovereignty']
|
||||
|
||||
function getServicePosition(service: ArchService): { x: number; y: number } {
|
||||
const layer = LAYERS[service.layer]
|
||||
const layerServices = ARCH_SERVICES.filter(s => s.layer === service.layer)
|
||||
const idx = layerServices.findIndex(s => s.id === service.id)
|
||||
|
||||
return {
|
||||
x: 80 + idx * NODE_X_SPACING,
|
||||
y: LANE_Y_START + LANE_LABEL_HEIGHT + layer.y,
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DETAIL PANEL
|
||||
// =============================================================================
|
||||
|
||||
function DetailPanel({
|
||||
service,
|
||||
onClose,
|
||||
}: {
|
||||
service: ArchService
|
||||
onClose: () => void
|
||||
}) {
|
||||
const layer = LAYERS[service.layer]
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-white border-l border-slate-200 overflow-y-auto">
|
||||
<div className="sticky top-0 bg-white z-10 border-b border-slate-200">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<h3 className="font-bold text-slate-900">{service.name}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-slate-400 hover:text-slate-600 text-lg leading-none"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-4 pb-3 flex items-center gap-2">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-xs font-medium"
|
||||
style={{ background: layer.colorBg, color: layer.colorText }}
|
||||
>
|
||||
{layer.name}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">{service.tech}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Beschreibung */}
|
||||
<p className="text-sm text-slate-700 leading-relaxed">{service.description}</p>
|
||||
<p className="text-xs text-slate-500 leading-relaxed mt-1">{service.descriptionLong}</p>
|
||||
|
||||
{/* Tech + Port + Container */}
|
||||
<div className="bg-slate-50 rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">Tech</span>
|
||||
<span className="font-medium text-slate-800">{service.tech}</span>
|
||||
</div>
|
||||
{service.port && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">Port</span>
|
||||
<code className="text-xs bg-slate-200 px-1.5 py-0.5 rounded">{service.port}</code>
|
||||
</div>
|
||||
)}
|
||||
{service.url && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">URL</span>
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:underline truncate max-w-[180px]"
|
||||
>
|
||||
{service.url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-slate-500">Container</span>
|
||||
<code className="text-xs bg-slate-200 px-1.5 py-0.5 rounded truncate max-w-[180px]">{service.container}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DB Tables */}
|
||||
{service.dbTables.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
|
||||
DB-Tabellen ({service.dbTables.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.dbTables.map(table => (
|
||||
<div
|
||||
key={table}
|
||||
className="text-sm bg-slate-100 rounded px-2 py-1"
|
||||
>
|
||||
<code className="text-slate-700 text-xs">{table}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RAG Collections */}
|
||||
{service.ragCollections.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
|
||||
RAG-Collections ({service.ragCollections.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.ragCollections.map(rag => (
|
||||
<div
|
||||
key={rag}
|
||||
className="text-sm bg-green-50 rounded px-2 py-1"
|
||||
>
|
||||
<code className="text-green-700 text-xs">{rag}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Endpoints */}
|
||||
{service.apiEndpoints.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
|
||||
API-Endpunkte ({service.apiEndpoints.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.apiEndpoints.map(ep => (
|
||||
<div
|
||||
key={ep}
|
||||
className="text-sm bg-violet-50 rounded px-2 py-1"
|
||||
>
|
||||
<code className="text-violet-700 text-xs">{ep}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dependencies */}
|
||||
{service.dependsOn.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-slate-500 uppercase mb-1.5">
|
||||
Abhaengigkeiten
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.dependsOn.map(depId => {
|
||||
const dep = ARCH_SERVICES.find(s => s.id === depId)
|
||||
return (
|
||||
<div
|
||||
key={depId}
|
||||
className="text-sm text-slate-600 bg-slate-50 rounded px-2 py-1"
|
||||
>
|
||||
{dep?.name || depId}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Open URL */}
|
||||
{service.url && (
|
||||
<button
|
||||
onClick={() => window.open(service.url!, '_blank')}
|
||||
className="w-full mt-2 px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Service oeffnen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
import { type LayerFilter } from './_layout'
|
||||
import ArchHeader from './_components/ArchHeader'
|
||||
import Toolbar from './_components/Toolbar'
|
||||
import ArchCanvas from './_components/ArchCanvas'
|
||||
import ServiceTable from './_components/ServiceTable'
|
||||
|
||||
export default function ArchitecturePage() {
|
||||
const [selectedService, setSelectedService] = useState<ArchService | null>(null)
|
||||
@@ -251,258 +42,6 @@ export default function ArchitecturePage() {
|
||||
const allDbTables = useMemo(() => getAllDbTables(), [])
|
||||
const allRagCollections = useMemo(() => getAllRagCollections(), [])
|
||||
|
||||
// =========================================================================
|
||||
// Build Nodes + Edges
|
||||
// =========================================================================
|
||||
|
||||
const { nodes: initialNodes, edges: initialEdges } = useMemo(() => {
|
||||
const nodes: Node[] = []
|
||||
const edges: Edge[] = []
|
||||
|
||||
const visibleServices =
|
||||
layerFilter === 'alle'
|
||||
? ARCH_SERVICES
|
||||
: ARCH_SERVICES.filter(s => s.layer === layerFilter)
|
||||
|
||||
const visibleIds = new Set(visibleServices.map(s => s.id))
|
||||
|
||||
// ── Service Nodes ──────────────────────────────────────────────────────
|
||||
visibleServices.forEach(service => {
|
||||
const layer = LAYERS[service.layer]
|
||||
const pos = getServicePosition(service)
|
||||
const isSelected = selectedService?.id === service.id
|
||||
|
||||
nodes.push({
|
||||
id: service.id,
|
||||
type: 'default',
|
||||
position: pos,
|
||||
data: {
|
||||
label: (
|
||||
<div className="text-center px-1">
|
||||
<div className="font-medium text-xs leading-tight">
|
||||
{service.nameShort}
|
||||
</div>
|
||||
<div className="text-[10px] opacity-70 mt-0.5">
|
||||
{service.tech}
|
||||
</div>
|
||||
{service.port && (
|
||||
<div className="text-[9px] opacity-50 mt-0.5">
|
||||
:{service.port}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
style: {
|
||||
background: isSelected ? layer.colorBorder : layer.colorBg,
|
||||
color: isSelected ? 'white' : layer.colorText,
|
||||
border: `2px solid ${layer.colorBorder}`,
|
||||
borderRadius: '10px',
|
||||
padding: '8px 4px',
|
||||
minWidth: `${NODE_WIDTH}px`,
|
||||
maxWidth: `${NODE_WIDTH}px`,
|
||||
cursor: 'pointer',
|
||||
boxShadow: isSelected
|
||||
? `0 0 16px ${layer.colorBorder}`
|
||||
: '0 1px 3px rgba(0,0,0,0.08)',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// ── Connection Edges ───────────────────────────────────────────────────
|
||||
ARCH_EDGES.forEach(archEdge => {
|
||||
if (visibleIds.has(archEdge.source) && visibleIds.has(archEdge.target)) {
|
||||
const isHighlighted =
|
||||
selectedService?.id === archEdge.source ||
|
||||
selectedService?.id === archEdge.target
|
||||
|
||||
edges.push({
|
||||
id: `e-${archEdge.source}-${archEdge.target}`,
|
||||
source: archEdge.source,
|
||||
target: archEdge.target,
|
||||
type: 'smoothstep',
|
||||
animated: isHighlighted,
|
||||
label: archEdge.label,
|
||||
labelStyle: { fontSize: 9, fill: isHighlighted ? '#7c3aed' : '#94a3b8' },
|
||||
style: {
|
||||
stroke: isHighlighted ? '#7c3aed' : '#94a3b8',
|
||||
strokeWidth: isHighlighted ? 2.5 : 1.5,
|
||||
},
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: isHighlighted ? '#7c3aed' : '#94a3b8',
|
||||
width: 14,
|
||||
height: 14,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// ── DB Table Nodes ─────────────────────────────────────────────────────
|
||||
if (showDb) {
|
||||
const dbTablesInUse = new Set<string>()
|
||||
visibleServices.forEach(s => s.dbTables.forEach(t => dbTablesInUse.add(t)))
|
||||
|
||||
let dbIdx = 0
|
||||
dbTablesInUse.forEach(table => {
|
||||
const nodeId = `db-${table}`
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: 'default',
|
||||
position: { x: -250, y: LANE_Y_START + dbIdx * 60 },
|
||||
data: {
|
||||
label: (
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-[10px] leading-tight">{table}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
style: {
|
||||
background: '#f1f5f9',
|
||||
color: '#475569',
|
||||
border: '1px solid #94a3b8',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 6px',
|
||||
fontSize: '10px',
|
||||
minWidth: '140px',
|
||||
},
|
||||
})
|
||||
|
||||
visibleServices
|
||||
.filter(s => s.dbTables.includes(table))
|
||||
.forEach(svc => {
|
||||
edges.push({
|
||||
id: `e-db-${table}-${svc.id}`,
|
||||
source: nodeId,
|
||||
target: svc.id,
|
||||
type: 'straight',
|
||||
style: { stroke: '#94a3b8', strokeWidth: 1, strokeDasharray: '6 3' },
|
||||
})
|
||||
})
|
||||
|
||||
dbIdx++
|
||||
})
|
||||
}
|
||||
|
||||
// ── RAG Collection Nodes ───────────────────────────────────────────────
|
||||
if (showRag) {
|
||||
const ragInUse = new Set<string>()
|
||||
visibleServices.forEach(s => s.ragCollections.forEach(r => ragInUse.add(r)))
|
||||
|
||||
let ragIdx = 0
|
||||
ragInUse.forEach(collection => {
|
||||
const nodeId = `rag-${collection}`
|
||||
const rightX = 1200
|
||||
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: 'default',
|
||||
position: { x: rightX, y: LANE_Y_START + ragIdx * 60 },
|
||||
data: {
|
||||
label: (
|
||||
<div className="text-center">
|
||||
<div className="font-medium text-[10px] leading-tight">
|
||||
{collection.replace('bp_', '')}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
style: {
|
||||
background: '#dcfce7',
|
||||
color: '#166534',
|
||||
border: '1px solid #22c55e',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 6px',
|
||||
fontSize: '10px',
|
||||
minWidth: '130px',
|
||||
},
|
||||
})
|
||||
|
||||
visibleServices
|
||||
.filter(s => s.ragCollections.includes(collection))
|
||||
.forEach(svc => {
|
||||
edges.push({
|
||||
id: `e-rag-${collection}-${svc.id}`,
|
||||
source: nodeId,
|
||||
target: svc.id,
|
||||
type: 'straight',
|
||||
style: { stroke: '#22c55e', strokeWidth: 1, strokeDasharray: '6 3' },
|
||||
})
|
||||
})
|
||||
|
||||
ragIdx++
|
||||
})
|
||||
}
|
||||
|
||||
// ── API Endpoint Nodes ─────────────────────────────────────────────────
|
||||
if (showApis) {
|
||||
visibleServices.forEach(svc => {
|
||||
if (svc.apiEndpoints.length === 0) return
|
||||
const svcPos = getServicePosition(svc)
|
||||
|
||||
svc.apiEndpoints.forEach((ep, idx) => {
|
||||
const nodeId = `api-${svc.id}-${idx}`
|
||||
nodes.push({
|
||||
id: nodeId,
|
||||
type: 'default',
|
||||
position: { x: svcPos.x + NODE_WIDTH + 30, y: svcPos.y + idx * 32 },
|
||||
data: {
|
||||
label: (
|
||||
<div className="text-[9px] font-mono leading-tight truncate">{ep}</div>
|
||||
),
|
||||
},
|
||||
style: {
|
||||
background: '#faf5ff',
|
||||
color: '#7c3aed',
|
||||
border: '1px solid #c4b5fd',
|
||||
borderRadius: '4px',
|
||||
padding: '2px 6px',
|
||||
fontSize: '9px',
|
||||
minWidth: '160px',
|
||||
},
|
||||
})
|
||||
|
||||
edges.push({
|
||||
id: `e-api-${svc.id}-${idx}`,
|
||||
source: svc.id,
|
||||
target: nodeId,
|
||||
type: 'straight',
|
||||
style: { stroke: '#c4b5fd', strokeWidth: 1 },
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return { nodes, edges }
|
||||
}, [layerFilter, showDb, showRag, showApis, selectedService])
|
||||
|
||||
// =========================================================================
|
||||
// React Flow State
|
||||
// =========================================================================
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([])
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(initialNodes)
|
||||
setEdges(initialEdges)
|
||||
}, [initialNodes, initialEdges, setNodes, setEdges])
|
||||
|
||||
const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
|
||||
const service = ARCH_SERVICES.find(s => s.id === node.id)
|
||||
if (service) {
|
||||
setSelectedService(prev => (prev?.id === service.id ? null : service))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
setSelectedService(null)
|
||||
}, [])
|
||||
|
||||
// =========================================================================
|
||||
// Stats
|
||||
// =========================================================================
|
||||
|
||||
const stats = useMemo(() => {
|
||||
return {
|
||||
services: ARCH_SERVICES.length,
|
||||
@@ -512,439 +51,41 @@ export default function ArchitecturePage() {
|
||||
}
|
||||
}, [allDbTables, allRagCollections])
|
||||
|
||||
// =========================================================================
|
||||
// Render
|
||||
// =========================================================================
|
||||
const handleLayerFilter = useCallback((f: LayerFilter) => {
|
||||
setLayerFilter(f)
|
||||
setSelectedService(null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-slate-700 flex items-center justify-center text-white">
|
||||
<svg className="w-7 h-7" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-slate-900">
|
||||
Architektur-Uebersicht
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
{stats.services} Services | {stats.dbTables} DB-Tabellen | {stats.ragCollections} RAG-Collections | {stats.edges} Verbindungen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArchHeader stats={stats} />
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Layer Filter */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setLayerFilter('alle')
|
||||
setSelectedService(null)
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
layerFilter === 'alle'
|
||||
? 'bg-slate-800 text-white'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Alle ({ARCH_SERVICES.length})
|
||||
</button>
|
||||
{LAYER_ORDER.map(layerId => {
|
||||
const layer = LAYERS[layerId]
|
||||
const count = ARCH_SERVICES.filter(s => s.layer === layerId).length
|
||||
return (
|
||||
<button
|
||||
key={layerId}
|
||||
onClick={() => {
|
||||
setLayerFilter(layerFilter === layerId ? 'alle' : layerId)
|
||||
setSelectedService(null)
|
||||
}}
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-all flex items-center gap-1.5"
|
||||
style={{
|
||||
background: layerFilter === layerId ? layer.colorBorder : layer.colorBg,
|
||||
color: layerFilter === layerId ? 'white' : layer.colorText,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-2.5 h-2.5 rounded-full"
|
||||
style={{ background: layer.colorBorder }}
|
||||
/>
|
||||
{layer.name} ({count})
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<Toolbar
|
||||
layerFilter={layerFilter}
|
||||
showDb={showDb}
|
||||
showRag={showRag}
|
||||
showApis={showApis}
|
||||
onLayerFilter={handleLayerFilter}
|
||||
onToggleDb={() => setShowDb(v => !v)}
|
||||
onToggleRag={() => setShowRag(v => !v)}
|
||||
onToggleApis={() => setShowApis(v => !v)}
|
||||
/>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-6 bg-slate-200 mx-1" />
|
||||
<ArchCanvas
|
||||
layerFilter={layerFilter}
|
||||
showDb={showDb}
|
||||
showRag={showRag}
|
||||
showApis={showApis}
|
||||
selectedService={selectedService}
|
||||
setSelectedService={setSelectedService}
|
||||
/>
|
||||
|
||||
{/* Toggles */}
|
||||
<button
|
||||
onClick={() => setShowDb(!showDb)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
showDb
|
||||
? 'bg-slate-700 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
DB-Tabellen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRag(!showRag)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
showRag
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-green-50 text-green-700 hover:bg-green-100'
|
||||
}`}
|
||||
>
|
||||
RAG-Collections
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowApis(!showApis)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
showApis
|
||||
? 'bg-violet-600 text-white'
|
||||
: 'bg-violet-50 text-violet-700 hover:bg-violet-100'
|
||||
}`}
|
||||
>
|
||||
API-Endpunkte
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flow Canvas + Detail Panel */}
|
||||
<div className="flex bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden" style={{ height: '700px' }}>
|
||||
{/* Canvas */}
|
||||
<div className="flex-1 relative">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.15 }}
|
||||
attributionPosition="bottom-left"
|
||||
>
|
||||
<Controls />
|
||||
<MiniMap
|
||||
nodeColor={node => {
|
||||
if (node.id.startsWith('db-')) return '#94a3b8'
|
||||
if (node.id.startsWith('rag-')) return '#22c55e'
|
||||
if (node.id.startsWith('api-')) return '#c4b5fd'
|
||||
const svc = ARCH_SERVICES.find(s => s.id === node.id)
|
||||
return svc ? LAYERS[svc.layer].colorBorder : '#94a3b8'
|
||||
}}
|
||||
maskColor="rgba(0,0,0,0.08)"
|
||||
/>
|
||||
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
||||
|
||||
{/* Legende */}
|
||||
<Panel
|
||||
position="bottom-right"
|
||||
className="bg-white/95 p-3 rounded-lg shadow-lg text-xs"
|
||||
>
|
||||
<div className="font-medium text-slate-700 mb-2">Legende</div>
|
||||
<div className="space-y-1">
|
||||
{LAYER_ORDER.map(layerId => {
|
||||
const layer = LAYERS[layerId]
|
||||
return (
|
||||
<div key={layerId} className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-3 h-3 rounded"
|
||||
style={{
|
||||
background: layer.colorBg,
|
||||
border: `1px solid ${layer.colorBorder}`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-slate-600">{layer.name}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="border-t border-slate-200 my-1.5 pt-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded bg-slate-200 border border-slate-400" />
|
||||
<span className="text-slate-500">DB-Tabelle</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="w-3 h-3 rounded bg-green-100 border border-green-500" />
|
||||
<span className="text-slate-500">RAG-Collection</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="w-3 h-3 rounded bg-violet-100 border border-violet-400" />
|
||||
<span className="text-slate-500">API-Endpunkt</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Swim-Lane Labels */}
|
||||
{layerFilter === 'alle' && (
|
||||
<Panel position="top-left" className="pointer-events-none">
|
||||
<div className="space-y-1">
|
||||
{LAYER_ORDER.map(layerId => {
|
||||
const layer = LAYERS[layerId]
|
||||
return (
|
||||
<div
|
||||
key={layerId}
|
||||
className="px-3 py-1 rounded text-xs font-medium opacity-50"
|
||||
style={{
|
||||
background: layer.colorBg,
|
||||
color: layer.colorText,
|
||||
border: `1px solid ${layer.colorBorder}`,
|
||||
}}
|
||||
>
|
||||
{layer.name}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* Detail Panel */}
|
||||
{selectedService && (
|
||||
<DetailPanel
|
||||
service={selectedService}
|
||||
onClose={() => setSelectedService(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Service Table (aufklappbar) */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 border-b">
|
||||
<h3 className="font-medium text-slate-700">
|
||||
Alle Services ({
|
||||
layerFilter === 'alle'
|
||||
? ARCH_SERVICES.length
|
||||
: `${ARCH_SERVICES.filter(s => s.layer === layerFilter).length} / ${ARCH_SERVICES.length}`
|
||||
})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="divide-y max-h-[600px] overflow-y-auto">
|
||||
{ARCH_SERVICES.filter(
|
||||
s => layerFilter === 'alle' || s.layer === layerFilter
|
||||
).map(service => {
|
||||
const layer = LAYERS[service.layer]
|
||||
const isExpanded = expandedServices.has(service.id)
|
||||
|
||||
return (
|
||||
<div key={service.id}>
|
||||
{/* Row Header */}
|
||||
<button
|
||||
onClick={() => toggleExpanded(service.id)}
|
||||
className={`w-full flex items-center gap-3 p-3 text-left transition-colors ${
|
||||
isExpanded
|
||||
? 'bg-purple-50'
|
||||
: 'hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{/* Chevron */}
|
||||
<svg
|
||||
className={`w-4 h-4 text-slate-400 shrink-0 transition-transform duration-200 ${
|
||||
isExpanded ? 'rotate-90' : ''
|
||||
}`}
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[10px] font-bold shrink-0"
|
||||
style={{ background: layer.colorBg, color: layer.colorText }}
|
||||
>
|
||||
{service.port ? `:${service.port}` : '--'}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-800 text-sm">
|
||||
{service.name}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 flex items-center gap-2 mt-0.5">
|
||||
<span className="text-slate-400">{service.tech}</span>
|
||||
{service.dbTables.length > 0 && (
|
||||
<span className="text-slate-400">
|
||||
DB: {service.dbTables.length}
|
||||
</span>
|
||||
)}
|
||||
{service.ragCollections.length > 0 && (
|
||||
<span className="text-green-600">
|
||||
RAG: {service.ragCollections.length}
|
||||
</span>
|
||||
)}
|
||||
{service.apiEndpoints.length > 0 && (
|
||||
<span className="text-violet-600">
|
||||
API: {service.apiEndpoints.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<code className="text-[10px] text-slate-400 shrink-0 hidden sm:block">
|
||||
{service.container}
|
||||
</code>
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-[10px] font-medium shrink-0"
|
||||
style={{ background: layer.colorBg, color: layer.colorText }}
|
||||
>
|
||||
{layer.name}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded Detail */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 pb-4 pt-1 bg-slate-50/50 border-t border-slate-100">
|
||||
{/* Beschreibung */}
|
||||
<p className="text-sm text-slate-700 leading-relaxed">
|
||||
{service.description}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 leading-relaxed mt-1 mb-3">
|
||||
{service.descriptionLong}
|
||||
</p>
|
||||
|
||||
{/* Info Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-3">
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
|
||||
<div className="text-[10px] font-semibold text-slate-400 uppercase">Tech</div>
|
||||
<div className="text-sm font-medium text-slate-800 mt-0.5">{service.tech}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
|
||||
<div className="text-[10px] font-semibold text-slate-400 uppercase">Port</div>
|
||||
<div className="text-sm font-medium text-slate-800 mt-0.5">
|
||||
{service.port ? `:${service.port}` : 'Intern'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
|
||||
<div className="text-[10px] font-semibold text-slate-400 uppercase">Container</div>
|
||||
<div className="text-xs font-mono text-slate-700 mt-0.5 truncate">{service.container}</div>
|
||||
</div>
|
||||
{service.url && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-2.5">
|
||||
<div className="text-[10px] font-semibold text-slate-400 uppercase">URL</div>
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:underline mt-0.5 block truncate"
|
||||
>
|
||||
{service.url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{/* DB Tables */}
|
||||
{service.dbTables.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3">
|
||||
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
|
||||
DB-Tabellen ({service.dbTables.length})
|
||||
</h4>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{service.dbTables.map(table => (
|
||||
<div key={table} className="bg-slate-50 rounded px-2 py-1">
|
||||
<code className="text-xs text-slate-700">{table}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RAG Collections */}
|
||||
{service.ragCollections.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3">
|
||||
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
|
||||
RAG-Collections ({service.ragCollections.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.ragCollections.map(rag => (
|
||||
<div key={rag} className="bg-green-50 rounded px-2 py-1">
|
||||
<code className="text-xs text-green-700">{rag}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Endpoints */}
|
||||
{service.apiEndpoints.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3">
|
||||
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
|
||||
API-Endpunkte ({service.apiEndpoints.length})
|
||||
</h4>
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto">
|
||||
{service.apiEndpoints.map(ep => (
|
||||
<div key={ep} className="bg-violet-50 rounded px-2 py-1">
|
||||
<code className="text-xs text-violet-700">{ep}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dependencies */}
|
||||
{service.dependsOn.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3">
|
||||
<h4 className="text-[10px] font-semibold text-slate-500 uppercase mb-2">
|
||||
Abhaengigkeiten ({service.dependsOn.length})
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{service.dependsOn.map(depId => {
|
||||
const dep = ARCH_SERVICES.find(s => s.id === depId)
|
||||
const depLayer = dep ? LAYERS[dep.layer] : null
|
||||
return (
|
||||
<div key={depId} className="flex items-center gap-2 bg-slate-50 rounded px-2 py-1">
|
||||
{depLayer && (
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{ background: depLayer.colorBorder }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-slate-700">{dep?.name || depId}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Open in Graph + URL */}
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedService(service)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}}
|
||||
className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg text-xs font-medium hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
Im Graph markieren
|
||||
</button>
|
||||
{service.url && (
|
||||
<a
|
||||
href={service.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1.5 bg-purple-600 text-white rounded-lg text-xs font-medium hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Service oeffnen
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<ServiceTable
|
||||
layerFilter={layerFilter}
|
||||
expandedServices={expandedServices}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
onMarkInGraph={setSelectedService}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { CompanyProfile, BUSINESS_MODEL_LABELS, COMPANY_SIZE_LABELS, TARGET_MARKET_LABELS } from '@/lib/sdk/types'
|
||||
import { LEGAL_FORM_LABELS } from './constants'
|
||||
|
||||
export function ProfileSummary({
|
||||
formData,
|
||||
onEdit,
|
||||
onContinue,
|
||||
}: {
|
||||
formData: Partial<CompanyProfile>
|
||||
onEdit: () => void
|
||||
onContinue: () => void
|
||||
}) {
|
||||
const summaryItems = [
|
||||
{ label: 'Firmenname', value: formData.companyName },
|
||||
{ label: 'Rechtsform', value: formData.legalForm ? LEGAL_FORM_LABELS[formData.legalForm] : undefined },
|
||||
{ label: 'Branche', value: formData.industry?.join(', ') },
|
||||
{ label: 'Geschaeftsmodell', value: formData.businessModel ? BUSINESS_MODEL_LABELS[formData.businessModel]?.short : undefined },
|
||||
{ label: 'Unternehmensgroesse', value: formData.companySize ? COMPANY_SIZE_LABELS[formData.companySize] : undefined },
|
||||
{ label: 'Mitarbeiter', value: formData.employeeCount },
|
||||
{ label: 'Hauptsitz', value: [formData.headquartersZip, formData.headquartersCity, formData.headquartersCountry === 'DE' ? 'Deutschland' : formData.headquartersCountry].filter(Boolean).join(', ') },
|
||||
{ label: 'Zielmaerkte', value: formData.targetMarkets?.map(m => TARGET_MARKET_LABELS[m] || m).join(', ') },
|
||||
{ label: 'Verantwortlicher', value: formData.isDataController ? 'Ja' : 'Nein' },
|
||||
{ label: 'Auftragsverarbeiter', value: formData.isDataProcessor ? 'Ja' : 'Nein' },
|
||||
{ label: 'DSB', value: formData.dpoName || 'Nicht angegeben' },
|
||||
].filter(item => item.value && item.value.length > 0)
|
||||
|
||||
const missingFields: string[] = []
|
||||
if (!formData.companyName) missingFields.push('Firmenname')
|
||||
if (!formData.legalForm) missingFields.push('Rechtsform')
|
||||
if (!formData.industry || formData.industry.length === 0) missingFields.push('Branche')
|
||||
if (!formData.businessModel) missingFields.push('Geschaeftsmodell')
|
||||
if (!formData.companySize) missingFields.push('Unternehmensgroesse')
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Unternehmensprofil</h1>
|
||||
</div>
|
||||
|
||||
{/* Success Banner */}
|
||||
<div className={`rounded-xl border-2 p-6 mb-6 ${formData.isComplete ? 'bg-green-50 border-green-300' : 'bg-yellow-50 border-yellow-300'}`}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`flex-shrink-0 w-12 h-12 rounded-full flex items-center justify-center ${formData.isComplete ? 'bg-green-200' : 'bg-yellow-200'}`}>
|
||||
<span className="text-2xl">{formData.isComplete ? '\u2713' : '!'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className={`text-xl font-bold ${formData.isComplete ? 'text-green-800' : 'text-yellow-800'}`}>
|
||||
{formData.isComplete ? 'Profil erfolgreich abgeschlossen' : 'Profil unvollstaendig'}
|
||||
</h2>
|
||||
<p className={`mt-1 ${formData.isComplete ? 'text-green-700' : 'text-yellow-700'}`}>
|
||||
{formData.isComplete
|
||||
? 'Alle Angaben wurden gespeichert. Sie koennen jetzt mit der Scope-Analyse fortfahren.'
|
||||
: `Es fehlen noch Angaben: ${missingFields.join(', ')}.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile Summary */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Zusammenfassung</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{summaryItems.map(item => (
|
||||
<div key={item.label} className="flex flex-col">
|
||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wide">{item.label}</span>
|
||||
<span className="text-sm text-gray-900 mt-0.5">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between items-center">
|
||||
<button onClick={onEdit} className="px-6 py-3 text-gray-600 hover:text-gray-900 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
Profil bearbeiten
|
||||
</button>
|
||||
|
||||
{formData.isComplete ? (
|
||||
<button onClick={onContinue} className="px-8 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium">
|
||||
Weiter zu Scope
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={onEdit} className="px-8 py-3 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 font-medium">
|
||||
Fehlende Angaben ergaenzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { CompanyProfile } from '@/lib/sdk/types'
|
||||
import { AISystem, AISystemTemplate } from './types'
|
||||
import { AI_SYSTEM_TEMPLATES } from './ai-system-data'
|
||||
|
||||
export function StepAISystems({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: Partial<CompanyProfile> & { aiSystems?: AISystem[] }
|
||||
onChange: (updates: Record<string, unknown>) => void
|
||||
}) {
|
||||
const aiSystems: AISystem[] = (data as any).aiSystems || []
|
||||
const [expandedSystem, setExpandedSystem] = useState<string | null>(null)
|
||||
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set())
|
||||
|
||||
const activeIds = new Set(aiSystems.map(a => a.id))
|
||||
|
||||
const toggleTemplateSystem = (template: AISystemTemplate) => {
|
||||
if (activeIds.has(template.id)) {
|
||||
onChange({ aiSystems: aiSystems.filter(a => a.id !== template.id) })
|
||||
if (expandedSystem === template.id) setExpandedSystem(null)
|
||||
} else {
|
||||
const newSystem: AISystem = {
|
||||
id: template.id, name: template.name, vendor: template.vendor,
|
||||
purpose: template.typicalPurposes.join(', '), purposes: [],
|
||||
processes_personal_data: template.processes_personal_data_likely, isCustom: false,
|
||||
}
|
||||
onChange({ aiSystems: [...aiSystems, newSystem] })
|
||||
setExpandedSystem(template.id)
|
||||
}
|
||||
}
|
||||
|
||||
const updateAISystem = (id: string, updates: Partial<AISystem>) => {
|
||||
onChange({ aiSystems: aiSystems.map(a => a.id === id ? { ...a, ...updates } : a) })
|
||||
}
|
||||
|
||||
const togglePurpose = (systemId: string, purpose: string) => {
|
||||
const system = aiSystems.find(a => a.id === systemId)
|
||||
if (!system) return
|
||||
const purposes = system.purposes || []
|
||||
const updated = purposes.includes(purpose) ? purposes.filter(p => p !== purpose) : [...purposes, purpose]
|
||||
updateAISystem(systemId, { purposes: updated, purpose: updated.join(', ') })
|
||||
}
|
||||
|
||||
const addCustomSystem = () => {
|
||||
const id = `custom_ai_${Date.now()}`
|
||||
onChange({ aiSystems: [...aiSystems, { id, name: '', vendor: '', purpose: '', processes_personal_data: false, isCustom: true }] })
|
||||
setExpandedSystem(id)
|
||||
}
|
||||
|
||||
const removeSystem = (id: string) => {
|
||||
onChange({ aiSystems: aiSystems.filter(a => a.id !== id) })
|
||||
if (expandedSystem === id) setExpandedSystem(null)
|
||||
}
|
||||
|
||||
const toggleCategoryCollapse = (category: string) => {
|
||||
setCollapsedCategories(prev => { const next = new Set(prev); if (next.has(category)) next.delete(category); else next.add(category); return next })
|
||||
}
|
||||
|
||||
const categoryActiveCount = (systems: AISystemTemplate[]) => systems.filter(s => activeIds.has(s.id)).length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-1">KI-Systeme im Einsatz</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Waehlen Sie die KI-Systeme aus, die in Ihrem Unternehmen eingesetzt werden. Dies dient der Erfassung fuer den EU AI Act und die DSGVO-Dokumentation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{AI_SYSTEM_TEMPLATES.map(group => {
|
||||
const isCollapsed = collapsedCategories.has(group.category)
|
||||
const activeCount = categoryActiveCount(group.systems)
|
||||
|
||||
return (
|
||||
<div key={group.category} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<button type="button" onClick={() => toggleCategoryCollapse(group.category)} className="w-full flex items-center gap-3 px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors text-left">
|
||||
<span className="text-base">{group.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900 flex-1">{group.category}</span>
|
||||
{activeCount > 0 && <span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">{activeCount} aktiv</span>}
|
||||
<svg className={`w-4 h-4 text-gray-400 transition-transform ${isCollapsed ? '' : 'rotate-180'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="p-3 space-y-2">
|
||||
{group.systems.map(template => {
|
||||
const isActive = activeIds.has(template.id)
|
||||
const system = aiSystems.find(a => a.id === template.id)
|
||||
const isExpanded = expandedSystem === template.id
|
||||
|
||||
return (
|
||||
<div key={template.id}>
|
||||
<div
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${isActive ? 'border-purple-500 bg-purple-50' : 'border-gray-100 hover:border-purple-300'}`}
|
||||
onClick={() => { if (!isActive) { toggleTemplateSystem(template) } else { setExpandedSystem(isExpanded ? null : template.id) } }}
|
||||
>
|
||||
<input type="checkbox" checked={isActive} onChange={e => { e.stopPropagation(); toggleTemplateSystem(template) }} className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900">{template.name}</div>
|
||||
<p className="text-xs text-gray-500">{template.vendor}</p>
|
||||
</div>
|
||||
{isActive && (
|
||||
<svg className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isActive && isExpanded && system && (
|
||||
<div className="ml-4 mt-2 p-4 bg-gray-50 rounded-lg border border-gray-200 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-2">Einsatzzweck</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{template.typicalPurposes.map(purpose => (
|
||||
<button key={purpose} type="button" onClick={() => togglePurpose(template.id, purpose)}
|
||||
className={`px-3 py-1.5 text-xs rounded-full border transition-all ${(system.purposes || []).includes(purpose) ? 'bg-purple-100 border-purple-300 text-purple-700' : 'bg-white border-gray-200 text-gray-600 hover:border-purple-200'}`}>
|
||||
{purpose}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input type="text" value={system.notes || ''} onChange={e => updateAISystem(template.id, { notes: e.target.value })} placeholder="Weitere Einsatzzwecke / Anmerkungen..." className="mt-2 w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
|
||||
{template.dataWarning && (
|
||||
<div className={`flex items-start gap-2 px-3 py-2 rounded-lg ${template.dataWarning.includes('EU') || template.dataWarning.includes('Deutscher Anbieter') || template.dataWarning.includes('NICHT') ? 'bg-blue-50 border border-blue-200' : 'bg-amber-50 border border-amber-200'}`}>
|
||||
<span className="text-sm mt-0.5">{template.dataWarning.includes('EU') || template.dataWarning.includes('Deutscher Anbieter') ? '\u2139\uFE0F' : '\u26A0\uFE0F'}</span>
|
||||
<span className="text-xs text-gray-800">{template.dataWarning}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 px-1 cursor-pointer">
|
||||
<input type="checkbox" checked={system.processes_personal_data} onChange={e => updateAISystem(template.id, { processes_personal_data: e.target.checked })} className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500" />
|
||||
<span className="text-sm text-gray-700">Verarbeitet personenbezogene Daten</span>
|
||||
</label>
|
||||
|
||||
<button type="button" onClick={() => removeSystem(template.id)} className="text-xs text-red-500 hover:text-red-700">KI-System entfernen</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{aiSystems.filter(a => a.isCustom).map(system => (
|
||||
<div key={system.id} className="mt-2">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border-2 border-purple-500 bg-purple-50 cursor-pointer" onClick={() => setExpandedSystem(expandedSystem === system.id ? null : system.id)}>
|
||||
<span className="w-4 h-4 flex items-center justify-center text-purple-600 flex-shrink-0 text-sm">+</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900">{system.name || 'Neues KI-System'}</span>
|
||||
{system.vendor && <span className="text-xs text-gray-500 ml-2">({system.vendor})</span>}
|
||||
</div>
|
||||
<svg className={`w-4 h-4 text-gray-400 transition-transform ${expandedSystem === system.id ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
{expandedSystem === system.id && (
|
||||
<div className="ml-4 mt-2 p-4 bg-gray-50 rounded-lg border border-gray-200 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input type="text" value={system.name} onChange={e => updateAISystem(system.id, { name: e.target.value })} placeholder="Name (z.B. ChatGPT, Copilot)" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
<input type="text" value={system.vendor} onChange={e => updateAISystem(system.id, { vendor: e.target.value })} placeholder="Anbieter (z.B. OpenAI, Microsoft)" className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
<input type="text" value={system.purpose} onChange={e => updateAISystem(system.id, { purpose: e.target.value })} placeholder="Einsatzzweck (z.B. Kundensupport, Code-Assistenz)" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
<label className="flex items-center gap-2 px-1 cursor-pointer">
|
||||
<input type="checkbox" checked={system.processes_personal_data} onChange={e => updateAISystem(system.id, { processes_personal_data: e.target.checked })} className="w-4 h-4 text-purple-600 rounded focus:ring-purple-500" />
|
||||
<span className="text-sm text-gray-700">Verarbeitet personenbezogene Daten</span>
|
||||
</label>
|
||||
<button type="button" onClick={() => removeSystem(system.id)} className="text-xs text-red-500 hover:text-red-700">KI-System entfernen</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button type="button" onClick={addCustomSystem} className="w-full mt-3 px-3 py-2 text-sm text-purple-700 bg-purple-50 border-2 border-dashed border-purple-200 rounded-lg hover:bg-purple-100 hover:border-purple-300 transition-colors">
|
||||
+ Eigenes KI-System hinzufuegen
|
||||
</button>
|
||||
|
||||
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-lg">{'\u2139\uFE0F'}</span>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-blue-900 mb-1">AI Act Risikoeinstufung</h4>
|
||||
<p className="text-xs text-blue-800 mb-3">
|
||||
Die detaillierte Risikoeinstufung Ihrer KI-Systeme nach EU AI Act (verboten / hochriskant / begrenzt / minimal) erfolgt automatisch im AI-Act-Modul.
|
||||
</p>
|
||||
<a href="/sdk/ai-act" className="inline-flex items-center gap-1 text-sm font-medium text-blue-700 hover:text-blue-900">
|
||||
Zum AI-Act-Modul
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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 },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,139 @@
|
||||
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' },
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
'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
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export function ApiGdprProcessEditor({
|
||||
process,
|
||||
saving,
|
||||
onSave,
|
||||
}: {
|
||||
process: { id: string; process_key: string; title: string; description: string; legal_basis: string; retention_days: number; is_active: boolean }
|
||||
saving: boolean
|
||||
onSave: (title: string, description: string) => void
|
||||
}) {
|
||||
const [title, setTitle] = useState(process.title)
|
||||
const [description, setDescription] = useState(process.description || '')
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="border border-slate-200 rounded-xl bg-white overflow-hidden">
|
||||
<div className="p-4 flex items-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-purple-100 text-purple-700 rounded-lg flex items-center justify-center flex-shrink-0 font-mono text-xs font-bold">
|
||||
{process.legal_basis?.replace('Art. ', '').replace(' DSGVO', '') || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-900">{title}</h4>
|
||||
<p className="text-sm text-slate-500">{description || 'Keine Beschreibung'}</p>
|
||||
{process.retention_days && (
|
||||
<span className="text-xs text-slate-400">Aufbewahrung: {process.retention_days} Tage</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400 flex-shrink-0"
|
||||
>
|
||||
{expanded ? 'Schliessen' : 'Bearbeiten'}
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="border-t border-slate-200 p-4 space-y-3 bg-slate-50">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-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-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-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-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => onSave(title, description)}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg font-medium disabled:opacity-60"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export function ApiTemplateEditor({
|
||||
template,
|
||||
saving,
|
||||
onSave,
|
||||
onPreview,
|
||||
}: {
|
||||
template: { id: string; template_key: string; subject: string; body: string; language: string; is_active: boolean }
|
||||
saving: boolean
|
||||
onSave: (subject: string, body: string) => void
|
||||
onPreview: (subject: string, body: string) => void
|
||||
}) {
|
||||
const [subject, setSubject] = useState(template.subject)
|
||||
const [body, setBody] = useState(template.body)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="border border-slate-200 rounded-lg bg-white overflow-hidden">
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${template.is_active ? 'bg-green-400' : 'bg-slate-300'}`} />
|
||||
<div>
|
||||
<span className="font-medium text-slate-900 font-mono text-sm">{template.template_key}</span>
|
||||
<p className="text-sm text-slate-500 truncate max-w-xs">{subject}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs uppercase">{template.language}</span>
|
||||
<button
|
||||
onClick={() => onPreview(subject, body)}
|
||||
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400"
|
||||
>
|
||||
Vorschau
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400"
|
||||
>
|
||||
{expanded ? 'Schliessen' : 'Bearbeiten'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="border-t border-slate-200 p-4 space-y-3 bg-slate-50">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-700 mb-1">Betreff</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-700 mb-1">Inhalt</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg p-3 border border-slate-200">
|
||||
<div className="text-xs font-medium text-slate-500 mb-1">Verfuegbare Platzhalter:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['{{name}}', '{{email}}', '{{date}}', '{{deadline}}', '{{company}}'].map(v => (
|
||||
<span key={v} className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs font-mono">{v}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => onSave(subject, body)}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg font-medium disabled:opacity-60"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export function ConsentTemplateCreateModal({
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [templateKey, setTemplateKey] = useState('')
|
||||
const [subject, setSubject] = useState('')
|
||||
const [body, setBody] = useState('')
|
||||
const [language, setLanguage] = useState('de')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSave() {
|
||||
if (!templateKey.trim()) {
|
||||
setError('Template-Key ist erforderlich.')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/consent-templates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
template_key: templateKey.trim(),
|
||||
subject: subject.trim(),
|
||||
body: body.trim(),
|
||||
language,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.detail || data.message || `Fehler: ${res.status}`)
|
||||
}
|
||||
onSuccess()
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||
} 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-2xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">Neue E-Mail Vorlage</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-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">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Template-Key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={templateKey}
|
||||
onChange={e => setTemplateKey(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="z.B. dsr_confirmation"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Sprache</label>
|
||||
<select
|
||||
value={language}
|
||||
onChange={e => setLanguage(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Betreff</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
placeholder="E-Mail Betreff"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Inhalt</label>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={e => setBody(e.target.value)}
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-none"
|
||||
placeholder="E-Mail Inhalt..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-slate-200 flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Vorlage erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import type { Document, Tab } from '../_types'
|
||||
|
||||
export function DocumentsTab({
|
||||
loading,
|
||||
documents,
|
||||
setSelectedDocument,
|
||||
setActiveTab,
|
||||
}: {
|
||||
loading: boolean
|
||||
documents: Document[]
|
||||
setSelectedDocument: (id: string) => void
|
||||
setActiveTab: (t: Tab) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Dokumente verwalten</h2>
|
||||
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
|
||||
+ Neues Dokument
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-slate-500">Lade Dokumente...</div>
|
||||
) : documents.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
Keine Dokumente vorhanden
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Typ</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Name</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Beschreibung</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Pflicht</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-slate-500">Erstellt</th>
|
||||
<th className="text-right py-3 px-4 text-sm font-medium text-slate-500">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{documents.map((doc) => (
|
||||
<tr key={doc.id} className="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td className="py-3 px-4">
|
||||
<span className="px-2 py-1 bg-slate-100 text-slate-700 rounded text-xs font-medium">
|
||||
{doc.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-medium text-slate-900">{doc.name}</td>
|
||||
<td className="py-3 px-4 text-slate-600 text-sm">{doc.description}</td>
|
||||
<td className="py-3 px-4">
|
||||
{doc.mandatory ? (
|
||||
<span className="text-green-600">Ja</span>
|
||||
) : (
|
||||
<span className="text-slate-400">Nein</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-slate-500">
|
||||
{new Date(doc.created_at).toLocaleDateString('de-DE')}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-right">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedDocument(doc.id)
|
||||
setActiveTab('versions')
|
||||
}}
|
||||
className="text-purple-600 hover:text-purple-700 text-sm font-medium mr-3"
|
||||
>
|
||||
Versionen
|
||||
</button>
|
||||
<button className="text-slate-500 hover:text-slate-700 text-sm">
|
||||
Bearbeiten
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import type { EmailTemplateData } from '../_types'
|
||||
|
||||
export function EmailTemplateEditModal({
|
||||
editingTemplate,
|
||||
onChange,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
editingTemplate: EmailTemplateData
|
||||
onChange: (tpl: EmailTemplateData) => void
|
||||
onClose: () => void
|
||||
onSave: (tpl: EmailTemplateData) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">E-Mail Vorlage bearbeiten</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-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-slate-700 mb-1">Betreff</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingTemplate.subject}
|
||||
onChange={(e) => onChange({ ...editingTemplate, subject: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Inhalt</label>
|
||||
<textarea
|
||||
value={editingTemplate.body}
|
||||
onChange={(e) => onChange({ ...editingTemplate, body: e.target.value })}
|
||||
rows={12}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<div className="text-xs font-medium text-slate-500 mb-1">Verfuegbare Platzhalter:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['{{name}}', '{{email}}', '{{referenceNumber}}', '{{date}}', '{{deadline}}', '{{company}}'].map(v => (
|
||||
<span key={v} className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs font-mono">{v}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-slate-200 flex justify-end gap-3">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-lg">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSave(editingTemplate)}
|
||||
className="px-4 py-2 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg font-medium"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmailTemplatePreviewModal({
|
||||
previewTemplate,
|
||||
onClose,
|
||||
}: {
|
||||
previewTemplate: EmailTemplateData
|
||||
onClose: () => void
|
||||
}) {
|
||||
const substitute = (text: string) =>
|
||||
text
|
||||
.replace(/\{\{name\}\}/g, 'Max Mustermann')
|
||||
.replace(/\{\{email\}\}/g, 'max@example.de')
|
||||
.replace(/\{\{referenceNumber\}\}/g, 'DSR-2025-000001')
|
||||
.replace(/\{\{date\}\}/g, new Date().toLocaleDateString('de-DE'))
|
||||
.replace(/\{\{deadline\}\}/g, '30 Tage')
|
||||
.replace(/\{\{company\}\}/g, 'BreakPilot GmbH')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||
<h3 className="font-semibold text-slate-900">Vorschau</h3>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-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 className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="text-xs text-slate-500 mb-1">Betreff:</div>
|
||||
<div className="font-medium text-slate-900">
|
||||
{substitute(previewTemplate.subject)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-slate-200 rounded-lg p-4 whitespace-pre-wrap text-sm text-slate-700">
|
||||
{substitute(previewTemplate.body)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-slate-200 flex justify-end">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-slate-600 hover:bg-slate-100 rounded-lg">
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import type { ApiEmailTemplate, EmailTemplateData } from '../_types'
|
||||
import { emailTemplates, emailCategories } from '../_data'
|
||||
import { ApiTemplateEditor } from './ApiTemplateEditor'
|
||||
|
||||
export function EmailsTab({
|
||||
apiEmailTemplates,
|
||||
templatesLoading,
|
||||
savingTemplateId,
|
||||
savedTemplates,
|
||||
setShowCreateTemplateModal,
|
||||
saveApiEmailTemplate,
|
||||
setPreviewTemplate,
|
||||
setEditingTemplate,
|
||||
}: {
|
||||
apiEmailTemplates: ApiEmailTemplate[]
|
||||
templatesLoading: boolean
|
||||
savingTemplateId: string | null
|
||||
savedTemplates: Record<string, EmailTemplateData>
|
||||
setShowCreateTemplateModal: (v: boolean) => void
|
||||
saveApiEmailTemplate: (t: { id: string; subject: string; body: string }) => void
|
||||
setPreviewTemplate: (t: EmailTemplateData | null) => void
|
||||
setEditingTemplate: (t: EmailTemplateData | null) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">E-Mail Vorlagen</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
{apiEmailTemplates.length > 0
|
||||
? `${apiEmailTemplates.length} DSGVO-Vorlagen aus der Datenbank`
|
||||
: '16 Lifecycle-Vorlagen fuer automatisierte Kommunikation'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateTemplateModal(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
+ Neue Vorlage
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* API-backed templates section */}
|
||||
{templatesLoading ? (
|
||||
<div className="text-center py-8 text-slate-500">Lade Vorlagen aus der Datenbank...</div>
|
||||
) : apiEmailTemplates.length > 0 ? (
|
||||
<div className="space-y-4 mb-8">
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-3">DSGVO-Pflichtvorlagen</h3>
|
||||
{apiEmailTemplates.map((template) => (
|
||||
<ApiTemplateEditor
|
||||
key={template.id}
|
||||
template={template}
|
||||
saving={savingTemplateId === template.id}
|
||||
onSave={(subject, body) => saveApiEmailTemplate({ id: template.id, subject, body })}
|
||||
onPreview={(subject, body) => setPreviewTemplate({ key: template.template_key, subject, body })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Category Filter for static templates */}
|
||||
{apiEmailTemplates.length === 0 && (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
<span className="text-sm text-slate-500 py-1">Filter:</span>
|
||||
{emailCategories.map((cat) => (
|
||||
<span key={cat.key} className={`px-3 py-1 rounded-full text-xs font-medium ${cat.color}`}>
|
||||
{cat.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Templates grouped by category (fallback when no API data) */}
|
||||
{emailCategories.map((category) => (
|
||||
<div key={category.key} className="mb-8">
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-3 flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${category.color.split(' ')[0]}`}></span>
|
||||
{category.label}
|
||||
</h3>
|
||||
<div className="grid gap-3">
|
||||
{emailTemplates
|
||||
.filter((t) => t.category === category.key)
|
||||
.map((template) => (
|
||||
<div
|
||||
key={template.key}
|
||||
className="border border-slate-200 rounded-lg p-4 flex items-center justify-between hover:border-purple-300 transition-colors bg-white"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-10 h-10 rounded-lg ${category.color} flex items-center justify-center text-lg`}>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-900">{template.name}</h4>
|
||||
<p className="text-sm text-slate-500">{template.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">
|
||||
{savedTemplates[template.key] ? 'Angepasst' : 'Aktiv'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const existing = savedTemplates[template.key]
|
||||
setEditingTemplate({
|
||||
key: template.key,
|
||||
subject: existing?.subject || `Betreff: ${template.name}`,
|
||||
body: existing?.body || `Sehr geehrte(r) {{name}},\n\n${template.description}\n\nMit freundlichen Gruessen\nIhr Datenschutz-Team`,
|
||||
})
|
||||
}}
|
||||
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const existing = savedTemplates[template.key]
|
||||
setPreviewTemplate({
|
||||
key: template.key,
|
||||
subject: existing?.subject || `Betreff: ${template.name}`,
|
||||
body: existing?.body || `Sehr geehrte(r) {{name}},\n\n${template.description}\n\nMit freundlichen Gruessen\nIhr Datenschutz-Team`,
|
||||
})
|
||||
}}
|
||||
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400"
|
||||
>
|
||||
Vorschau
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { ApiGdprProcess, DsrOverview } from '../_types'
|
||||
import { gdprProcesses } from '../_data'
|
||||
import { ApiGdprProcessEditor } from './ApiGdprProcessEditor'
|
||||
|
||||
export function GdprTab({
|
||||
router,
|
||||
apiGdprProcesses,
|
||||
gdprLoading,
|
||||
savingProcessId,
|
||||
saveApiGdprProcess,
|
||||
dsrCounts,
|
||||
dsrOverview,
|
||||
}: {
|
||||
router: { push: (path: string) => void }
|
||||
apiGdprProcesses: ApiGdprProcess[]
|
||||
gdprLoading: boolean
|
||||
savingProcessId: string | null
|
||||
saveApiGdprProcess: (p: { id: string; title: string; description: string }) => void
|
||||
dsrCounts: Record<string, number>
|
||||
dsrOverview: DsrOverview
|
||||
}) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">DSGVO Betroffenenrechte</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">Artikel 15-21 Prozesse und Vorlagen</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/sdk/dsr')}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
+ DSR Anfrage erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">*</span>
|
||||
<div>
|
||||
<h4 className="font-medium text-purple-900">Data Subject Rights (DSR)</h4>
|
||||
<p className="text-sm text-purple-700 mt-1">
|
||||
Hier verwalten Sie alle DSGVO-Anfragen. Jeder Artikel hat definierte Prozesse, SLAs und automatisierte Workflows.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API-backed GDPR Processes */}
|
||||
{gdprLoading ? (
|
||||
<div className="text-center py-8 text-slate-500">Lade DSGVO-Prozesse...</div>
|
||||
) : apiGdprProcesses.length > 0 ? (
|
||||
<div className="space-y-4 mb-8">
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-3">Konfigurierte Prozesse</h3>
|
||||
{apiGdprProcesses.map((process) => (
|
||||
<ApiGdprProcessEditor
|
||||
key={process.id}
|
||||
process={process}
|
||||
saving={savingProcessId === process.id}
|
||||
onSave={(title, description) => saveApiGdprProcess({ id: process.id, title, description })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Static GDPR Process Cards (always shown as reference) */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-3">DSGVO Artikel-Uebersicht</h3>
|
||||
{gdprProcesses.map((process) => (
|
||||
<div
|
||||
key={process.article}
|
||||
className="border border-slate-200 rounded-xl p-5 hover:border-purple-300 transition-colors bg-white"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-purple-100 text-purple-700 rounded-lg flex items-center justify-center font-bold text-lg">
|
||||
{process.article}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-slate-900">{process.title}</h3>
|
||||
<span className="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">Aktiv</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mb-3">{process.description}</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{process.actions.map((action, idx) => (
|
||||
<span key={idx} className="px-2 py-1 bg-slate-100 text-slate-600 rounded text-xs">
|
||||
{action}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* SLA */}
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-slate-500">
|
||||
SLA: <span className="font-medium text-slate-700">{process.sla}</span>
|
||||
</span>
|
||||
<span className="text-slate-300">|</span>
|
||||
<span className="text-slate-500">
|
||||
Offene Anfragen: <span className={`font-medium ${(dsrCounts[process.article] || 0) > 0 ? 'text-orange-600' : 'text-slate-700'}`}>{dsrCounts[process.article] || 0}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link
|
||||
href={`/sdk/dsr?type=${process.article === '15' ? 'access' : process.article === '16' ? 'rectification' : process.article === '17' ? 'erasure' : process.article === '18' ? 'restriction' : process.article === '20' ? 'portability' : 'objection'}`}
|
||||
className="px-3 py-1.5 text-sm text-white bg-purple-600 hover:bg-purple-700 rounded-lg text-center"
|
||||
>
|
||||
Anfragen
|
||||
</Link>
|
||||
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
|
||||
Vorlage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* DSR Request Statistics */}
|
||||
<div className="mt-8 pt-6 border-t border-slate-200">
|
||||
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wider mb-4">DSR Uebersicht</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-slate-50 rounded-lg p-4 text-center">
|
||||
<div className={`text-2xl font-bold ${dsrOverview.open > 0 ? 'text-blue-600' : 'text-slate-900'}`}>{dsrOverview.open}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">Offen</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-700">{dsrOverview.completed}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">Erledigt</div>
|
||||
</div>
|
||||
<div className="bg-yellow-50 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-yellow-700">{dsrOverview.in_progress}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">In Bearbeitung</div>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-4 text-center">
|
||||
<div className={`text-2xl font-bold ${dsrOverview.overdue > 0 ? 'text-red-700' : 'text-slate-400'}`}>{dsrOverview.overdue}</div>
|
||||
<div className="text-xs text-slate-500 mt-1">Ueberfaellig</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import type { ConsentStats } from '../_types'
|
||||
|
||||
export function StatsTab({ consentStats }: { consentStats: ConsentStats }) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-6">Statistiken</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div className="bg-slate-50 rounded-xl p-6">
|
||||
<div className="text-3xl font-bold text-slate-900">{consentStats.activeConsents}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">Aktive Zustimmungen</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-xl p-6">
|
||||
<div className="text-3xl font-bold text-slate-900">{consentStats.documentCount}</div>
|
||||
<div className="text-sm text-slate-500 mt-1">Dokumente</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-xl p-6">
|
||||
<div className={`text-3xl font-bold ${consentStats.openDSRs > 0 ? 'text-orange-600' : 'text-slate-900'}`}>
|
||||
{consentStats.openDSRs}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 mt-1">Offene DSR-Anfragen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-slate-200 rounded-lg p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Zustimmungsrate nach Dokument</h3>
|
||||
<div className="text-center py-8 text-slate-400 text-sm">
|
||||
Diagramm wird in einer zukuenftigen Version verfuegbar sein
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
'use client'
|
||||
|
||||
import type { Document, Version } from '../_types'
|
||||
|
||||
export function VersionsTab({
|
||||
loading,
|
||||
documents,
|
||||
versions,
|
||||
selectedDocument,
|
||||
setSelectedDocument,
|
||||
}: {
|
||||
loading: boolean
|
||||
documents: Document[]
|
||||
versions: Version[]
|
||||
selectedDocument: string
|
||||
setSelectedDocument: (id: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Versionen</h2>
|
||||
<select
|
||||
value={selectedDocument}
|
||||
onChange={(e) => setSelectedDocument(e.target.value)}
|
||||
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">Dokument auswaehlen...</option>
|
||||
{documents.map((doc) => (
|
||||
<option key={doc.id} value={doc.id}>
|
||||
{doc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{selectedDocument && (
|
||||
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
|
||||
+ Neue Version
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedDocument ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
Bitte waehlen Sie ein Dokument aus
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="text-center py-12 text-slate-500">Lade Versionen...</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
Keine Versionen vorhanden
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{versions.map((version) => (
|
||||
<div
|
||||
key={version.id}
|
||||
className="border border-slate-200 rounded-lg p-4 hover:border-purple-300 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-slate-900">v{version.version}</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">
|
||||
{version.language.toUpperCase()}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs ${
|
||||
version.status === 'published'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: version.status === 'draft'
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{version.status}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-slate-700">{version.title}</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Erstellt: {new Date(version.created_at).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
|
||||
Bearbeiten
|
||||
</button>
|
||||
{version.status === 'draft' && (
|
||||
<button className="px-3 py-1.5 text-sm text-white bg-green-600 hover:bg-green-700 rounded-lg">
|
||||
Veroeffentlichen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
80
admin-compliance/app/sdk/consent-management/_data.ts
Normal file
80
admin-compliance/app/sdk/consent-management/_data.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// 16 Lifecycle Email Templates
|
||||
export const emailTemplates = [
|
||||
{ name: 'Willkommens-E-Mail', key: 'welcome', category: 'onboarding', description: 'Wird bei Registrierung versendet' },
|
||||
{ name: 'E-Mail Bestaetigung', key: 'email_verification', category: 'onboarding', description: 'Bestaetigungslink fuer E-Mail-Adresse' },
|
||||
{ name: 'Konto aktiviert', key: 'account_activated', category: 'onboarding', description: 'Bestaetigung der Kontoaktivierung' },
|
||||
{ name: 'Passwort zuruecksetzen', key: 'password_reset', category: 'security', description: 'Link zum Zuruecksetzen des Passworts' },
|
||||
{ name: 'Passwort geaendert', key: 'password_changed', category: 'security', description: 'Bestaetigung der Passwortaenderung' },
|
||||
{ name: 'Neue Anmeldung', key: 'new_login', category: 'security', description: 'Benachrichtigung ueber Anmeldung von neuem Geraet' },
|
||||
{ name: '2FA aktiviert', key: '2fa_enabled', category: 'security', description: 'Bestaetigung der 2FA-Aktivierung' },
|
||||
{ name: 'Neue Dokumentversion', key: 'new_document_version', category: 'consent', description: 'Info ueber neue Dokumentversion zur Zustimmung' },
|
||||
{ name: 'Zustimmung erteilt', key: 'consent_given', category: 'consent', description: 'Bestaetigung der erteilten Zustimmung' },
|
||||
{ name: 'Zustimmung widerrufen', key: 'consent_withdrawn', category: 'consent', description: 'Bestaetigung des Widerrufs' },
|
||||
{ name: 'DSR Anfrage erhalten', key: 'dsr_received', category: 'gdpr', description: 'Bestaetigung des Eingangs einer DSGVO-Anfrage' },
|
||||
{ name: 'Datenexport bereit', key: 'export_ready', category: 'gdpr', description: 'Benachrichtigung ueber fertigen Datenexport' },
|
||||
{ name: 'Daten geloescht', key: 'data_deleted', category: 'gdpr', description: 'Bestaetigung der Datenloeschung' },
|
||||
{ name: 'Daten berichtigt', key: 'data_rectified', category: 'gdpr', description: 'Bestaetigung der Datenberichtigung' },
|
||||
{ name: 'Konto deaktiviert', key: 'account_deactivated', category: 'lifecycle', description: 'Konto wurde deaktiviert' },
|
||||
{ name: 'Konto geloescht', key: 'account_deleted', category: 'lifecycle', description: 'Bestaetigung der Kontoloeschung' },
|
||||
]
|
||||
|
||||
// GDPR Article 15-21 Processes
|
||||
export const gdprProcesses = [
|
||||
{
|
||||
article: '15',
|
||||
title: 'Auskunftsrecht',
|
||||
description: 'Recht auf Bestaetigung und Auskunft ueber verarbeitete personenbezogene Daten',
|
||||
actions: ['Datenexport generieren', 'Verarbeitungszwecke auflisten', 'Empfaenger auflisten'],
|
||||
sla: '30 Tage',
|
||||
},
|
||||
{
|
||||
article: '16',
|
||||
title: 'Recht auf Berichtigung',
|
||||
description: 'Recht auf Berichtigung unrichtiger personenbezogener Daten',
|
||||
actions: ['Daten bearbeiten', 'Aenderungshistorie fuehren', 'Benachrichtigung senden'],
|
||||
sla: '30 Tage',
|
||||
},
|
||||
{
|
||||
article: '17',
|
||||
title: 'Recht auf Loeschung ("Vergessenwerden")',
|
||||
description: 'Recht auf Loeschung personenbezogener Daten unter bestimmten Voraussetzungen',
|
||||
actions: ['Loeschantrag pruefen', 'Daten loeschen', 'Aufbewahrungsfristen pruefen', 'Loeschbestaetigung senden'],
|
||||
sla: '30 Tage',
|
||||
},
|
||||
{
|
||||
article: '18',
|
||||
title: 'Recht auf Einschraenkung der Verarbeitung',
|
||||
description: 'Recht auf Markierung von Daten zur eingeschraenkten Verarbeitung',
|
||||
actions: ['Daten markieren', 'Verarbeitung einschraenken', 'Benachrichtigung bei Aufhebung'],
|
||||
sla: '30 Tage',
|
||||
},
|
||||
{
|
||||
article: '19',
|
||||
title: 'Mitteilungspflicht',
|
||||
description: 'Pflicht zur Mitteilung von Berichtigung, Loeschung oder Einschraenkung an Empfaenger',
|
||||
actions: ['Empfaenger ermitteln', 'Mitteilungen versenden', 'Protokollierung'],
|
||||
sla: 'Unverzueglich',
|
||||
},
|
||||
{
|
||||
article: '20',
|
||||
title: 'Recht auf Datenuebertragbarkeit',
|
||||
description: 'Recht auf Erhalt der Daten in strukturiertem, maschinenlesbarem Format',
|
||||
actions: ['JSON/CSV Export', 'API-Schnittstelle', 'Direkte Uebertragung'],
|
||||
sla: '30 Tage',
|
||||
},
|
||||
{
|
||||
article: '21',
|
||||
title: 'Widerspruchsrecht',
|
||||
description: 'Recht auf Widerspruch gegen Verarbeitung aus berechtigtem Interesse oder Direktwerbung',
|
||||
actions: ['Widerspruch erfassen', 'Verarbeitung stoppen', 'Marketing-Opt-out'],
|
||||
sla: 'Unverzueglich',
|
||||
},
|
||||
]
|
||||
|
||||
export const emailCategories = [
|
||||
{ key: 'onboarding', label: 'Onboarding', color: 'bg-blue-100 text-blue-700' },
|
||||
{ key: 'security', label: 'Sicherheit', color: 'bg-red-100 text-red-700' },
|
||||
{ key: 'consent', label: 'Zustimmung', color: 'bg-green-100 text-green-700' },
|
||||
{ key: 'gdpr', label: 'DSGVO', color: 'bg-purple-100 text-purple-700' },
|
||||
{ key: 'lifecycle', label: 'Lifecycle', color: 'bg-orange-100 text-orange-700' },
|
||||
]
|
||||
@@ -0,0 +1,291 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { API_BASE } from '../_types'
|
||||
import type {
|
||||
Tab, Document, Version, ApiEmailTemplate, ApiGdprProcess,
|
||||
ConsentStats, DsrOverview, EmailTemplateData,
|
||||
} from '../_types'
|
||||
|
||||
export function useConsentData(activeTab: Tab, selectedDocument: string) {
|
||||
const [documents, setDocuments] = useState<Document[]>([])
|
||||
const [versions, setVersions] = useState<Version[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Stats state
|
||||
const [consentStats, setConsentStats] = useState<ConsentStats>({ activeConsents: 0, documentCount: 0, openDSRs: 0 })
|
||||
|
||||
// GDPR tab state
|
||||
const [dsrCounts, setDsrCounts] = useState<Record<string, number>>({})
|
||||
const [dsrOverview, setDsrOverview] = useState<DsrOverview>({ open: 0, completed: 0, in_progress: 0, overdue: 0 })
|
||||
|
||||
// Email template editor state
|
||||
const [savedTemplates, setSavedTemplates] = useState<Record<string, EmailTemplateData>>({})
|
||||
|
||||
// API-backed email templates and GDPR processes
|
||||
const [apiEmailTemplates, setApiEmailTemplates] = useState<ApiEmailTemplate[]>([])
|
||||
const [apiGdprProcesses, setApiGdprProcesses] = useState<ApiGdprProcess[]>([])
|
||||
const [templatesLoading, setTemplatesLoading] = useState(false)
|
||||
const [gdprLoading, setGdprLoading] = useState(false)
|
||||
const [savingTemplateId, setSavingTemplateId] = useState<string | null>(null)
|
||||
const [savingProcessId, setSavingProcessId] = useState<string | null>(null)
|
||||
|
||||
// Auth token (in production, get from auth context)
|
||||
const [authToken, setAuthToken] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('bp_admin_token')
|
||||
if (token) {
|
||||
setAuthToken(token)
|
||||
}
|
||||
// Load saved email templates from localStorage
|
||||
try {
|
||||
const saved = localStorage.getItem('sdk-email-templates')
|
||||
if (saved) {
|
||||
setSavedTemplates(JSON.parse(saved))
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'documents') {
|
||||
loadDocuments()
|
||||
} else if (activeTab === 'versions' && selectedDocument) {
|
||||
loadVersions(selectedDocument)
|
||||
} else if (activeTab === 'stats') {
|
||||
loadStats()
|
||||
} else if (activeTab === 'gdpr') {
|
||||
loadGDPRData()
|
||||
loadApiGdprProcesses()
|
||||
} else if (activeTab === 'emails') {
|
||||
loadApiEmailTemplates()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab, selectedDocument, authToken])
|
||||
|
||||
async function loadApiEmailTemplates() {
|
||||
setTemplatesLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/consent-templates')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setApiEmailTemplates(Array.isArray(data) ? data : [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load email templates from API:', err)
|
||||
} finally {
|
||||
setTemplatesLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApiGdprProcesses() {
|
||||
setGdprLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/consent-templates/gdpr-processes')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setApiGdprProcesses(Array.isArray(data) ? data : [])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load GDPR processes from API:', err)
|
||||
} finally {
|
||||
setGdprLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveApiEmailTemplate(template: { id: string; subject: string; body: string }) {
|
||||
setSavingTemplateId(template.id)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/consent-templates/${template.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subject: template.subject, body: template.body }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const updated = await res.json()
|
||||
setApiEmailTemplates(prev => prev.map(t => t.id === updated.id ? updated : t))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save email template:', err)
|
||||
} finally {
|
||||
setSavingTemplateId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveApiGdprProcess(process: { id: string; title: string; description: string }) {
|
||||
setSavingProcessId(process.id)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/consent-templates/gdpr-processes/${process.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: process.title, description: process.description }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const updated = await res.json()
|
||||
setApiGdprProcesses(prev => prev.map(p => p.id === updated.id ? updated : p))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save GDPR process:', err)
|
||||
} finally {
|
||||
setSavingProcessId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDocuments() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/documents`, {
|
||||
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setDocuments(data.documents || [])
|
||||
} else {
|
||||
const errorData = await res.json().catch(() => ({}))
|
||||
setError(errorData.error || 'Fehler beim Laden der Dokumente')
|
||||
}
|
||||
} catch {
|
||||
setError('Verbindungsfehler zum Server')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVersions(docId: string) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/documents/${docId}/versions`, {
|
||||
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setVersions(data.versions || [])
|
||||
} else {
|
||||
const errorData = await res.json().catch(() => ({}))
|
||||
setError(errorData.error || 'Fehler beim Laden der Versionen')
|
||||
}
|
||||
} catch {
|
||||
setError('Verbindungsfehler zum Server')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const token = localStorage.getItem('bp_admin_token')
|
||||
const [statsRes, docsRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/stats`, {
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
|
||||
}),
|
||||
fetch(`${API_BASE}/documents`, {
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {}
|
||||
}),
|
||||
])
|
||||
|
||||
let activeConsents = 0
|
||||
let documentCount = 0
|
||||
let openDSRs = 0
|
||||
|
||||
if (statsRes.ok) {
|
||||
const statsData = await statsRes.json()
|
||||
activeConsents = statsData.total_consents || statsData.active_consents || 0
|
||||
}
|
||||
|
||||
if (docsRes.ok) {
|
||||
const docsData = await docsRes.json()
|
||||
documentCount = (docsData.documents || []).length
|
||||
}
|
||||
|
||||
// Try to get DSR count
|
||||
try {
|
||||
const dsrRes = await fetch('/api/sdk/v1/compliance/dsr', {
|
||||
headers: {
|
||||
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
|
||||
'X-User-ID': localStorage.getItem('bp_user_id') || '',
|
||||
}
|
||||
})
|
||||
if (dsrRes.ok) {
|
||||
const dsrData = await dsrRes.json()
|
||||
const dsrs = dsrData.dsrs || []
|
||||
openDSRs = dsrs.filter((r: any) => r.status !== 'completed' && r.status !== 'rejected').length
|
||||
}
|
||||
} catch { /* DSR endpoint might not be available */ }
|
||||
|
||||
setConsentStats({ activeConsents, documentCount, openDSRs })
|
||||
} catch (err) {
|
||||
console.error('Failed to load stats:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGDPRData() {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/compliance/dsr', {
|
||||
headers: {
|
||||
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
|
||||
'X-User-ID': localStorage.getItem('bp_user_id') || '',
|
||||
}
|
||||
})
|
||||
if (!res.ok) return
|
||||
|
||||
const data = await res.json()
|
||||
const dsrs = data.dsrs || []
|
||||
const now = new Date()
|
||||
|
||||
// Count per article type
|
||||
const counts: Record<string, number> = {}
|
||||
const typeMapping: Record<string, string> = {
|
||||
'access': '15',
|
||||
'rectification': '16',
|
||||
'erasure': '17',
|
||||
'restriction': '18',
|
||||
'portability': '20',
|
||||
'objection': '21',
|
||||
}
|
||||
|
||||
for (const dsr of dsrs) {
|
||||
if (dsr.status === 'completed' || dsr.status === 'rejected') continue
|
||||
const article = typeMapping[dsr.request_type]
|
||||
if (article) {
|
||||
counts[article] = (counts[article] || 0) + 1
|
||||
}
|
||||
}
|
||||
setDsrCounts(counts)
|
||||
|
||||
// Calculate overview
|
||||
const open = dsrs.filter((r: any) => r.status === 'received' || r.status === 'verified').length
|
||||
const completed = dsrs.filter((r: any) => r.status === 'completed').length
|
||||
const in_progress = dsrs.filter((r: any) => r.status === 'in_progress').length
|
||||
const overdue = dsrs.filter((r: any) => {
|
||||
if (r.status === 'completed' || r.status === 'rejected') return false
|
||||
const deadline = r.extended_deadline_at ? new Date(r.extended_deadline_at) : new Date(r.deadline_at)
|
||||
return deadline < now
|
||||
}).length
|
||||
|
||||
setDsrOverview({ open, completed, in_progress, overdue })
|
||||
} catch (err) {
|
||||
console.error('Failed to load GDPR data:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function saveEmailTemplate(template: EmailTemplateData) {
|
||||
const updated = { ...savedTemplates, [template.key]: template }
|
||||
setSavedTemplates(updated)
|
||||
localStorage.setItem('sdk-email-templates', JSON.stringify(updated))
|
||||
}
|
||||
|
||||
return {
|
||||
documents, versions, loading, error, setError,
|
||||
consentStats, dsrCounts, dsrOverview,
|
||||
savedTemplates, saveEmailTemplate,
|
||||
apiEmailTemplates, apiGdprProcesses,
|
||||
templatesLoading, gdprLoading,
|
||||
savingTemplateId, savingProcessId,
|
||||
saveApiEmailTemplate, saveApiGdprProcess,
|
||||
loadApiEmailTemplates,
|
||||
authToken, setAuthToken,
|
||||
}
|
||||
}
|
||||
63
admin-compliance/app/sdk/consent-management/_types.ts
Normal file
63
admin-compliance/app/sdk/consent-management/_types.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export const API_BASE = '/api/admin/consent'
|
||||
|
||||
export type Tab = 'documents' | 'versions' | 'emails' | 'gdpr' | 'stats'
|
||||
|
||||
export interface Document {
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
mandatory: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Version {
|
||||
id: string
|
||||
document_id: string
|
||||
version: string
|
||||
language: string
|
||||
title: string
|
||||
content: string
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Email template editor types
|
||||
export interface EmailTemplateData {
|
||||
key: string
|
||||
subject: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export interface ApiEmailTemplate {
|
||||
id: string
|
||||
template_key: string
|
||||
subject: string
|
||||
body: string
|
||||
language: string
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface ApiGdprProcess {
|
||||
id: string
|
||||
process_key: string
|
||||
title: string
|
||||
description: string
|
||||
legal_basis: string
|
||||
retention_days: number
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface ConsentStats {
|
||||
activeConsents: number
|
||||
documentCount: number
|
||||
openDSRs: number
|
||||
}
|
||||
|
||||
export interface DsrOverview {
|
||||
open: number
|
||||
completed: number
|
||||
in_progress: number
|
||||
overdue: number
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
import { AlertTriangle, CheckCircle2, Info } from 'lucide-react'
|
||||
|
||||
const SEVERITY_CONFIG: Record<string, { bg: string; label: string; icon: React.ComponentType<{ className?: string }> }> = {
|
||||
critical: { bg: 'bg-red-100 text-red-800', label: 'Kritisch', icon: AlertTriangle },
|
||||
high: { bg: 'bg-orange-100 text-orange-800', label: 'Hoch', icon: AlertTriangle },
|
||||
medium: { bg: 'bg-yellow-100 text-yellow-800', label: 'Mittel', icon: Info },
|
||||
low: { bg: 'bg-green-100 text-green-800', label: 'Niedrig', icon: CheckCircle2 },
|
||||
}
|
||||
|
||||
export function SeverityBadge({ severity }: { severity: string }) {
|
||||
const config = SEVERITY_CONFIG[severity] || SEVERITY_CONFIG.medium
|
||||
const Icon = config.icon
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${config.bg}`}>
|
||||
<Icon className="w-3 h-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function StateBadge({ state }: { state: string }) {
|
||||
const config: Record<string, string> = {
|
||||
draft: 'bg-gray-100 text-gray-600',
|
||||
review: 'bg-blue-100 text-blue-700',
|
||||
approved: 'bg-green-100 text-green-700',
|
||||
deprecated: 'bg-red-100 text-red-600',
|
||||
needs_review: 'bg-yellow-100 text-yellow-800',
|
||||
too_close: 'bg-red-100 text-red-700',
|
||||
duplicate: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
const labels: Record<string, string> = {
|
||||
needs_review: 'Review noetig',
|
||||
too_close: 'Zu aehnlich',
|
||||
duplicate: 'Duplikat',
|
||||
}
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${config[state] || config.draft}`}>
|
||||
{labels[state] || state}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function LicenseRuleBadge({ rule }: { rule: number | null | undefined }) {
|
||||
if (!rule) return null
|
||||
const config: Record<number, { bg: string; label: string }> = {
|
||||
1: { bg: 'bg-green-100 text-green-700', label: 'Free Use' },
|
||||
2: { bg: 'bg-blue-100 text-blue-700', label: 'Zitation' },
|
||||
3: { bg: 'bg-amber-100 text-amber-700', label: 'Reformuliert' },
|
||||
}
|
||||
const c = config[rule]
|
||||
if (!c) return null
|
||||
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${c.bg}`}>{c.label}</span>
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Shield, ArrowLeft, ExternalLink, CheckCircle2, Lock,
|
||||
FileText, BookOpen, Scale, Pencil, Trash2, Eye, Clock,
|
||||
} from 'lucide-react'
|
||||
import type { CanonicalControl } from '../_types'
|
||||
import { EFFORT_LABELS } from '../_types'
|
||||
import { SeverityBadge, StateBadge, LicenseRuleBadge } from './Badges'
|
||||
|
||||
export function ControlDetailView({
|
||||
ctrl,
|
||||
onBack,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReview,
|
||||
}: {
|
||||
ctrl: CanonicalControl
|
||||
onBack: () => void
|
||||
onEdit: () => void
|
||||
onDelete: (controlId: string) => void
|
||||
onReview: (controlId: string, action: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<button onClick={onBack} className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700">
|
||||
<ArrowLeft className="w-4 h-4" /> Zurueck zur Uebersicht
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onEdit} className="flex items-center gap-1 px-3 py-1.5 text-sm text-purple-600 border border-purple-300 rounded-lg hover:bg-purple-50">
|
||||
<Pencil className="w-3.5 h-3.5" /> Bearbeiten
|
||||
</button>
|
||||
<button onClick={() => onDelete(ctrl.control_id)} className="flex items-center gap-1 px-3 py-1.5 text-sm text-red-600 border border-red-300 rounded-lg hover:bg-red-50">
|
||||
<Trash2 className="w-3.5 h-3.5" /> Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<Shield className="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-mono text-purple-600">{ctrl.control_id}</span>
|
||||
<SeverityBadge severity={ctrl.severity} />
|
||||
<StateBadge state={ctrl.release_state} />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-gray-900">{ctrl.title}</h1>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
|
||||
{ctrl.risk_score !== null && <span>Risiko-Score: {ctrl.risk_score}/10</span>}
|
||||
{ctrl.implementation_effort && <span>Aufwand: {EFFORT_LABELS[ctrl.implementation_effort] || ctrl.implementation_effort}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Objective & Rationale */}
|
||||
<div className="space-y-6">
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Ziel</h3>
|
||||
<p className="text-sm text-gray-700 bg-gray-50 rounded-lg p-4">{ctrl.objective}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Begruendung</h3>
|
||||
<p className="text-sm text-gray-700 bg-gray-50 rounded-lg p-4">{ctrl.rationale}</p>
|
||||
</section>
|
||||
|
||||
{/* Scope */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Geltungsbereich</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{ctrl.scope.platforms && ctrl.scope.platforms.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">Plattformen</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ctrl.scope.platforms.map(p => (
|
||||
<span key={p} className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-xs">{p}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ctrl.scope.components && ctrl.scope.components.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">Komponenten</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ctrl.scope.components.map(c => (
|
||||
<span key={c} className="px-2 py-0.5 bg-purple-50 text-purple-700 rounded text-xs">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ctrl.scope.data_classes && ctrl.scope.data_classes.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-1">Datenklassen</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ctrl.scope.data_classes.map(d => (
|
||||
<span key={d} className="px-2 py-0.5 bg-amber-50 text-amber-700 rounded text-xs">{d}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Requirements */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
||||
<ol className="space-y-2">
|
||||
{ctrl.requirements.map((req, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<span className="flex-shrink-0 w-5 h-5 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center text-xs font-medium mt-0.5">{i + 1}</span>
|
||||
{req}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Test Procedure */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
||||
<ol className="space-y-2">
|
||||
{ctrl.test_procedure.map((step, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-700">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" />
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Evidence */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Nachweisanforderungen</h3>
|
||||
<div className="space-y-2">
|
||||
{ctrl.evidence.map((ev, i) => (
|
||||
<div key={i} className="flex items-start gap-2 p-3 bg-gray-50 rounded-lg">
|
||||
<FileText className="w-4 h-4 text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-500 uppercase">{ev.type}</span>
|
||||
<p className="text-sm text-gray-700">{ev.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Open Anchors — THE KEY SECTION */}
|
||||
<section className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<BookOpen className="w-4 h-4 text-green-700" />
|
||||
<h3 className="text-sm font-semibold text-green-900">Open-Source-Referenzen</h3>
|
||||
<span className="text-xs text-green-600">({ctrl.open_anchors.length} Quellen)</span>
|
||||
</div>
|
||||
<p className="text-xs text-green-700 mb-3">
|
||||
Dieses Control basiert auf frei verfuegbarem Wissen. Alle Referenzen sind offen und oeffentlich zugaenglich.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{ctrl.open_anchors.map((anchor, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-2 bg-white rounded border border-green-100">
|
||||
<Scale className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-semibold text-green-800">{anchor.framework}</span>
|
||||
<p className="text-sm text-gray-700">{anchor.ref}</p>
|
||||
</div>
|
||||
<a
|
||||
href={anchor.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-green-600 hover:text-green-800 flex-shrink-0"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Quelle
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tags */}
|
||||
{ctrl.tags.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Tags</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ctrl.tags.map(tag => (
|
||||
<span key={tag} className="px-2 py-1 bg-gray-100 text-gray-600 rounded text-xs">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* License & Citation Info */}
|
||||
{ctrl.license_rule && (
|
||||
<section className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Scale className="w-4 h-4 text-blue-700" />
|
||||
<h3 className="text-sm font-semibold text-blue-900">Lizenzinformationen</h3>
|
||||
<LicenseRuleBadge rule={ctrl.license_rule} />
|
||||
</div>
|
||||
{ctrl.source_citation && (
|
||||
<div className="text-xs text-blue-800 space-y-1">
|
||||
<p><span className="font-medium">Quelle:</span> {ctrl.source_citation.source}</p>
|
||||
{ctrl.source_citation.license && <p><span className="font-medium">Lizenz:</span> {ctrl.source_citation.license}</p>}
|
||||
{ctrl.source_citation.license_notice && <p><span className="font-medium">Hinweis:</span> {ctrl.source_citation.license_notice}</p>}
|
||||
{ctrl.source_citation.url && (
|
||||
<a href={ctrl.source_citation.url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 text-blue-600 hover:text-blue-800">
|
||||
<ExternalLink className="w-3 h-3" /> Originalquelle
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{ctrl.source_original_text && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs text-blue-600 cursor-pointer hover:text-blue-800">Originaltext anzeigen</summary>
|
||||
<p className="mt-1 text-xs text-gray-700 bg-white rounded p-2 border border-blue-100 max-h-40 overflow-y-auto">{ctrl.source_original_text}</p>
|
||||
</details>
|
||||
)}
|
||||
{ctrl.license_rule === 3 && (
|
||||
<p className="text-xs text-amber-700 mt-2 flex items-center gap-1">
|
||||
<Lock className="w-3 h-3" />
|
||||
Eigenstaendig formuliert — keine Originalquelle gespeichert
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Generation Metadata (internal) */}
|
||||
{ctrl.generation_metadata && (
|
||||
<section className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4 text-gray-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-700">Generierungsdetails (intern)</h3>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 space-y-1">
|
||||
<p>Pfad: {String(ctrl.generation_metadata.processing_path || '-')}</p>
|
||||
{ctrl.generation_metadata.similarity_status && (
|
||||
<p className="text-red-600">Similarity: {String(ctrl.generation_metadata.similarity_status)}</p>
|
||||
)}
|
||||
{Array.isArray(ctrl.generation_metadata.similar_controls) && (
|
||||
<div>
|
||||
<p className="font-medium">Aehnliche Controls:</p>
|
||||
{(ctrl.generation_metadata.similar_controls as Array<Record<string, unknown>>).map((s, i) => (
|
||||
<p key={i} className="ml-2">{String(s.control_id)} — {String(s.title)} ({String(s.similarity)})</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Review Actions */}
|
||||
{['needs_review', 'too_close', 'duplicate'].includes(ctrl.release_state) && (
|
||||
<section className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Eye className="w-4 h-4 text-yellow-700" />
|
||||
<h3 className="text-sm font-semibold text-yellow-900">Review erforderlich</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onReview(ctrl.control_id, 'approve')}
|
||||
className="px-3 py-1.5 text-sm text-white bg-green-600 rounded-lg hover:bg-green-700"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5 inline mr-1" />
|
||||
Akzeptieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onReview(ctrl.control_id, 'reject')}
|
||||
className="px-3 py-1.5 text-sm text-white bg-red-600 rounded-lg hover:bg-red-700"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 inline mr-1" />
|
||||
Ablehnen
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5 inline mr-1" />
|
||||
Ueberarbeiten
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { BookOpen, Trash2, Save, X } from 'lucide-react'
|
||||
import type { ControlFormData } from '../_types'
|
||||
|
||||
export function ControlForm({
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
saving,
|
||||
}: {
|
||||
initial: ControlFormData
|
||||
onSave: (data: ControlFormData) => void
|
||||
onCancel: () => void
|
||||
saving: boolean
|
||||
}) {
|
||||
const [form, setForm] = useState(initial)
|
||||
const [tagInput, setTagInput] = useState(initial.tags.join(', '))
|
||||
const [platformInput, setPlatformInput] = useState((initial.scope.platforms || []).join(', '))
|
||||
const [componentInput, setComponentInput] = useState((initial.scope.components || []).join(', '))
|
||||
const [dataClassInput, setDataClassInput] = useState((initial.scope.data_classes || []).join(', '))
|
||||
|
||||
const handleSave = () => {
|
||||
const data = {
|
||||
...form,
|
||||
tags: tagInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
scope: {
|
||||
platforms: platformInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
components: componentInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
data_classes: dataClassInput.split(',').map(t => t.trim()).filter(Boolean),
|
||||
},
|
||||
requirements: form.requirements.filter(r => r.trim()),
|
||||
test_procedure: form.test_procedure.filter(r => r.trim()),
|
||||
evidence: form.evidence.filter(e => e.type.trim() || e.description.trim()),
|
||||
open_anchors: form.open_anchors.filter(a => a.framework.trim() || a.ref.trim()),
|
||||
}
|
||||
onSave(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{initial.control_id ? `Control ${initial.control_id} bearbeiten` : 'Neues Control erstellen'}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={onCancel} className="px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
<X className="w-4 h-4 inline mr-1" />Abbrechen
|
||||
</button>
|
||||
<button onClick={handleSave} disabled={saving} className="px-3 py-1.5 text-sm text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
<Save className="w-4 h-4 inline mr-1" />{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic fields */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Control-ID *</label>
|
||||
<input
|
||||
value={form.control_id}
|
||||
onChange={e => setForm({ ...form, control_id: e.target.value.toUpperCase() })}
|
||||
placeholder="AUTH-003"
|
||||
disabled={!!initial.control_id}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none disabled:bg-gray-100"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Format: DOMAIN-NNN (z.B. AUTH-003, NET-005)</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Titel *</label>
|
||||
<input
|
||||
value={form.title}
|
||||
onChange={e => setForm({ ...form, title: e.target.value })}
|
||||
placeholder="Control-Titel"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Schweregrad</label>
|
||||
<select value={form.severity} onChange={e => setForm({ ...form, severity: e.target.value })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="low">Niedrig</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Risiko-Score (0-10)</label>
|
||||
<input
|
||||
type="number" min="0" max="10" step="0.5"
|
||||
value={form.risk_score ?? ''}
|
||||
onChange={e => setForm({ ...form, risk_score: e.target.value ? parseFloat(e.target.value) : null })}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Aufwand</label>
|
||||
<select value={form.implementation_effort || ''} onChange={e => setForm({ ...form, implementation_effort: e.target.value || null })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="">-</option>
|
||||
<option value="s">Klein (S)</option>
|
||||
<option value="m">Mittel (M)</option>
|
||||
<option value="l">Gross (L)</option>
|
||||
<option value="xl">Sehr gross (XL)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Objective & Rationale */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Ziel *</label>
|
||||
<textarea
|
||||
value={form.objective}
|
||||
onChange={e => setForm({ ...form, objective: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Begruendung *</label>
|
||||
<textarea
|
||||
value={form.rationale}
|
||||
onChange={e => setForm({ ...form, rationale: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scope */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Plattformen (komma-getrennt)</label>
|
||||
<input value={platformInput} onChange={e => setPlatformInput(e.target.value)} placeholder="web, mobile, api" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Komponenten (komma-getrennt)</label>
|
||||
<input value={componentInput} onChange={e => setComponentInput(e.target.value)} placeholder="auth-service, gateway" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Datenklassen (komma-getrennt)</label>
|
||||
<input value={dataClassInput} onChange={e => setDataClassInput(e.target.value)} placeholder="credentials, tokens" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirements */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-medium text-gray-600">Anforderungen</label>
|
||||
<button onClick={() => setForm({ ...form, requirements: [...form.requirements, ''] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
|
||||
</div>
|
||||
{form.requirements.map((req, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<span className="text-xs text-gray-400 mt-2.5">{i + 1}.</span>
|
||||
<input
|
||||
value={req}
|
||||
onChange={e => { const r = [...form.requirements]; r[i] = e.target.value; setForm({ ...form, requirements: r }) }}
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, requirements: form.requirements.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Test Procedure */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-medium text-gray-600">Pruefverfahren</label>
|
||||
<button onClick={() => setForm({ ...form, test_procedure: [...form.test_procedure, ''] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
|
||||
</div>
|
||||
{form.test_procedure.map((step, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<span className="text-xs text-gray-400 mt-2.5">{i + 1}.</span>
|
||||
<input
|
||||
value={step}
|
||||
onChange={e => { const t = [...form.test_procedure]; t[i] = e.target.value; setForm({ ...form, test_procedure: t }) }}
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, test_procedure: form.test_procedure.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Evidence */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-medium text-gray-600">Nachweisanforderungen</label>
|
||||
<button onClick={() => setForm({ ...form, evidence: [...form.evidence, { type: '', description: '' }] })} className="text-xs text-purple-600 hover:text-purple-800">+ Hinzufuegen</button>
|
||||
</div>
|
||||
{form.evidence.map((ev, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<input
|
||||
value={ev.type}
|
||||
onChange={e => { const evs = [...form.evidence]; evs[i] = { ...evs[i], type: e.target.value }; setForm({ ...form, evidence: evs }) }}
|
||||
placeholder="Typ (z.B. config, test_result)"
|
||||
className="w-32 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<input
|
||||
value={ev.description}
|
||||
onChange={e => { const evs = [...form.evidence]; evs[i] = { ...evs[i], description: e.target.value }; setForm({ ...form, evidence: evs }) }}
|
||||
placeholder="Beschreibung"
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, evidence: form.evidence.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Open Anchors */}
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-green-700" />
|
||||
<label className="text-xs font-semibold text-green-900">Open-Source-Referenzen *</label>
|
||||
</div>
|
||||
<button onClick={() => setForm({ ...form, open_anchors: [...form.open_anchors, { framework: '', ref: '', url: '' }] })} className="text-xs text-green-700 hover:text-green-900">+ Hinzufuegen</button>
|
||||
</div>
|
||||
<p className="text-xs text-green-600 mb-3">Jedes Control braucht mindestens eine offene Referenz (OWASP, NIST, ENISA, etc.)</p>
|
||||
{form.open_anchors.map((anchor, i) => (
|
||||
<div key={i} className="flex gap-2 mb-2">
|
||||
<input
|
||||
value={anchor.framework}
|
||||
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], framework: e.target.value }; setForm({ ...form, open_anchors: a }) }}
|
||||
placeholder="Framework (z.B. OWASP ASVS)"
|
||||
className="w-40 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
|
||||
/>
|
||||
<input
|
||||
value={anchor.ref}
|
||||
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], ref: e.target.value }; setForm({ ...form, open_anchors: a }) }}
|
||||
placeholder="Referenz (z.B. V2.8)"
|
||||
className="w-48 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
|
||||
/>
|
||||
<input
|
||||
value={anchor.url}
|
||||
onChange={e => { const a = [...form.open_anchors]; a[i] = { ...a[i], url: e.target.value }; setForm({ ...form, open_anchors: a }) }}
|
||||
placeholder="https://..."
|
||||
className="flex-1 px-3 py-2 text-sm border border-green-200 rounded-lg bg-white"
|
||||
/>
|
||||
<button onClick={() => setForm({ ...form, open_anchors: form.open_anchors.filter((_, j) => j !== i) })} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tags & State */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Tags (komma-getrennt)</label>
|
||||
<input value={tagInput} onChange={e => setTagInput(e.target.value)} placeholder="mfa, auth, iam" className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Status</label>
|
||||
<select value={form.release_state} onChange={e => setForm({ ...form, release_state: e.target.value })} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="review">Review</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="deprecated">Deprecated</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Shield, Search, ChevronRight, Filter, Lock,
|
||||
BookOpen, Plus, Zap, BarChart3,
|
||||
} from 'lucide-react'
|
||||
import type { CanonicalControl, Framework } from '../_types'
|
||||
import { SeverityBadge, StateBadge, LicenseRuleBadge } from './Badges'
|
||||
|
||||
export function ControlListView({
|
||||
controls,
|
||||
filteredControls,
|
||||
frameworks,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
severityFilter,
|
||||
setSeverityFilter,
|
||||
domainFilter,
|
||||
setDomainFilter,
|
||||
stateFilter,
|
||||
setStateFilter,
|
||||
domains,
|
||||
showStats,
|
||||
toggleStats,
|
||||
processedStats,
|
||||
onOpenGenerator,
|
||||
onCreate,
|
||||
onSelect,
|
||||
}: {
|
||||
controls: CanonicalControl[]
|
||||
filteredControls: CanonicalControl[]
|
||||
frameworks: Framework[]
|
||||
searchQuery: string
|
||||
setSearchQuery: (v: string) => void
|
||||
severityFilter: string
|
||||
setSeverityFilter: (v: string) => void
|
||||
domainFilter: string
|
||||
setDomainFilter: (v: string) => void
|
||||
stateFilter: string
|
||||
setStateFilter: (v: string) => void
|
||||
domains: string[]
|
||||
showStats: boolean
|
||||
toggleStats: () => void
|
||||
processedStats: Array<Record<string, unknown>>
|
||||
onOpenGenerator: () => void
|
||||
onCreate: () => void
|
||||
onSelect: (ctrl: CanonicalControl) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-6 h-6 text-purple-600" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-gray-900">Canonical Control Library</h1>
|
||||
<p className="text-xs text-gray-500">
|
||||
{controls.length} unabhaengig formulierte Security Controls —{' '}
|
||||
{controls.reduce((sum, c) => sum + c.open_anchors.length, 0)} Open-Source-Referenzen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={toggleStats}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
Stats
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenGenerator}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-amber-600 rounded-lg hover:bg-amber-700"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
Generator
|
||||
</button>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-white bg-purple-600 rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Neues Control
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Frameworks */}
|
||||
{frameworks.length > 0 && (
|
||||
<div className="mb-4 p-3 bg-purple-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-xs text-purple-700">
|
||||
<Lock className="w-3 h-3" />
|
||||
<span className="font-medium">{frameworks[0]?.name} v{frameworks[0]?.version}</span>
|
||||
<span className="text-purple-500">—</span>
|
||||
<span>{frameworks[0]?.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Controls durchsuchen..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<select
|
||||
value={severityFilter}
|
||||
onChange={e => setSeverityFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Alle Schweregrade</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="medium">Mittel</option>
|
||||
<option value="low">Niedrig</option>
|
||||
</select>
|
||||
<select
|
||||
value={domainFilter}
|
||||
onChange={e => setDomainFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Alle Domains</option>
|
||||
{domains.map(d => (
|
||||
<option key={d} value={d}>{d}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={stateFilter}
|
||||
onChange={e => setStateFilter(e.target.value)}
|
||||
className="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Alle Status</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="needs_review">Review noetig</option>
|
||||
<option value="too_close">Zu aehnlich</option>
|
||||
<option value="duplicate">Duplikat</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Processing Stats */}
|
||||
{showStats && processedStats.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
|
||||
<h4 className="text-xs font-semibold text-gray-700 mb-2">Verarbeitungsfortschritt</h4>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{processedStats.map((s, i) => (
|
||||
<div key={i} className="text-xs">
|
||||
<span className="font-medium text-gray-700">{String(s.collection)}</span>
|
||||
<div className="flex gap-2 mt-1 text-gray-500">
|
||||
<span>{String(s.processed_chunks)} verarbeitet</span>
|
||||
<span>{String(s.direct_adopted)} direkt</span>
|
||||
<span>{String(s.llm_reformed)} reformuliert</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Control List */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="space-y-3">
|
||||
{filteredControls.map(ctrl => (
|
||||
<button
|
||||
key={ctrl.control_id}
|
||||
onClick={() => onSelect(ctrl)}
|
||||
className="w-full text-left bg-white border border-gray-200 rounded-lg p-4 hover:border-purple-300 hover:shadow-sm transition-all group"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded">{ctrl.control_id}</span>
|
||||
<SeverityBadge severity={ctrl.severity} />
|
||||
<StateBadge state={ctrl.release_state} />
|
||||
<LicenseRuleBadge rule={ctrl.license_rule} />
|
||||
{ctrl.risk_score !== null && (
|
||||
<span className="text-xs text-gray-400">Score: {ctrl.risk_score}</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-900 group-hover:text-purple-700">{ctrl.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{ctrl.objective}</p>
|
||||
|
||||
{/* Open anchors summary */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<BookOpen className="w-3 h-3 text-green-600" />
|
||||
<span className="text-xs text-green-700">
|
||||
{ctrl.open_anchors.length} Open-Source-Referenzen:
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{ctrl.open_anchors.map(a => a.framework).filter((v, i, arr) => arr.indexOf(v) === i).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-gray-300 group-hover:text-purple-500 flex-shrink-0 mt-1 ml-4" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{filteredControls.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">
|
||||
{controls.length === 0
|
||||
? 'Noch keine Controls vorhanden. Klicke auf "Neues Control" um zu starten.'
|
||||
: 'Keine Controls gefunden.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import { Zap, X, RefreshCw } from 'lucide-react'
|
||||
|
||||
export function GeneratorModal({
|
||||
genDomain,
|
||||
setGenDomain,
|
||||
genMaxControls,
|
||||
setGenMaxControls,
|
||||
genDryRun,
|
||||
setGenDryRun,
|
||||
generating,
|
||||
genResult,
|
||||
onGenerate,
|
||||
onClose,
|
||||
}: {
|
||||
genDomain: string
|
||||
setGenDomain: (v: string) => void
|
||||
genMaxControls: number
|
||||
setGenMaxControls: (v: number) => void
|
||||
genDryRun: boolean
|
||||
setGenDryRun: (v: boolean) => void
|
||||
generating: boolean
|
||||
genResult: Record<string, unknown> | null
|
||||
onGenerate: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6 mx-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-amber-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">Control Generator</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Domain (optional)</label>
|
||||
<select value={genDomain} onChange={e => setGenDomain(e.target.value)} className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg">
|
||||
<option value="">Alle Domains</option>
|
||||
<option value="AUTH">AUTH — Authentifizierung</option>
|
||||
<option value="CRYPT">CRYPT — Kryptographie</option>
|
||||
<option value="NET">NET — Netzwerk</option>
|
||||
<option value="DATA">DATA — Datenschutz</option>
|
||||
<option value="LOG">LOG — Logging</option>
|
||||
<option value="ACC">ACC — Zugriffskontrolle</option>
|
||||
<option value="SEC">SEC — Sicherheit</option>
|
||||
<option value="INC">INC — Incident Response</option>
|
||||
<option value="AI">AI — Kuenstliche Intelligenz</option>
|
||||
<option value="COMP">COMP — Compliance</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-600 mb-1">Max. Controls: {genMaxControls}</label>
|
||||
<input
|
||||
type="range" min="1" max="100" step="1"
|
||||
value={genMaxControls}
|
||||
onChange={e => setGenMaxControls(parseInt(e.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="dryRun"
|
||||
checked={genDryRun}
|
||||
onChange={e => setGenDryRun(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
<label htmlFor="dryRun" className="text-sm text-gray-700">Dry Run (Vorschau ohne Speicherung)</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onGenerate}
|
||||
disabled={generating}
|
||||
className="w-full py-2 text-sm text-white bg-amber-600 rounded-lg hover:bg-amber-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{generating ? (
|
||||
<><RefreshCw className="w-4 h-4 animate-spin" /> Generiere...</>
|
||||
) : (
|
||||
<><Zap className="w-4 h-4" /> Generierung starten</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Results */}
|
||||
{genResult && (
|
||||
<div className={`p-4 rounded-lg text-sm ${genResult.status === 'error' ? 'bg-red-50 text-red-800' : 'bg-green-50 text-green-800'}`}>
|
||||
<p className="font-medium mb-1">{String(genResult.message || genResult.status)}</p>
|
||||
{genResult.status !== 'error' && (
|
||||
<div className="grid grid-cols-2 gap-1 text-xs mt-2">
|
||||
<span>Chunks gescannt: {String(genResult.total_chunks_scanned)}</span>
|
||||
<span>Controls generiert: {String(genResult.controls_generated)}</span>
|
||||
<span>Verifiziert: {String(genResult.controls_verified)}</span>
|
||||
<span>Review noetig: {String(genResult.controls_needs_review)}</span>
|
||||
<span>Zu aehnlich: {String(genResult.controls_too_close)}</span>
|
||||
<span>Duplikate: {String(genResult.controls_duplicates_found)}</span>
|
||||
</div>
|
||||
)}
|
||||
{Array.isArray(genResult.errors) && (genResult.errors as string[]).length > 0 && (
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
{(genResult.errors as string[]).slice(0, 3).map((e, i) => <p key={i}>{e}</p>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
admin-compliance/app/sdk/control-library/_types.ts
Normal file
87
admin-compliance/app/sdk/control-library/_types.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export interface OpenAnchor {
|
||||
framework: string
|
||||
ref: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface EvidenceItem {
|
||||
type: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface CanonicalControl {
|
||||
id: string
|
||||
framework_id: string
|
||||
control_id: string
|
||||
title: string
|
||||
objective: string
|
||||
rationale: string
|
||||
scope: {
|
||||
platforms?: string[]
|
||||
components?: string[]
|
||||
data_classes?: string[]
|
||||
}
|
||||
requirements: string[]
|
||||
test_procedure: string[]
|
||||
evidence: EvidenceItem[]
|
||||
severity: string
|
||||
risk_score: number | null
|
||||
implementation_effort: string | null
|
||||
evidence_confidence: number | null
|
||||
open_anchors: OpenAnchor[]
|
||||
release_state: string
|
||||
tags: string[]
|
||||
license_rule?: number | null
|
||||
source_original_text?: string | null
|
||||
source_citation?: Record<string, string> | null
|
||||
customer_visible?: boolean
|
||||
generation_metadata?: Record<string, unknown> | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Framework {
|
||||
id: string
|
||||
framework_id: string
|
||||
name: string
|
||||
version: string
|
||||
description: string
|
||||
release_state: string
|
||||
}
|
||||
|
||||
export const EFFORT_LABELS: Record<string, string> = {
|
||||
s: 'Klein (S)',
|
||||
m: 'Mittel (M)',
|
||||
l: 'Gross (L)',
|
||||
xl: 'Sehr gross (XL)',
|
||||
}
|
||||
|
||||
export const BACKEND_URL = '/api/sdk/v1/canonical'
|
||||
|
||||
export const EMPTY_CONTROL = {
|
||||
framework_id: 'bp_security_v1',
|
||||
control_id: '',
|
||||
title: '',
|
||||
objective: '',
|
||||
rationale: '',
|
||||
scope: { platforms: [] as string[], components: [] as string[], data_classes: [] as string[] },
|
||||
requirements: [''],
|
||||
test_procedure: [''],
|
||||
evidence: [{ type: '', description: '' }],
|
||||
severity: 'medium',
|
||||
risk_score: null as number | null,
|
||||
implementation_effort: 'm' as string | null,
|
||||
open_anchors: [{ framework: '', ref: '', url: '' }],
|
||||
release_state: 'draft',
|
||||
tags: [] as string[],
|
||||
}
|
||||
|
||||
export type ControlFormData = typeof EMPTY_CONTROL
|
||||
|
||||
export function getDomain(controlId: string): string {
|
||||
return controlId.split('-')[0] || ''
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
export function ComplianceRing({ score }: { score: number }) {
|
||||
const radius = 50
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const offset = circumference - (score / 100) * circumference
|
||||
const color = score >= 75 ? '#16a34a' : score >= 50 ? '#f59e0b' : '#dc2626'
|
||||
|
||||
return (
|
||||
<div className="relative w-36 h-36">
|
||||
<svg className="w-full h-full -rotate-90">
|
||||
<circle cx="68" cy="68" r={radius} fill="none" stroke="#e5e7eb" strokeWidth="8" />
|
||||
<circle
|
||||
cx="68" cy="68" r={radius} fill="none"
|
||||
stroke={color} strokeWidth="8"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-1000"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-3xl font-bold" style={{ color }}>{score.toFixed(0)}%</span>
|
||||
<span className="text-xs text-gray-500">Compliance</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { CrawlDocument, api, CLASSIFICATION_LABELS, ALL_CLASSIFICATIONS } from '../_types'
|
||||
|
||||
export function DocumentsTab() {
|
||||
const [docs, setDocs] = useState<CrawlDocument[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filterClass, setFilterClass] = useState('')
|
||||
const [archiving, setArchiving] = useState<Record<string, boolean>>({})
|
||||
|
||||
const loadDocs = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = filterClass ? `?classification=${filterClass}` : ''
|
||||
const data = await api(`documents${params}`)
|
||||
setDocs(data?.documents || [])
|
||||
setTotal(data?.total || 0)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [filterClass])
|
||||
|
||||
useEffect(() => { loadDocs() }, [loadDocs])
|
||||
|
||||
const handleReclassify = async (docId: string, newClass: string) => {
|
||||
await api(`documents/${docId}/classify`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ classification: newClass }),
|
||||
})
|
||||
loadDocs()
|
||||
}
|
||||
|
||||
const handleArchive = async (docId: string) => {
|
||||
setArchiving(prev => ({ ...prev, [docId]: true }))
|
||||
try {
|
||||
await api(`documents/${docId}/archive`, { method: 'POST' })
|
||||
loadDocs()
|
||||
} catch { /* ignore */ }
|
||||
setArchiving(prev => ({ ...prev, [docId]: false }))
|
||||
}
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">{total} Dokumente</h2>
|
||||
<select
|
||||
value={filterClass}
|
||||
onChange={e => setFilterClass(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">Alle Kategorien</option>
|
||||
{ALL_CLASSIFICATIONS.map(c => (
|
||||
<option key={c} value={c}>{CLASSIFICATION_LABELS[c]?.label || c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Laden...</div>
|
||||
) : docs.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
|
||||
Keine Dokumente gefunden. Starten Sie zuerst einen Crawl-Job.
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium">Datei</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Kategorie</th>
|
||||
<th className="text-center px-4 py-3 font-medium">Konfidenz</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Groesse</th>
|
||||
<th className="text-center px-4 py-3 font-medium">Archiv</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{docs.map(doc => {
|
||||
const cls = CLASSIFICATION_LABELS[doc.classification || ''] || CLASSIFICATION_LABELS['Sonstiges']
|
||||
return (
|
||||
<tr key={doc.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-gray-900 truncate max-w-xs">{doc.file_name}</div>
|
||||
<div className="text-xs text-gray-400">{doc.source_name}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={doc.classification || 'Sonstiges'}
|
||||
onChange={e => handleReclassify(doc.id, e.target.value)}
|
||||
className={`px-2 py-1 text-xs font-medium rounded border-0 ${cls.color}`}
|
||||
>
|
||||
{ALL_CLASSIFICATIONS.map(c => (
|
||||
<option key={c} value={c}>{CLASSIFICATION_LABELS[c].label}</option>
|
||||
))}
|
||||
</select>
|
||||
{doc.classification_corrected && (
|
||||
<span className="ml-1 text-xs text-orange-500" title="Manuell korrigiert">*</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{doc.classification_confidence != null && (
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<div className="w-12 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-500 rounded-full"
|
||||
style={{ width: `${doc.classification_confidence * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{(doc.classification_confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-500">{formatSize(doc.file_size_bytes)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{doc.archived ? (
|
||||
<span className="text-green-600 text-xs font-medium" title={doc.ipfs_cid || ''}>IPFS</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{!doc.archived && (
|
||||
<button
|
||||
onClick={() => handleArchive(doc.id)}
|
||||
disabled={archiving[doc.id]}
|
||||
className="text-xs text-purple-600 hover:text-purple-800 disabled:opacity-50"
|
||||
>
|
||||
{archiving[doc.id] ? 'Archiviert...' : 'Archivieren'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { CrawlJob, CrawlSource, api } from '../_types'
|
||||
|
||||
export function JobsTab() {
|
||||
const [jobs, setJobs] = useState<CrawlJob[]>([])
|
||||
const [sources, setSources] = useState<CrawlSource[]>([])
|
||||
const [selectedSource, setSelectedSource] = useState('')
|
||||
const [jobType, setJobType] = useState<'full' | 'delta'>('full')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [j, s] = await Promise.all([api('jobs'), api('sources')])
|
||||
setJobs(j || [])
|
||||
setSources(s || [])
|
||||
if (!selectedSource && s?.length > 0) setSelectedSource(s[0].id)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [selectedSource])
|
||||
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
// Auto-refresh running jobs
|
||||
useEffect(() => {
|
||||
const hasRunning = jobs.some(j => j.status === 'running' || j.status === 'pending')
|
||||
if (!hasRunning) return
|
||||
const interval = setInterval(loadData, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [jobs, loadData])
|
||||
|
||||
const handleTrigger = async () => {
|
||||
if (!selectedSource) return
|
||||
await api('jobs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ source_id: selectedSource, job_type: jobType }),
|
||||
})
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
await api(`jobs/${id}/cancel`, { method: 'POST' })
|
||||
loadData()
|
||||
}
|
||||
|
||||
const statusColor = (s: string) => {
|
||||
switch (s) {
|
||||
case 'completed': return 'bg-green-100 text-green-700'
|
||||
case 'running': return 'bg-blue-100 text-blue-700'
|
||||
case 'pending': return 'bg-yellow-100 text-yellow-700'
|
||||
case 'failed': return 'bg-red-100 text-red-700'
|
||||
case 'cancelled': return 'bg-gray-100 text-gray-600'
|
||||
default: return 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Trigger form */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-4">Neuen Crawl starten</h3>
|
||||
<div className="flex gap-4 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Quelle</label>
|
||||
<select
|
||||
value={selectedSource}
|
||||
onChange={e => setSelectedSource(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
{sources.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Typ</label>
|
||||
<select
|
||||
value={jobType}
|
||||
onChange={e => setJobType(e.target.value as 'full' | 'delta')}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="full">Voll-Scan</option>
|
||||
<option value="delta">Delta-Scan</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleTrigger}
|
||||
disabled={!selectedSource}
|
||||
className="px-6 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
Crawl starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Job list */}
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-500">Laden...</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
|
||||
Noch keine Crawl-Jobs ausgefuehrt.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{jobs.map(job => (
|
||||
<div key={job.id} className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${statusColor(job.status)}`}>
|
||||
{job.status}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">{job.source_name || 'Quelle'}</span>
|
||||
<span className="text-xs text-gray-400">{job.job_type === 'delta' ? 'Delta' : 'Voll'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(job.status === 'running' || job.status === 'pending') && (
|
||||
<button onClick={() => handleCancel(job.id)} className="text-xs text-red-600 hover:text-red-800">
|
||||
Abbrechen
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(job.created_at).toLocaleString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{job.status === 'running' && job.files_found > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-600 rounded-full transition-all"
|
||||
style={{ width: `${(job.files_processed / job.files_found) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{job.files_processed} / {job.files_found} Dateien verarbeitet
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-6 gap-2 text-center">
|
||||
<div className="bg-gray-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-gray-900">{job.files_found}</div>
|
||||
<div className="text-xs text-gray-500">Gefunden</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-gray-900">{job.files_processed}</div>
|
||||
<div className="text-xs text-gray-500">Verarbeitet</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-green-700">{job.files_new}</div>
|
||||
<div className="text-xs text-green-600">Neu</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-blue-700">{job.files_changed}</div>
|
||||
<div className="text-xs text-blue-600">Geaendert</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-gray-500">{job.files_skipped}</div>
|
||||
<div className="text-xs text-gray-500">Uebersprungen</div>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-red-700">{job.files_error}</div>
|
||||
<div className="text-xs text-red-600">Fehler</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { OnboardingReport, api, CLASSIFICATION_LABELS } from '../_types'
|
||||
import { ComplianceRing } from './ComplianceRing'
|
||||
|
||||
export function ReportTab() {
|
||||
const [reports, setReports] = useState<OnboardingReport[]>([])
|
||||
const [activeReport, setActiveReport] = useState<OnboardingReport | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
const loadReports = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api('reports')
|
||||
setReports(data || [])
|
||||
if (data?.length > 0 && !activeReport) {
|
||||
const detail = await api(`reports/${data[0].id}`)
|
||||
setActiveReport(detail)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [activeReport])
|
||||
|
||||
useEffect(() => { loadReports() }, [loadReports])
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setGenerating(true)
|
||||
try {
|
||||
const result = await api('reports/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
setActiveReport(result)
|
||||
loadReports()
|
||||
} catch { /* ignore */ }
|
||||
setGenerating(false)
|
||||
}
|
||||
|
||||
const handleSelectReport = async (id: string) => {
|
||||
const detail = await api(`reports/${id}`)
|
||||
setActiveReport(detail)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Onboarding-Report</h2>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
className="px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{generating ? 'Generiere...' : 'Neuen Report erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Report selector */}
|
||||
{reports.length > 1 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{reports.map(r => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => handleSelectReport(r.id)}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg border whitespace-nowrap ${
|
||||
activeReport?.id === r.id
|
||||
? 'bg-purple-50 border-purple-300 text-purple-700'
|
||||
: 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{new Date(r.created_at).toLocaleString('de-DE')} — {r.compliance_score.toFixed(0)}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Laden...</div>
|
||||
) : !activeReport ? (
|
||||
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
|
||||
<p className="text-lg font-medium">Kein Report vorhanden</p>
|
||||
<p className="text-sm mt-1">Fuehren Sie zuerst einen Crawl durch und generieren Sie dann einen Report.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Score + Stats */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-8">
|
||||
<ComplianceRing score={activeReport.compliance_score} />
|
||||
<div className="flex-1 grid grid-cols-3 gap-4">
|
||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
||||
<div className="text-3xl font-bold text-gray-900">{activeReport.total_documents_found}</div>
|
||||
<div className="text-sm text-gray-500">Dokumente gefunden</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
||||
<div className="text-3xl font-bold text-gray-900">
|
||||
{Object.keys(activeReport.classification_breakdown || {}).length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Kategorien abgedeckt</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
||||
<div className="text-3xl font-bold text-red-600">
|
||||
{(activeReport.gaps || []).length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Luecken identifiziert</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Classification breakdown */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-4">Dokumenten-Verteilung</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(activeReport.classification_breakdown || {}).map(([cat, count]) => {
|
||||
const cls = CLASSIFICATION_LABELS[cat] || CLASSIFICATION_LABELS['Sonstiges']
|
||||
return (
|
||||
<span key={cat} className={`px-3 py-1.5 text-sm font-medium rounded-lg ${cls.color}`}>
|
||||
{cls.label}: {count as number}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{Object.keys(activeReport.classification_breakdown || {}).length === 0 && (
|
||||
<span className="text-gray-400 text-sm">Keine Dokumente klassifiziert</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gap summary */}
|
||||
{activeReport.gap_summary && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center p-4 bg-red-50 rounded-xl border border-red-100">
|
||||
<div className="text-3xl font-bold text-red-600">{activeReport.gap_summary.critical}</div>
|
||||
<div className="text-sm text-red-600 font-medium">Kritisch</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-orange-50 rounded-xl border border-orange-100">
|
||||
<div className="text-3xl font-bold text-orange-600">{activeReport.gap_summary.high}</div>
|
||||
<div className="text-sm text-orange-600 font-medium">Hoch</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-yellow-50 rounded-xl border border-yellow-100">
|
||||
<div className="text-3xl font-bold text-yellow-600">{activeReport.gap_summary.medium}</div>
|
||||
<div className="text-sm text-yellow-600 font-medium">Mittel</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gap details */}
|
||||
{(activeReport.gaps || []).length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-4">Compliance-Luecken</h3>
|
||||
<div className="space-y-3">
|
||||
{activeReport.gaps.map((gap) => (
|
||||
<div
|
||||
key={gap.id}
|
||||
className={`p-4 rounded-lg border-l-4 ${
|
||||
gap.severity === 'CRITICAL'
|
||||
? 'bg-red-50 border-red-500'
|
||||
: gap.severity === 'HIGH'
|
||||
? 'bg-orange-50 border-orange-500'
|
||||
: 'bg-yellow-50 border-yellow-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{gap.category}</div>
|
||||
<p className="text-sm text-gray-600 mt-1">{gap.description}</p>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${
|
||||
gap.severity === 'CRITICAL' ? 'bg-red-100 text-red-700'
|
||||
: gap.severity === 'HIGH' ? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{gap.severity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Regulierung: {gap.regulation} | Aktion: {gap.requiredAction}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { CrawlSource, api } from '../_types'
|
||||
|
||||
export function SourcesTab() {
|
||||
const [sources, setSources] = useState<CrawlSource[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [formName, setFormName] = useState('')
|
||||
const [formPath, setFormPath] = useState('')
|
||||
const [testResult, setTestResult] = useState<Record<string, string>>({})
|
||||
|
||||
const loadSources = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api('sources')
|
||||
setSources(data || [])
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadSources() }, [loadSources])
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formName || !formPath) return
|
||||
await api('sources', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: formName, path: formPath }),
|
||||
})
|
||||
setFormName('')
|
||||
setFormPath('')
|
||||
setShowForm(false)
|
||||
loadSources()
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await api(`sources/${id}`, { method: 'DELETE' })
|
||||
loadSources()
|
||||
}
|
||||
|
||||
const handleToggle = async (source: CrawlSource) => {
|
||||
await api(`sources/${source.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled: !source.enabled }),
|
||||
})
|
||||
loadSources()
|
||||
}
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
setTestResult(prev => ({ ...prev, [id]: 'testing...' }))
|
||||
const result = await api(`sources/${id}/test`, { method: 'POST' })
|
||||
setTestResult(prev => ({ ...prev, [id]: result?.message || 'Fehler' }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Crawl-Quellen</h2>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
+ Neue Quelle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input
|
||||
value={formName}
|
||||
onChange={e => setFormName(e.target.value)}
|
||||
placeholder="z.B. Compliance-Ordner"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Pfad (relativ zu /data/crawl)</label>
|
||||
<input
|
||||
value={formPath}
|
||||
onChange={e => setFormPath(e.target.value)}
|
||||
placeholder="z.B. compliance-docs"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleCreate} className="px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700">
|
||||
Erstellen
|
||||
</button>
|
||||
<button onClick={() => setShowForm(false)} className="px-4 py-2 text-gray-600 text-sm hover:text-gray-800">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Laden...</div>
|
||||
) : sources.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
|
||||
<p className="text-lg font-medium">Keine Quellen konfiguriert</p>
|
||||
<p className="text-sm mt-1">Erstellen Sie eine Crawl-Quelle um Dokumente zu scannen.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sources.map(s => (
|
||||
<div key={s.id} className="bg-white rounded-xl border border-gray-200 p-5 flex items-center gap-4">
|
||||
<div className={`w-3 h-3 rounded-full ${s.enabled ? 'bg-green-500' : 'bg-gray-300'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900">{s.name}</div>
|
||||
<div className="text-sm text-gray-500 truncate">{s.path}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
Tiefe: {s.max_depth} | Formate: {(typeof s.file_extensions === 'string' ? JSON.parse(s.file_extensions) : s.file_extensions).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
{testResult[s.id] && (
|
||||
<span className="text-xs text-gray-500 bg-gray-50 px-2 py-1 rounded">{testResult[s.id]}</span>
|
||||
)}
|
||||
<button onClick={() => handleTest(s.id)} className="text-sm text-blue-600 hover:text-blue-800">Testen</button>
|
||||
<button onClick={() => handleToggle(s)} className="text-sm text-gray-600 hover:text-gray-800">
|
||||
{s.enabled ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</button>
|
||||
<button onClick={() => handleDelete(s.id)} className="text-sm text-red-600 hover:text-red-800">Loeschen</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
98
admin-compliance/app/sdk/document-crawler/_types.ts
Normal file
98
admin-compliance/app/sdk/document-crawler/_types.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
export interface CrawlSource {
|
||||
id: string
|
||||
name: string
|
||||
source_type: string
|
||||
path: string
|
||||
file_extensions: string[]
|
||||
max_depth: number
|
||||
exclude_patterns: string[]
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CrawlJob {
|
||||
id: string
|
||||
source_id: string
|
||||
source_name?: string
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
|
||||
job_type: 'full' | 'delta'
|
||||
files_found: number
|
||||
files_processed: number
|
||||
files_new: number
|
||||
files_changed: number
|
||||
files_skipped: number
|
||||
files_error: number
|
||||
error_message?: string
|
||||
started_at?: string
|
||||
completed_at?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CrawlDocument {
|
||||
id: string
|
||||
file_name: string
|
||||
file_extension: string
|
||||
file_size_bytes: number
|
||||
classification: string | null
|
||||
classification_confidence: number | null
|
||||
classification_corrected: boolean
|
||||
extraction_status: string
|
||||
archived: boolean
|
||||
ipfs_cid: string | null
|
||||
first_seen_at: string
|
||||
last_seen_at: string
|
||||
version_count: number
|
||||
source_name?: string
|
||||
}
|
||||
|
||||
export interface OnboardingReport {
|
||||
id: string
|
||||
total_documents_found: number
|
||||
classification_breakdown: Record<string, number>
|
||||
gaps: GapItem[]
|
||||
compliance_score: number
|
||||
gap_summary?: { critical: number; high: number; medium: number }
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface GapItem {
|
||||
id: string
|
||||
category: string
|
||||
description: string
|
||||
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM'
|
||||
regulation: string
|
||||
requiredAction: string
|
||||
}
|
||||
|
||||
export const TENANT_ID = '00000000-0000-0000-0000-000000000001' // Default tenant
|
||||
|
||||
export async function api(path: string, options: RequestInit = {}) {
|
||||
const res = await fetch(`/api/sdk/v1/crawler/${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': TENANT_ID,
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
if (res.status === 204) return null
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export const CLASSIFICATION_LABELS: Record<string, { label: string; color: string }> = {
|
||||
VVT: { label: 'VVT', color: 'bg-blue-100 text-blue-700' },
|
||||
TOM: { label: 'TOM', color: 'bg-green-100 text-green-700' },
|
||||
DSE: { label: 'DSE', color: 'bg-purple-100 text-purple-700' },
|
||||
AVV: { label: 'AVV', color: 'bg-orange-100 text-orange-700' },
|
||||
DSFA: { label: 'DSFA', color: 'bg-red-100 text-red-700' },
|
||||
Loeschkonzept: { label: 'Loeschkonzept', color: 'bg-yellow-100 text-yellow-700' },
|
||||
Einwilligung: { label: 'Einwilligung', color: 'bg-pink-100 text-pink-700' },
|
||||
Vertrag: { label: 'Vertrag', color: 'bg-indigo-100 text-indigo-700' },
|
||||
Richtlinie: { label: 'Richtlinie', color: 'bg-teal-100 text-teal-700' },
|
||||
Schulungsnachweis: { label: 'Schulung', color: 'bg-cyan-100 text-cyan-700' },
|
||||
Sonstiges: { label: 'Sonstiges', color: 'bg-gray-100 text-gray-700' },
|
||||
}
|
||||
|
||||
export const ALL_CLASSIFICATIONS = Object.keys(CLASSIFICATION_LABELS)
|
||||
|
||||
export type Tab = 'sources' | 'jobs' | 'documents' | 'report'
|
||||
@@ -1,794 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface CrawlSource {
|
||||
id: string
|
||||
name: string
|
||||
source_type: string
|
||||
path: string
|
||||
file_extensions: string[]
|
||||
max_depth: number
|
||||
exclude_patterns: string[]
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface CrawlJob {
|
||||
id: string
|
||||
source_id: string
|
||||
source_name?: string
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
|
||||
job_type: 'full' | 'delta'
|
||||
files_found: number
|
||||
files_processed: number
|
||||
files_new: number
|
||||
files_changed: number
|
||||
files_skipped: number
|
||||
files_error: number
|
||||
error_message?: string
|
||||
started_at?: string
|
||||
completed_at?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface CrawlDocument {
|
||||
id: string
|
||||
file_name: string
|
||||
file_extension: string
|
||||
file_size_bytes: number
|
||||
classification: string | null
|
||||
classification_confidence: number | null
|
||||
classification_corrected: boolean
|
||||
extraction_status: string
|
||||
archived: boolean
|
||||
ipfs_cid: string | null
|
||||
first_seen_at: string
|
||||
last_seen_at: string
|
||||
version_count: number
|
||||
source_name?: string
|
||||
}
|
||||
|
||||
interface OnboardingReport {
|
||||
id: string
|
||||
total_documents_found: number
|
||||
classification_breakdown: Record<string, number>
|
||||
gaps: GapItem[]
|
||||
compliance_score: number
|
||||
gap_summary?: { critical: number; high: number; medium: number }
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface GapItem {
|
||||
id: string
|
||||
category: string
|
||||
description: string
|
||||
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM'
|
||||
regulation: string
|
||||
requiredAction: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API HELPERS
|
||||
// =============================================================================
|
||||
|
||||
const TENANT_ID = '00000000-0000-0000-0000-000000000001' // Default tenant
|
||||
|
||||
async function api(path: string, options: RequestInit = {}) {
|
||||
const res = await fetch(`/api/sdk/v1/crawler/${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': TENANT_ID,
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
if (res.status === 204) return null
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CLASSIFICATION LABELS
|
||||
// =============================================================================
|
||||
|
||||
const CLASSIFICATION_LABELS: Record<string, { label: string; color: string }> = {
|
||||
VVT: { label: 'VVT', color: 'bg-blue-100 text-blue-700' },
|
||||
TOM: { label: 'TOM', color: 'bg-green-100 text-green-700' },
|
||||
DSE: { label: 'DSE', color: 'bg-purple-100 text-purple-700' },
|
||||
AVV: { label: 'AVV', color: 'bg-orange-100 text-orange-700' },
|
||||
DSFA: { label: 'DSFA', color: 'bg-red-100 text-red-700' },
|
||||
Loeschkonzept: { label: 'Loeschkonzept', color: 'bg-yellow-100 text-yellow-700' },
|
||||
Einwilligung: { label: 'Einwilligung', color: 'bg-pink-100 text-pink-700' },
|
||||
Vertrag: { label: 'Vertrag', color: 'bg-indigo-100 text-indigo-700' },
|
||||
Richtlinie: { label: 'Richtlinie', color: 'bg-teal-100 text-teal-700' },
|
||||
Schulungsnachweis: { label: 'Schulung', color: 'bg-cyan-100 text-cyan-700' },
|
||||
Sonstiges: { label: 'Sonstiges', color: 'bg-gray-100 text-gray-700' },
|
||||
}
|
||||
|
||||
const ALL_CLASSIFICATIONS = Object.keys(CLASSIFICATION_LABELS)
|
||||
|
||||
// =============================================================================
|
||||
// TAB: QUELLEN (Sources)
|
||||
// =============================================================================
|
||||
|
||||
function SourcesTab() {
|
||||
const [sources, setSources] = useState<CrawlSource[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [formName, setFormName] = useState('')
|
||||
const [formPath, setFormPath] = useState('')
|
||||
const [testResult, setTestResult] = useState<Record<string, string>>({})
|
||||
|
||||
const loadSources = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api('sources')
|
||||
setSources(data || [])
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadSources() }, [loadSources])
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formName || !formPath) return
|
||||
await api('sources', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: formName, path: formPath }),
|
||||
})
|
||||
setFormName('')
|
||||
setFormPath('')
|
||||
setShowForm(false)
|
||||
loadSources()
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await api(`sources/${id}`, { method: 'DELETE' })
|
||||
loadSources()
|
||||
}
|
||||
|
||||
const handleToggle = async (source: CrawlSource) => {
|
||||
await api(`sources/${source.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled: !source.enabled }),
|
||||
})
|
||||
loadSources()
|
||||
}
|
||||
|
||||
const handleTest = async (id: string) => {
|
||||
setTestResult(prev => ({ ...prev, [id]: 'testing...' }))
|
||||
const result = await api(`sources/${id}/test`, { method: 'POST' })
|
||||
setTestResult(prev => ({ ...prev, [id]: result?.message || 'Fehler' }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Crawl-Quellen</h2>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
+ Neue Quelle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input
|
||||
value={formName}
|
||||
onChange={e => setFormName(e.target.value)}
|
||||
placeholder="z.B. Compliance-Ordner"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Pfad (relativ zu /data/crawl)</label>
|
||||
<input
|
||||
value={formPath}
|
||||
onChange={e => setFormPath(e.target.value)}
|
||||
placeholder="z.B. compliance-docs"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleCreate} className="px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700">
|
||||
Erstellen
|
||||
</button>
|
||||
<button onClick={() => setShowForm(false)} className="px-4 py-2 text-gray-600 text-sm hover:text-gray-800">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Laden...</div>
|
||||
) : sources.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
|
||||
<p className="text-lg font-medium">Keine Quellen konfiguriert</p>
|
||||
<p className="text-sm mt-1">Erstellen Sie eine Crawl-Quelle um Dokumente zu scannen.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sources.map(s => (
|
||||
<div key={s.id} className="bg-white rounded-xl border border-gray-200 p-5 flex items-center gap-4">
|
||||
<div className={`w-3 h-3 rounded-full ${s.enabled ? 'bg-green-500' : 'bg-gray-300'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-gray-900">{s.name}</div>
|
||||
<div className="text-sm text-gray-500 truncate">{s.path}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
Tiefe: {s.max_depth} | Formate: {(typeof s.file_extensions === 'string' ? JSON.parse(s.file_extensions) : s.file_extensions).join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
{testResult[s.id] && (
|
||||
<span className="text-xs text-gray-500 bg-gray-50 px-2 py-1 rounded">{testResult[s.id]}</span>
|
||||
)}
|
||||
<button onClick={() => handleTest(s.id)} className="text-sm text-blue-600 hover:text-blue-800">Testen</button>
|
||||
<button onClick={() => handleToggle(s)} className="text-sm text-gray-600 hover:text-gray-800">
|
||||
{s.enabled ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</button>
|
||||
<button onClick={() => handleDelete(s.id)} className="text-sm text-red-600 hover:text-red-800">Loeschen</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TAB: CRAWL-JOBS
|
||||
// =============================================================================
|
||||
|
||||
function JobsTab() {
|
||||
const [jobs, setJobs] = useState<CrawlJob[]>([])
|
||||
const [sources, setSources] = useState<CrawlSource[]>([])
|
||||
const [selectedSource, setSelectedSource] = useState('')
|
||||
const [jobType, setJobType] = useState<'full' | 'delta'>('full')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [j, s] = await Promise.all([api('jobs'), api('sources')])
|
||||
setJobs(j || [])
|
||||
setSources(s || [])
|
||||
if (!selectedSource && s?.length > 0) setSelectedSource(s[0].id)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [selectedSource])
|
||||
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
// Auto-refresh running jobs
|
||||
useEffect(() => {
|
||||
const hasRunning = jobs.some(j => j.status === 'running' || j.status === 'pending')
|
||||
if (!hasRunning) return
|
||||
const interval = setInterval(loadData, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [jobs, loadData])
|
||||
|
||||
const handleTrigger = async () => {
|
||||
if (!selectedSource) return
|
||||
await api('jobs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ source_id: selectedSource, job_type: jobType }),
|
||||
})
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
await api(`jobs/${id}/cancel`, { method: 'POST' })
|
||||
loadData()
|
||||
}
|
||||
|
||||
const statusColor = (s: string) => {
|
||||
switch (s) {
|
||||
case 'completed': return 'bg-green-100 text-green-700'
|
||||
case 'running': return 'bg-blue-100 text-blue-700'
|
||||
case 'pending': return 'bg-yellow-100 text-yellow-700'
|
||||
case 'failed': return 'bg-red-100 text-red-700'
|
||||
case 'cancelled': return 'bg-gray-100 text-gray-600'
|
||||
default: return 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Trigger form */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-4">Neuen Crawl starten</h3>
|
||||
<div className="flex gap-4 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Quelle</label>
|
||||
<select
|
||||
value={selectedSource}
|
||||
onChange={e => setSelectedSource(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
{sources.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Typ</label>
|
||||
<select
|
||||
value={jobType}
|
||||
onChange={e => setJobType(e.target.value as 'full' | 'delta')}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="full">Voll-Scan</option>
|
||||
<option value="delta">Delta-Scan</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleTrigger}
|
||||
disabled={!selectedSource}
|
||||
className="px-6 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
Crawl starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Job list */}
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-500">Laden...</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
|
||||
Noch keine Crawl-Jobs ausgefuehrt.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{jobs.map(job => (
|
||||
<div key={job.id} className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${statusColor(job.status)}`}>
|
||||
{job.status}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900">{job.source_name || 'Quelle'}</span>
|
||||
<span className="text-xs text-gray-400">{job.job_type === 'delta' ? 'Delta' : 'Voll'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(job.status === 'running' || job.status === 'pending') && (
|
||||
<button onClick={() => handleCancel(job.id)} className="text-xs text-red-600 hover:text-red-800">
|
||||
Abbrechen
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(job.created_at).toLocaleString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{job.status === 'running' && job.files_found > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-600 rounded-full transition-all"
|
||||
style={{ width: `${(job.files_processed / job.files_found) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{job.files_processed} / {job.files_found} Dateien verarbeitet
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-6 gap-2 text-center">
|
||||
<div className="bg-gray-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-gray-900">{job.files_found}</div>
|
||||
<div className="text-xs text-gray-500">Gefunden</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-gray-900">{job.files_processed}</div>
|
||||
<div className="text-xs text-gray-500">Verarbeitet</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-green-700">{job.files_new}</div>
|
||||
<div className="text-xs text-green-600">Neu</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-blue-700">{job.files_changed}</div>
|
||||
<div className="text-xs text-blue-600">Geaendert</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-gray-500">{job.files_skipped}</div>
|
||||
<div className="text-xs text-gray-500">Uebersprungen</div>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-2">
|
||||
<div className="text-lg font-bold text-red-700">{job.files_error}</div>
|
||||
<div className="text-xs text-red-600">Fehler</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TAB: DOKUMENTE
|
||||
// =============================================================================
|
||||
|
||||
function DocumentsTab() {
|
||||
const [docs, setDocs] = useState<CrawlDocument[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filterClass, setFilterClass] = useState('')
|
||||
const [archiving, setArchiving] = useState<Record<string, boolean>>({})
|
||||
|
||||
const loadDocs = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = filterClass ? `?classification=${filterClass}` : ''
|
||||
const data = await api(`documents${params}`)
|
||||
setDocs(data?.documents || [])
|
||||
setTotal(data?.total || 0)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [filterClass])
|
||||
|
||||
useEffect(() => { loadDocs() }, [loadDocs])
|
||||
|
||||
const handleReclassify = async (docId: string, newClass: string) => {
|
||||
await api(`documents/${docId}/classify`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ classification: newClass }),
|
||||
})
|
||||
loadDocs()
|
||||
}
|
||||
|
||||
const handleArchive = async (docId: string) => {
|
||||
setArchiving(prev => ({ ...prev, [docId]: true }))
|
||||
try {
|
||||
await api(`documents/${docId}/archive`, { method: 'POST' })
|
||||
loadDocs()
|
||||
} catch { /* ignore */ }
|
||||
setArchiving(prev => ({ ...prev, [docId]: false }))
|
||||
}
|
||||
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">{total} Dokumente</h2>
|
||||
<select
|
||||
value={filterClass}
|
||||
onChange={e => setFilterClass(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">Alle Kategorien</option>
|
||||
{ALL_CLASSIFICATIONS.map(c => (
|
||||
<option key={c} value={c}>{CLASSIFICATION_LABELS[c]?.label || c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Laden...</div>
|
||||
) : docs.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
|
||||
Keine Dokumente gefunden. Starten Sie zuerst einen Crawl-Job.
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium">Datei</th>
|
||||
<th className="text-left px-4 py-3 font-medium">Kategorie</th>
|
||||
<th className="text-center px-4 py-3 font-medium">Konfidenz</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Groesse</th>
|
||||
<th className="text-center px-4 py-3 font-medium">Archiv</th>
|
||||
<th className="text-right px-4 py-3 font-medium">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{docs.map(doc => {
|
||||
const cls = CLASSIFICATION_LABELS[doc.classification || ''] || CLASSIFICATION_LABELS['Sonstiges']
|
||||
return (
|
||||
<tr key={doc.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-gray-900 truncate max-w-xs">{doc.file_name}</div>
|
||||
<div className="text-xs text-gray-400">{doc.source_name}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={doc.classification || 'Sonstiges'}
|
||||
onChange={e => handleReclassify(doc.id, e.target.value)}
|
||||
className={`px-2 py-1 text-xs font-medium rounded border-0 ${cls.color}`}
|
||||
>
|
||||
{ALL_CLASSIFICATIONS.map(c => (
|
||||
<option key={c} value={c}>{CLASSIFICATION_LABELS[c].label}</option>
|
||||
))}
|
||||
</select>
|
||||
{doc.classification_corrected && (
|
||||
<span className="ml-1 text-xs text-orange-500" title="Manuell korrigiert">*</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{doc.classification_confidence != null && (
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<div className="w-12 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-500 rounded-full"
|
||||
style={{ width: `${doc.classification_confidence * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{(doc.classification_confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-gray-500">{formatSize(doc.file_size_bytes)}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{doc.archived ? (
|
||||
<span className="text-green-600 text-xs font-medium" title={doc.ipfs_cid || ''}>IPFS</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{!doc.archived && (
|
||||
<button
|
||||
onClick={() => handleArchive(doc.id)}
|
||||
disabled={archiving[doc.id]}
|
||||
className="text-xs text-purple-600 hover:text-purple-800 disabled:opacity-50"
|
||||
>
|
||||
{archiving[doc.id] ? 'Archiviert...' : 'Archivieren'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TAB: ONBOARDING-REPORT
|
||||
// =============================================================================
|
||||
|
||||
function ReportTab() {
|
||||
const [reports, setReports] = useState<OnboardingReport[]>([])
|
||||
const [activeReport, setActiveReport] = useState<OnboardingReport | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
const loadReports = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api('reports')
|
||||
setReports(data || [])
|
||||
if (data?.length > 0 && !activeReport) {
|
||||
const detail = await api(`reports/${data[0].id}`)
|
||||
setActiveReport(detail)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [activeReport])
|
||||
|
||||
useEffect(() => { loadReports() }, [loadReports])
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setGenerating(true)
|
||||
try {
|
||||
const result = await api('reports/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
setActiveReport(result)
|
||||
loadReports()
|
||||
} catch { /* ignore */ }
|
||||
setGenerating(false)
|
||||
}
|
||||
|
||||
const handleSelectReport = async (id: string) => {
|
||||
const detail = await api(`reports/${id}`)
|
||||
setActiveReport(detail)
|
||||
}
|
||||
|
||||
// Compliance score ring
|
||||
const ComplianceRing = ({ score }: { score: number }) => {
|
||||
const radius = 50
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const offset = circumference - (score / 100) * circumference
|
||||
const color = score >= 75 ? '#16a34a' : score >= 50 ? '#f59e0b' : '#dc2626'
|
||||
|
||||
return (
|
||||
<div className="relative w-36 h-36">
|
||||
<svg className="w-full h-full -rotate-90">
|
||||
<circle cx="68" cy="68" r={radius} fill="none" stroke="#e5e7eb" strokeWidth="8" />
|
||||
<circle
|
||||
cx="68" cy="68" r={radius} fill="none"
|
||||
stroke={color} strokeWidth="8"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
className="transition-all duration-1000"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-3xl font-bold" style={{ color }}>{score.toFixed(0)}%</span>
|
||||
<span className="text-xs text-gray-500">Compliance</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Onboarding-Report</h2>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
className="px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{generating ? 'Generiere...' : 'Neuen Report erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Report selector */}
|
||||
{reports.length > 1 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{reports.map(r => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => handleSelectReport(r.id)}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg border whitespace-nowrap ${
|
||||
activeReport?.id === r.id
|
||||
? 'bg-purple-50 border-purple-300 text-purple-700'
|
||||
: 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{new Date(r.created_at).toLocaleString('de-DE')} — {r.compliance_score.toFixed(0)}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Laden...</div>
|
||||
) : !activeReport ? (
|
||||
<div className="text-center py-12 text-gray-500 bg-white rounded-xl border border-gray-200">
|
||||
<p className="text-lg font-medium">Kein Report vorhanden</p>
|
||||
<p className="text-sm mt-1">Fuehren Sie zuerst einen Crawl durch und generieren Sie dann einen Report.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Score + Stats */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center gap-8">
|
||||
<ComplianceRing score={activeReport.compliance_score} />
|
||||
<div className="flex-1 grid grid-cols-3 gap-4">
|
||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
||||
<div className="text-3xl font-bold text-gray-900">{activeReport.total_documents_found}</div>
|
||||
<div className="text-sm text-gray-500">Dokumente gefunden</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
||||
<div className="text-3xl font-bold text-gray-900">
|
||||
{Object.keys(activeReport.classification_breakdown || {}).length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Kategorien abgedeckt</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-xl">
|
||||
<div className="text-3xl font-bold text-red-600">
|
||||
{(activeReport.gaps || []).length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">Luecken identifiziert</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Classification breakdown */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-4">Dokumenten-Verteilung</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(activeReport.classification_breakdown || {}).map(([cat, count]) => {
|
||||
const cls = CLASSIFICATION_LABELS[cat] || CLASSIFICATION_LABELS['Sonstiges']
|
||||
return (
|
||||
<span key={cat} className={`px-3 py-1.5 text-sm font-medium rounded-lg ${cls.color}`}>
|
||||
{cls.label}: {count as number}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{Object.keys(activeReport.classification_breakdown || {}).length === 0 && (
|
||||
<span className="text-gray-400 text-sm">Keine Dokumente klassifiziert</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gap summary */}
|
||||
{activeReport.gap_summary && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center p-4 bg-red-50 rounded-xl border border-red-100">
|
||||
<div className="text-3xl font-bold text-red-600">{activeReport.gap_summary.critical}</div>
|
||||
<div className="text-sm text-red-600 font-medium">Kritisch</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-orange-50 rounded-xl border border-orange-100">
|
||||
<div className="text-3xl font-bold text-orange-600">{activeReport.gap_summary.high}</div>
|
||||
<div className="text-sm text-orange-600 font-medium">Hoch</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-yellow-50 rounded-xl border border-yellow-100">
|
||||
<div className="text-3xl font-bold text-yellow-600">{activeReport.gap_summary.medium}</div>
|
||||
<div className="text-sm text-yellow-600 font-medium">Mittel</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gap details */}
|
||||
{(activeReport.gaps || []).length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-4">Compliance-Luecken</h3>
|
||||
<div className="space-y-3">
|
||||
{activeReport.gaps.map((gap) => (
|
||||
<div
|
||||
key={gap.id}
|
||||
className={`p-4 rounded-lg border-l-4 ${
|
||||
gap.severity === 'CRITICAL'
|
||||
? 'bg-red-50 border-red-500'
|
||||
: gap.severity === 'HIGH'
|
||||
? 'bg-orange-50 border-orange-500'
|
||||
: 'bg-yellow-50 border-yellow-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{gap.category}</div>
|
||||
<p className="text-sm text-gray-600 mt-1">{gap.description}</p>
|
||||
</div>
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${
|
||||
gap.severity === 'CRITICAL' ? 'bg-red-100 text-red-700'
|
||||
: gap.severity === 'HIGH' ? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{gap.severity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
Regulierung: {gap.regulation} | Aktion: {gap.requiredAction}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
type Tab = 'sources' | 'jobs' | 'documents' | 'report'
|
||||
import { useState } from 'react'
|
||||
import { Tab } from './_types'
|
||||
import { SourcesTab } from './_components/SourcesTab'
|
||||
import { JobsTab } from './_components/JobsTab'
|
||||
import { DocumentsTab } from './_components/DocumentsTab'
|
||||
import { ReportTab } from './_components/ReportTab'
|
||||
|
||||
export default function DocumentCrawlerPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('sources')
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import { TemplateContext } from '../contextBridge'
|
||||
import { SECTION_FIELDS } from '../_constants'
|
||||
|
||||
export default function ContextSectionForm({
|
||||
section,
|
||||
context,
|
||||
onChange,
|
||||
}: {
|
||||
section: keyof TemplateContext
|
||||
context: TemplateContext
|
||||
onChange: (section: keyof TemplateContext, key: string, value: unknown) => void
|
||||
}) {
|
||||
const fields = SECTION_FIELDS[section]
|
||||
const sectionData = context[section] as unknown as Record<string, unknown>
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{fields.map((field) => {
|
||||
const rawValue = sectionData[field.key]
|
||||
const inputCls = 'w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-400'
|
||||
|
||||
if (field.type === 'boolean') {
|
||||
return (
|
||||
<div key={field.key} className="flex items-center gap-2 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`${section}-${field.key}`}
|
||||
checked={!!rawValue}
|
||||
onChange={(e) => onChange(section, field.key, e.target.checked)}
|
||||
className="w-4 h-4 accent-purple-600"
|
||||
/>
|
||||
<label htmlFor={`${section}-${field.key}`} className="text-sm text-gray-700">
|
||||
{field.label}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'select' && field.opts) {
|
||||
return (
|
||||
<div key={field.key} className={field.span ? 'md:col-span-2' : ''}>
|
||||
<label className="block text-xs text-gray-500 mb-1">{field.label}</label>
|
||||
<select
|
||||
value={String(rawValue ?? '')}
|
||||
onChange={(e) => onChange(section, field.key, e.target.value)}
|
||||
className={inputCls}
|
||||
>
|
||||
{field.opts.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
return (
|
||||
<div key={field.key} className={field.span ? 'md:col-span-2' : ''}>
|
||||
<label className="block text-xs text-gray-500 mb-1">{field.label}</label>
|
||||
<textarea
|
||||
value={String(rawValue ?? '')}
|
||||
onChange={(e) => onChange(section, field.key, field.nullable && e.target.value === '' ? null : e.target.value)}
|
||||
rows={3}
|
||||
className={`${inputCls} resize-none`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
return (
|
||||
<div key={field.key} className={field.span ? 'md:col-span-2' : ''}>
|
||||
<label className="block text-xs text-gray-500 mb-1">{field.label}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={rawValue === null || rawValue === undefined ? '' : String(rawValue)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
onChange(section, field.key, field.nullable && v === '' ? null : v === '' ? '' : Number(v))
|
||||
}}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// default: text / email
|
||||
return (
|
||||
<div key={field.key} className={field.span ? 'md:col-span-2' : ''}>
|
||||
<label className="block text-xs text-gray-500 mb-1">{field.label}</label>
|
||||
<input
|
||||
type={field.type === 'email' ? 'email' : 'text'}
|
||||
value={rawValue === null || rawValue === undefined ? '' : String(rawValue)}
|
||||
onChange={(e) => onChange(section, field.key, field.nullable && e.target.value === '' ? null : e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
'use client'
|
||||
|
||||
import { TemplateContext } from '../contextBridge'
|
||||
import { SECTION_LABELS, MODULE_LABELS } from '../_constants'
|
||||
import ContextSectionForm from './ContextSectionForm'
|
||||
|
||||
interface GeneratorPlaceholdersTabProps {
|
||||
placeholders: string[]
|
||||
relevantSections: (keyof TemplateContext)[]
|
||||
uncovered: string[]
|
||||
missing: string[]
|
||||
expandedSections: Set<string>
|
||||
toggleSection: (sec: string) => void
|
||||
context: TemplateContext
|
||||
onContextChange: (section: keyof TemplateContext, key: string, value: unknown) => void
|
||||
extraPlaceholders: Record<string, string>
|
||||
onExtraChange: (key: string, value: string) => void
|
||||
relevantModules: string[]
|
||||
enabledModules: string[]
|
||||
onModuleToggle: (mod: string, checked: boolean) => void
|
||||
onGotoPreview: () => void
|
||||
}
|
||||
|
||||
export default function GeneratorPlaceholdersTab({
|
||||
placeholders,
|
||||
relevantSections,
|
||||
uncovered,
|
||||
missing,
|
||||
expandedSections,
|
||||
toggleSection,
|
||||
context,
|
||||
onContextChange,
|
||||
extraPlaceholders,
|
||||
onExtraChange,
|
||||
relevantModules,
|
||||
enabledModules,
|
||||
onModuleToggle,
|
||||
onGotoPreview,
|
||||
}: GeneratorPlaceholdersTabProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{placeholders.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 text-center py-4">
|
||||
Keine Platzhalter — Vorlage kann direkt verwendet werden.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{relevantSections.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Alle Platzhalter müssen manuell befüllt werden.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{relevantSections.map((section) => (
|
||||
<div key={section} className="border border-gray-200 rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSection(section)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors text-left"
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-800">{SECTION_LABELS[section]}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-400 transition-transform ${expandedSections.has(section) ? '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>
|
||||
{expandedSections.has(section) && (
|
||||
<div className="p-4 border-t border-gray-100">
|
||||
<ContextSectionForm
|
||||
section={section}
|
||||
context={context}
|
||||
onChange={onContextChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uncovered.length > 0 && (
|
||||
<div className="border border-dashed border-gray-300 rounded-xl p-4">
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
|
||||
Weitere Platzhalter (manuell ausfüllen)
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{uncovered.map((ph) => (
|
||||
<div key={ph}>
|
||||
<label className="block text-xs text-gray-500 mb-1 font-mono">{ph}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={extraPlaceholders[ph] || ''}
|
||||
onChange={(e) => onExtraChange(ph, e.target.value)}
|
||||
placeholder={`Wert für ${ph}`}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-400"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{relevantModules.length > 0 && (
|
||||
<div className="border border-gray-200 rounded-xl p-4">
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">Module</p>
|
||||
<div className="space-y-2">
|
||||
{relevantModules.map((modId) => (
|
||||
<label key={modId} className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledModules.includes(modId)}
|
||||
onChange={(e) => onModuleToggle(modId, e.target.checked)}
|
||||
className="w-4 h-4 accent-purple-600"
|
||||
/>
|
||||
<span className="text-xs font-mono text-gray-600">{modId}</span>
|
||||
{MODULE_LABELS[modId] && (
|
||||
<span className="text-xs text-gray-500">{MODULE_LABELS[modId]}</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2 flex-wrap gap-3">
|
||||
<div>
|
||||
{missing.length > 0 ? (
|
||||
<span className="text-sm text-orange-600">
|
||||
⚠ {missing.length} Pflichtfeld{missing.length > 1 ? 'er' : ''} fehlt{missing.length === 1 ? '' : 'en'}
|
||||
<span className="ml-1 text-xs text-orange-400">
|
||||
({missing.map((m) => m.replace(/\{\{|\}\}/g, '')).slice(0, 3).join(', ')}{missing.length > 3 ? ` +${missing.length - 3}` : ''})
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-green-600">Alle Pflichtfelder ausgefüllt</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onGotoPreview}
|
||||
className="px-5 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Zur Vorschau →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import { LegalTemplateResult } from '@/lib/sdk/types'
|
||||
import { RuleEngineResult } from '../ruleEngine'
|
||||
|
||||
interface GeneratorPreviewTabProps {
|
||||
template: LegalTemplateResult
|
||||
ruleResult: RuleEngineResult | null
|
||||
renderedContent: string
|
||||
missing: string[]
|
||||
onCopy: () => void
|
||||
onExportMarkdown: () => void
|
||||
}
|
||||
|
||||
export default function GeneratorPreviewTab({
|
||||
template,
|
||||
ruleResult,
|
||||
renderedContent,
|
||||
missing,
|
||||
onCopy,
|
||||
onExportMarkdown,
|
||||
}: GeneratorPreviewTabProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{ruleResult && ruleResult.violations.length > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||
<p className="text-sm font-semibold text-red-700 mb-2">
|
||||
🔴 {ruleResult.violations.length} Fehler
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{ruleResult.violations.map((v) => (
|
||||
<li key={v.id} className="text-xs text-red-600">
|
||||
<span className="font-mono font-medium">[{v.id}]</span> {v.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{ruleResult && ruleResult.warnings.filter((w) => w.id !== 'WARN_LEGAL_REVIEW').length > 0 && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4">
|
||||
<ul className="space-y-1">
|
||||
{ruleResult.warnings
|
||||
.filter((w) => w.id !== 'WARN_LEGAL_REVIEW')
|
||||
.map((w) => (
|
||||
<li key={w.id} className="text-xs text-yellow-700">
|
||||
🟡 <span className="font-mono font-medium">[{w.id}]</span> {w.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{ruleResult && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3">
|
||||
<p className="text-xs text-blue-700">
|
||||
ℹ️ Rechtlicher Hinweis: Diese Vorlage ist MIT-lizenziert. Vor Produktionseinsatz
|
||||
wird eine rechtliche Überprüfung dringend empfohlen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{ruleResult && ruleResult.appliedDefaults.length > 0 && (
|
||||
<p className="text-xs text-gray-400">
|
||||
Defaults angewendet: {ruleResult.appliedDefaults.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<span className="text-sm text-gray-600">
|
||||
{missing.length > 0 && (
|
||||
<span className="text-orange-600">
|
||||
⚠ {missing.length} Platzhalter noch nicht ausgefüllt
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onCopy}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Kopieren
|
||||
</button>
|
||||
<button
|
||||
onClick={onExportMarkdown}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Markdown
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
||||
</svg>
|
||||
PDF drucken
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6 max-h-[600px] overflow-y-auto">
|
||||
<pre className="text-sm text-gray-800 whitespace-pre-wrap leading-relaxed font-sans">
|
||||
{renderedContent}
|
||||
</pre>
|
||||
</div>
|
||||
{template.attributionRequired && template.attributionText && (
|
||||
<div className="text-xs text-orange-600 bg-orange-50 p-3 rounded-lg border border-orange-200">
|
||||
<strong>Attribution erforderlich:</strong> {template.attributionText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { LegalTemplateResult } from '@/lib/sdk/types'
|
||||
import {
|
||||
TemplateContext,
|
||||
contextToPlaceholders, getRelevantSections,
|
||||
getUncoveredPlaceholders, getMissingRequired,
|
||||
} from '../contextBridge'
|
||||
import {
|
||||
runRuleset, getDocType, applyBlockRemoval,
|
||||
buildBoolContext, applyConditionalBlocks,
|
||||
type RuleInput, type RuleEngineResult,
|
||||
} from '../ruleEngine'
|
||||
import { MODULE_LABELS } from '../_constants'
|
||||
import GeneratorPlaceholdersTab from './GeneratorPlaceholdersTab'
|
||||
import GeneratorPreviewTab from './GeneratorPreviewTab'
|
||||
|
||||
export default function GeneratorSection({
|
||||
template,
|
||||
context,
|
||||
onContextChange,
|
||||
extraPlaceholders,
|
||||
onExtraChange,
|
||||
onClose,
|
||||
enabledModules,
|
||||
onModuleToggle,
|
||||
}: {
|
||||
template: LegalTemplateResult
|
||||
context: TemplateContext
|
||||
onContextChange: (section: keyof TemplateContext, key: string, value: unknown) => void
|
||||
extraPlaceholders: Record<string, string>
|
||||
onExtraChange: (key: string, value: string) => void
|
||||
onClose: () => void
|
||||
enabledModules: string[]
|
||||
onModuleToggle: (mod: string, checked: boolean) => void
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<'placeholders' | 'preview'>('placeholders')
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['PROVIDER', 'LEGAL']))
|
||||
|
||||
const placeholders = template.placeholders || []
|
||||
const relevantSections = useMemo(() => getRelevantSections(placeholders), [placeholders])
|
||||
const uncovered = useMemo(() => getUncoveredPlaceholders(placeholders, context), [placeholders, context])
|
||||
const missing = useMemo(() => getMissingRequired(placeholders, context), [placeholders, context])
|
||||
|
||||
// Rule engine evaluation
|
||||
const ruleResult = useMemo((): RuleEngineResult | null => {
|
||||
if (!template) return null
|
||||
return runRuleset({
|
||||
doc_type: getDocType(template.templateType ?? '', template.language ?? 'de'),
|
||||
render: { lang: template.language ?? 'de', variant: 'standard' },
|
||||
context,
|
||||
modules: { enabled: enabledModules },
|
||||
} satisfies RuleInput)
|
||||
}, [template, context, enabledModules])
|
||||
|
||||
const allPlaceholderValues = useMemo(() => ({
|
||||
...contextToPlaceholders(ruleResult?.contextAfterDefaults ?? context),
|
||||
...extraPlaceholders,
|
||||
}), [context, extraPlaceholders, ruleResult])
|
||||
|
||||
// Boolean context for {{#IF}} rendering
|
||||
const boolCtx = useMemo(
|
||||
() => ruleResult ? buildBoolContext(ruleResult.contextAfterDefaults, ruleResult.computedFlags) : {},
|
||||
[ruleResult]
|
||||
)
|
||||
|
||||
const renderedContent = useMemo(() => {
|
||||
// 1. Remove ruleset-driven blocks ([BLOCK:ID])
|
||||
let content = applyBlockRemoval(template.text, ruleResult?.removedBlocks ?? [])
|
||||
// 2. Evaluate {{#IF}} / {{#IF_NOT}} / {{#IF_ANY}} directives
|
||||
content = applyConditionalBlocks(content, boolCtx)
|
||||
// 3. Substitute placeholders
|
||||
for (const [key, value] of Object.entries(allPlaceholderValues)) {
|
||||
if (value) {
|
||||
content = content.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value)
|
||||
}
|
||||
}
|
||||
return content
|
||||
}, [template.text, allPlaceholderValues, ruleResult, boolCtx])
|
||||
|
||||
// Compute which modules are relevant (mentioned in violations/warnings)
|
||||
const relevantModules = useMemo(() => {
|
||||
if (!ruleResult) return []
|
||||
const mentioned = new Set<string>()
|
||||
const allIssues = [...ruleResult.violations, ...ruleResult.warnings]
|
||||
for (const issue of allIssues) {
|
||||
if (issue.phase === 'module_requirements') {
|
||||
// Extract module ID from message
|
||||
for (const modId of Object.keys(MODULE_LABELS)) {
|
||||
if (issue.message.includes(modId)) mentioned.add(modId)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also show modules that are enabled but not mentioned
|
||||
for (const mod of enabledModules) {
|
||||
if (mod in MODULE_LABELS) mentioned.add(mod)
|
||||
}
|
||||
return [...mentioned]
|
||||
}, [ruleResult, enabledModules])
|
||||
|
||||
const handleCopy = () => navigator.clipboard.writeText(renderedContent)
|
||||
|
||||
const handleExportMarkdown = () => {
|
||||
const blob = new Blob([renderedContent], { type: 'text/markdown' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${(template.documentTitle || 'dokument').replace(/\s+/g, '-').toLowerCase()}.md`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const toggleSection = (sec: string) => {
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(sec)) next.delete(sec)
|
||||
else next.add(sec)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Auto-expand all relevant sections on first render
|
||||
useEffect(() => {
|
||||
if (relevantSections.length > 0) {
|
||||
setExpandedSections(new Set(relevantSections))
|
||||
}
|
||||
}, [template.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Computed flags pills config
|
||||
const flagPills: { key: string; label: string; color: string }[] = ruleResult ? [
|
||||
{ key: 'IS_B2C', label: 'B2C', color: 'bg-blue-100 text-blue-700' },
|
||||
{ key: 'SERVICE_IS_SAAS', label: 'SaaS', color: 'bg-green-100 text-green-700' },
|
||||
{ key: 'HAS_PENALTY', label: 'Vertragsstrafe', color: 'bg-orange-100 text-orange-700' },
|
||||
{ key: 'HAS_ANALYTICS', label: 'Analytics', color: 'bg-gray-100 text-gray-600' },
|
||||
] : []
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border-2 border-purple-300 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-purple-50 px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<svg className="w-5 h-5 text-purple-600 shrink-0" 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>
|
||||
<div>
|
||||
<div className="text-xs text-purple-500 font-medium uppercase tracking-wide">Generator</div>
|
||||
<div className="font-semibold text-gray-900 text-sm">{template.documentTitle}</div>
|
||||
</div>
|
||||
{/* Computed flags pills */}
|
||||
{ruleResult && (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{flagPills.map(({ key, label, color }) =>
|
||||
ruleResult.computedFlags[key] ? (
|
||||
<span key={key} className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${color}`}>
|
||||
{label}
|
||||
</span>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors shrink-0" aria-label="Schließen">
|
||||
<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>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex border-b border-gray-200 px-6">
|
||||
{(['placeholders', 'preview'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
activeTab === tab
|
||||
? 'text-purple-600 border-purple-600'
|
||||
: 'text-gray-500 border-transparent hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab === 'placeholders' ? 'Kontext ausfüllen' : 'Vorschau & Export'}
|
||||
{tab === 'placeholders' && missing.length > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 text-[10px] bg-orange-100 text-orange-600 rounded-full">
|
||||
{missing.length}
|
||||
</span>
|
||||
)}
|
||||
{tab === 'preview' && ruleResult && ruleResult.violations.length > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 text-[10px] bg-red-100 text-red-600 rounded-full">
|
||||
{ruleResult.violations.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="p-6">
|
||||
{activeTab === 'placeholders' && (
|
||||
<GeneratorPlaceholdersTab
|
||||
placeholders={placeholders}
|
||||
relevantSections={relevantSections}
|
||||
uncovered={uncovered}
|
||||
missing={missing}
|
||||
expandedSections={expandedSections}
|
||||
toggleSection={toggleSection}
|
||||
context={context}
|
||||
onContextChange={onContextChange}
|
||||
extraPlaceholders={extraPlaceholders}
|
||||
onExtraChange={onExtraChange}
|
||||
relevantModules={relevantModules}
|
||||
enabledModules={enabledModules}
|
||||
onModuleToggle={onModuleToggle}
|
||||
onGotoPreview={() => setActiveTab('preview')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'preview' && (
|
||||
<GeneratorPreviewTab
|
||||
template={template}
|
||||
ruleResult={ruleResult}
|
||||
renderedContent={renderedContent}
|
||||
missing={missing}
|
||||
onCopy={handleCopy}
|
||||
onExportMarkdown={handleExportMarkdown}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client'
|
||||
|
||||
import { LegalTemplateResult, LicenseType, TemplateType, TEMPLATE_TYPE_LABELS } from '@/lib/sdk/types'
|
||||
import LicenseBadge from './LicenseBadge'
|
||||
|
||||
export default function LibraryCard({
|
||||
template,
|
||||
expanded,
|
||||
onTogglePreview,
|
||||
onUse,
|
||||
}: {
|
||||
template: LegalTemplateResult
|
||||
expanded: boolean
|
||||
onTogglePreview: () => void
|
||||
onUse: () => void
|
||||
}) {
|
||||
const typeLabel = template.templateType
|
||||
? (TEMPLATE_TYPE_LABELS[template.templateType as TemplateType] || template.templateType)
|
||||
: null
|
||||
const placeholderCount = template.placeholders?.length ?? 0
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden hover:border-purple-300 transition-colors">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<h3 className="font-medium text-gray-900 text-sm leading-snug">
|
||||
{template.documentTitle || 'Vorlage'}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400 uppercase shrink-0">{template.language}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap mb-3">
|
||||
{typeLabel && (
|
||||
<span className="text-xs text-purple-600 bg-purple-50 px-2 py-0.5 rounded">
|
||||
{typeLabel}
|
||||
</span>
|
||||
)}
|
||||
<LicenseBadge licenseId={template.licenseId as LicenseType} small />
|
||||
{placeholderCount > 0 && (
|
||||
<span className="text-xs text-gray-500">{placeholderCount} Platzh.</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onTogglePreview}
|
||||
className="flex-1 text-xs px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors"
|
||||
>
|
||||
{expanded ? 'Vorschau ▲' : 'Vorschau ▼'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onUse}
|
||||
className="flex-1 text-xs px-3 py-1.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Verwenden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-gray-100 bg-gray-50 p-4 max-h-[32rem] overflow-y-auto">
|
||||
<pre className="text-xs text-gray-600 whitespace-pre-wrap font-mono leading-relaxed">
|
||||
{template.text}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { LicenseType, LICENSE_TYPE_LABELS } from '@/lib/sdk/types'
|
||||
|
||||
export default function LicenseBadge({
|
||||
licenseId,
|
||||
small = false,
|
||||
}: {
|
||||
licenseId: LicenseType | null
|
||||
small?: boolean
|
||||
}) {
|
||||
if (!licenseId) return null
|
||||
const colors: Partial<Record<LicenseType, string>> = {
|
||||
public_domain: 'bg-green-100 text-green-700 border-green-200',
|
||||
cc0: 'bg-green-100 text-green-700 border-green-200',
|
||||
unlicense: 'bg-green-100 text-green-700 border-green-200',
|
||||
mit: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
cc_by_4: 'bg-purple-100 text-purple-700 border-purple-200',
|
||||
reuse_notice: 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
}
|
||||
return (
|
||||
<span className={`${small ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-1 text-xs'} rounded border ${colors[licenseId] || 'bg-gray-100 text-gray-600 border-gray-200'}`}>
|
||||
{LICENSE_TYPE_LABELS[licenseId] || licenseId}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import { LegalTemplateResult } from '@/lib/sdk/types'
|
||||
import { CATEGORIES } from '../_constants'
|
||||
import LibraryCard from './LibraryCard'
|
||||
|
||||
interface TemplateLibraryProps {
|
||||
allTemplates: LegalTemplateResult[]
|
||||
filteredTemplates: LegalTemplateResult[]
|
||||
isLoadingLibrary: boolean
|
||||
activeCategory: string
|
||||
onCategoryChange: (cat: string) => void
|
||||
activeLanguage: 'all' | 'de' | 'en'
|
||||
onLanguageChange: (lang: 'all' | 'de' | 'en') => void
|
||||
librarySearch: string
|
||||
onSearchChange: (q: string) => void
|
||||
expandedPreviewId: string | null
|
||||
onTogglePreview: (id: string) => void
|
||||
onUseTemplate: (t: LegalTemplateResult) => void
|
||||
}
|
||||
|
||||
export default function TemplateLibrary({
|
||||
allTemplates,
|
||||
filteredTemplates,
|
||||
isLoadingLibrary,
|
||||
activeCategory,
|
||||
onCategoryChange,
|
||||
activeLanguage,
|
||||
onLanguageChange,
|
||||
librarySearch,
|
||||
onSearchChange,
|
||||
expandedPreviewId,
|
||||
onTogglePreview,
|
||||
onUseTemplate,
|
||||
}: TemplateLibraryProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between flex-wrap gap-3">
|
||||
<h2 className="font-semibold text-gray-900">Template-Bibliothek</h2>
|
||||
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
|
||||
{(['all', 'de', 'en'] as const).map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => onLanguageChange(lang)}
|
||||
className={`px-3 py-1.5 text-xs rounded-md font-medium transition-colors ${
|
||||
activeLanguage === lang
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{lang === 'all' ? 'Alle' : lang.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category pills */}
|
||||
<div className="px-6 py-3 border-b border-gray-100 flex gap-2 flex-wrap">
|
||||
{CATEGORIES.map((cat) => {
|
||||
const count = cat.types === null
|
||||
? allTemplates.filter(t => activeLanguage === 'all' || t.language === activeLanguage).length
|
||||
: allTemplates.filter(t =>
|
||||
cat.types!.includes(t.templateType || '') &&
|
||||
(activeLanguage === 'all' || t.language === activeLanguage)
|
||||
).length
|
||||
return (
|
||||
<button
|
||||
key={cat.key}
|
||||
onClick={() => onCategoryChange(cat.key)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
activeCategory === cat.key
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{cat.label}
|
||||
{count > 0 && (
|
||||
<span className={`ml-1.5 ${activeCategory === cat.key ? 'text-purple-200' : 'text-gray-400'}`}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-6 py-3 border-b border-gray-100">
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={librarySearch}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Vorlage suchen... (optional)"
|
||||
className="w-full pl-9 pr-4 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-400"
|
||||
/>
|
||||
{librarySearch && (
|
||||
<button
|
||||
onClick={() => onSearchChange('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template grid */}
|
||||
<div className="p-6">
|
||||
{isLoadingLibrary ? (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<div className="text-4xl mb-3">📄</div>
|
||||
<p>Keine Vorlagen für diese Auswahl</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredTemplates.map((template) => (
|
||||
<LibraryCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
expanded={expandedPreviewId === template.id}
|
||||
onTogglePreview={() => onTogglePreview(template.id)}
|
||||
onUse={() => onUseTemplate(template)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
192
admin-compliance/app/sdk/document-generator/_constants.ts
Normal file
192
admin-compliance/app/sdk/document-generator/_constants.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { TemplateContext } from './contextBridge'
|
||||
|
||||
// =============================================================================
|
||||
// CATEGORY CONFIG
|
||||
// =============================================================================
|
||||
|
||||
export const CATEGORIES: { key: string; label: string; types: string[] | null }[] = [
|
||||
{ key: 'all', label: 'Alle', types: null },
|
||||
{ key: 'privacy_policy', label: 'Datenschutz', types: ['privacy_policy'] },
|
||||
{ key: 'terms', label: 'AGB', types: ['terms_of_service', 'agb', 'clause'] },
|
||||
{ key: 'impressum', label: 'Impressum', types: ['impressum'] },
|
||||
{ key: 'dpa', label: 'AVV/DPA', types: ['dpa'] },
|
||||
{ key: 'nda', label: 'NDA', types: ['nda'] },
|
||||
{ key: 'sla', label: 'SLA', types: ['sla'] },
|
||||
{ key: 'acceptable_use', label: 'AUP', types: ['acceptable_use'] },
|
||||
{ key: 'widerruf', label: 'Widerruf', types: ['widerruf'] },
|
||||
{ key: 'cookie', label: 'Cookie', types: ['cookie_policy', 'cookie_banner'] },
|
||||
{ key: 'cloud', label: 'Cloud', types: ['cloud_service_agreement'] },
|
||||
{ key: 'misc', label: 'Weitere', types: ['community_guidelines', 'copyright_policy', 'data_usage_clause'] },
|
||||
{ key: 'dsfa', label: 'DSFA', types: ['dsfa'] },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// CONTEXT FORM CONFIG
|
||||
// =============================================================================
|
||||
|
||||
export const SECTION_LABELS: Record<keyof TemplateContext, string> = {
|
||||
PROVIDER: 'Anbieter',
|
||||
CUSTOMER: 'Kunde / Gegenpartei',
|
||||
SERVICE: 'Dienst / Produkt',
|
||||
LEGAL: 'Rechtliches',
|
||||
PRIVACY: 'Datenschutz',
|
||||
SLA: 'Service Level (SLA)',
|
||||
PAYMENTS: 'Zahlungskonditionen',
|
||||
SECURITY: 'Sicherheit & Logs',
|
||||
NDA: 'Geheimhaltung (NDA)',
|
||||
CONSENT: 'Cookie / Einwilligung',
|
||||
HOSTING: 'Hosting-Provider',
|
||||
FEATURES: 'Dokument-Features & Textbausteine',
|
||||
}
|
||||
|
||||
export type FieldType = 'text' | 'email' | 'number' | 'select' | 'textarea' | 'boolean'
|
||||
export interface FieldDef {
|
||||
key: string
|
||||
label: string
|
||||
type?: FieldType
|
||||
opts?: string[]
|
||||
span?: boolean
|
||||
nullable?: boolean
|
||||
}
|
||||
|
||||
export const SECTION_FIELDS: Record<keyof TemplateContext, FieldDef[]> = {
|
||||
PROVIDER: [
|
||||
{ key: 'LEGAL_NAME', label: 'Firmenname' },
|
||||
{ key: 'EMAIL', label: 'Kontakt-E-Mail', type: 'email' },
|
||||
{ key: 'LEGAL_FORM', label: 'Rechtsform' },
|
||||
{ key: 'ADDRESS_LINE', label: 'Adresse' },
|
||||
{ key: 'POSTAL_CODE', label: 'PLZ' },
|
||||
{ key: 'CITY', label: 'Stadt' },
|
||||
{ key: 'WEBSITE_URL', label: 'Website-URL' },
|
||||
{ key: 'CEO_NAME', label: 'Geschäftsführer' },
|
||||
{ key: 'REGISTER_COURT', label: 'Registergericht' },
|
||||
{ key: 'REGISTER_NUMBER', label: 'HRB-Nummer' },
|
||||
{ key: 'VAT_ID', label: 'USt-ID' },
|
||||
{ key: 'PHONE', label: 'Telefon' },
|
||||
],
|
||||
CUSTOMER: [
|
||||
{ key: 'LEGAL_NAME', label: 'Name / Firma' },
|
||||
{ key: 'EMAIL', label: 'E-Mail', type: 'email' },
|
||||
{ key: 'CONTACT_NAME', label: 'Ansprechpartner' },
|
||||
{ key: 'ADDRESS_LINE', label: 'Adresse' },
|
||||
{ key: 'POSTAL_CODE', label: 'PLZ' },
|
||||
{ key: 'CITY', label: 'Stadt' },
|
||||
{ key: 'COUNTRY', label: 'Land' },
|
||||
{ key: 'IS_CONSUMER', label: 'Verbraucher (B2C)', type: 'boolean' },
|
||||
{ key: 'IS_BUSINESS', label: 'Unternehmer (B2B)', type: 'boolean' },
|
||||
],
|
||||
SERVICE: [
|
||||
{ key: 'NAME', label: 'Dienstname' },
|
||||
{ key: 'DESCRIPTION', label: 'Beschreibung', type: 'textarea', span: true },
|
||||
{ key: 'MODEL', label: 'Modell', type: 'select', opts: ['SaaS', 'PaaS', 'IaaS', 'OnPrem', 'Hybrid'] },
|
||||
{ key: 'TIER', label: 'Plan / Tier' },
|
||||
{ key: 'DATA_LOCATION', label: 'Datenspeicherort' },
|
||||
{ key: 'EXPORT_WINDOW_DAYS', label: 'Export-Frist (Tage)', type: 'number' },
|
||||
{ key: 'MIN_TERM_MONTHS', label: 'Mindestlaufzeit (Monate)', type: 'number' },
|
||||
{ key: 'TERMINATION_NOTICE_DAYS', label: 'Kündigungsfrist (Tage)', type: 'number' },
|
||||
],
|
||||
LEGAL: [
|
||||
{ key: 'GOVERNING_LAW', label: 'Anwendbares Recht' },
|
||||
{ key: 'JURISDICTION_CITY', label: 'Gerichtsstand (Stadt)' },
|
||||
{ key: 'VERSION_DATE', label: 'Versionsstand (JJJJ-MM-TT)' },
|
||||
{ key: 'EFFECTIVE_DATE', label: 'Gültig ab (JJJJ-MM-TT)' },
|
||||
],
|
||||
PRIVACY: [
|
||||
{ key: 'DPO_NAME', label: 'DSB-Name' },
|
||||
{ key: 'DPO_EMAIL', label: 'DSB-E-Mail', type: 'email' },
|
||||
{ key: 'CONTACT_EMAIL', label: 'Datenschutz-Kontakt', type: 'email' },
|
||||
{ key: 'PRIVACY_POLICY_URL', label: 'Datenschutz-URL' },
|
||||
{ key: 'COOKIE_POLICY_URL', label: 'Cookie-Policy-URL' },
|
||||
{ key: 'ANALYTICS_RETENTION_MONTHS', label: 'Analytics-Aufbewahrung (Monate)', type: 'number' },
|
||||
{ key: 'SUPERVISORY_AUTHORITY_NAME', label: 'Aufsichtsbehörde' },
|
||||
],
|
||||
SLA: [
|
||||
{ key: 'AVAILABILITY_PERCENT', label: 'Verfügbarkeit (%)', type: 'number' },
|
||||
{ key: 'MAINTENANCE_NOTICE_HOURS', label: 'Wartungsankündigung (h)', type: 'number' },
|
||||
{ key: 'SUPPORT_EMAIL', label: 'Support-E-Mail', type: 'email' },
|
||||
{ key: 'SUPPORT_HOURS', label: 'Support-Zeiten' },
|
||||
{ key: 'RESPONSE_CRITICAL_H', label: 'Reaktion Kritisch (h)', type: 'number' },
|
||||
{ key: 'RESOLUTION_CRITICAL_H', label: 'Lösung Kritisch (h)', type: 'number' },
|
||||
{ key: 'RESPONSE_HIGH_H', label: 'Reaktion Hoch (h)', type: 'number' },
|
||||
{ key: 'RESOLUTION_HIGH_H', label: 'Lösung Hoch (h)', type: 'number' },
|
||||
{ key: 'RESPONSE_MEDIUM_H', label: 'Reaktion Mittel (h)', type: 'number' },
|
||||
{ key: 'RESOLUTION_MEDIUM_H', label: 'Lösung Mittel (h)', type: 'number' },
|
||||
{ key: 'RESPONSE_LOW_H', label: 'Reaktion Niedrig (h)', type: 'number' },
|
||||
],
|
||||
PAYMENTS: [
|
||||
{ key: 'MONTHLY_FEE_EUR', label: 'Monatl. Gebühr (EUR)', type: 'number' },
|
||||
{ key: 'PAYMENT_DUE_DAY', label: 'Fälligkeitstag', type: 'number' },
|
||||
{ key: 'PAYMENT_METHOD', label: 'Zahlungsmethode' },
|
||||
{ key: 'PAYMENT_DAYS', label: 'Zahlungsziel (Tage)', type: 'number' },
|
||||
],
|
||||
SECURITY: [
|
||||
{ key: 'INCIDENT_NOTICE_HOURS', label: 'Meldepflicht Vorfälle (h)', type: 'number' },
|
||||
{ key: 'LOG_RETENTION_DAYS', label: 'Log-Aufbewahrung (Tage)', type: 'number' },
|
||||
{ key: 'SECURITY_LOG_RETENTION_DAYS', label: 'Sicherheits-Log (Tage)', type: 'number' },
|
||||
],
|
||||
NDA: [
|
||||
{ key: 'PURPOSE', label: 'Zweck', type: 'textarea', span: true },
|
||||
{ key: 'DURATION_YEARS', label: 'Laufzeit (Jahre)', type: 'number' },
|
||||
{ key: 'PENALTY_AMOUNT_EUR', label: 'Vertragsstrafe EUR (leer = keine)', type: 'number', nullable: true },
|
||||
],
|
||||
CONSENT: [
|
||||
{ key: 'WEBSITE_NAME', label: 'Website-Name' },
|
||||
{ key: 'ANALYTICS_TOOLS', label: 'Analytics-Tools (leer = kein Block)', nullable: true },
|
||||
{ key: 'MARKETING_PARTNERS', label: 'Marketing-Partner (leer = kein Block)', nullable: true },
|
||||
],
|
||||
HOSTING: [
|
||||
{ key: 'PROVIDER_NAME', label: 'Hosting-Anbieter' },
|
||||
{ key: 'COUNTRY', label: 'Hosting-Land' },
|
||||
{ key: 'CONTRACT_TYPE', label: 'Vertragstyp (z. B. AVV nach Art. 28 DSGVO)' },
|
||||
],
|
||||
FEATURES: [
|
||||
// ── DSI / Cookie ─────────────────────────────────────────────────────────
|
||||
{ key: 'CONSENT_WITHDRAWAL_PATH', label: 'Einwilligungs-Widerrufspfad' },
|
||||
{ key: 'SECURITY_MEASURES_SUMMARY', label: 'Sicherheitsmaßnahmen (kurz)' },
|
||||
{ key: 'DATA_SUBJECT_REQUEST_CHANNEL', label: 'Kanal für Betroffenenanfragen' },
|
||||
{ key: 'HAS_THIRD_COUNTRY', label: 'Drittlandübermittlung möglich', type: 'boolean' },
|
||||
{ key: 'TRANSFER_GUARDS', label: 'Garantien (z. B. SCC)' },
|
||||
// ── Cookie/Consent ───────────────────────────────────────────────────────
|
||||
{ key: 'HAS_FUNCTIONAL_COOKIES', label: 'Funktionale Cookies aktiviert', type: 'boolean' },
|
||||
{ key: 'CMP_NAME', label: 'Consent-Manager-Name (optional)' },
|
||||
{ key: 'CMP_LOGS_CONSENTS', label: 'Consent-Protokollierung aktiv', type: 'boolean' },
|
||||
{ key: 'ANALYTICS_TOOLS_DETAIL', label: 'Analyse-Tools (Detailtext)', type: 'textarea', span: true },
|
||||
{ key: 'MARKETING_TOOLS_DETAIL', label: 'Marketing-Tools (Detailtext)', type: 'textarea', span: true },
|
||||
// ── Service-Features ─────────────────────────────────────────────────────
|
||||
{ key: 'HAS_ACCOUNT', label: 'Nutzerkonten vorhanden', type: 'boolean' },
|
||||
{ key: 'HAS_PAYMENTS', label: 'Zahlungsabwicklung vorhanden', type: 'boolean' },
|
||||
{ key: 'PAYMENT_PROVIDER_DETAIL', label: 'Zahlungsanbieter (Detailtext)', type: 'textarea', span: true },
|
||||
{ key: 'HAS_SUPPORT', label: 'Support-Funktion vorhanden', type: 'boolean' },
|
||||
{ key: 'SUPPORT_CHANNELS_TEXT', label: 'Support-Kanäle / Zeiten' },
|
||||
{ key: 'HAS_NEWSLETTER', label: 'Newsletter vorhanden', type: 'boolean' },
|
||||
{ key: 'NEWSLETTER_PROVIDER_DETAIL', label: 'Newsletter-Anbieter (Detailtext)', type: 'textarea', span: true },
|
||||
{ key: 'HAS_SOCIAL_MEDIA', label: 'Social-Media-Präsenz', type: 'boolean' },
|
||||
{ key: 'SOCIAL_MEDIA_DETAIL', label: 'Social-Media-Details', type: 'textarea', span: true },
|
||||
// ── AGB ──────────────────────────────────────────────────────────────────
|
||||
{ key: 'HAS_PAID_PLANS', label: 'Kostenpflichtige Pläne', type: 'boolean' },
|
||||
{ key: 'PRICES_TEXT', label: 'Preise (Text/Link)', type: 'textarea', span: true },
|
||||
{ key: 'PAYMENT_TERMS_TEXT', label: 'Zahlungsbedingungen', type: 'textarea', span: true },
|
||||
{ key: 'CONTRACT_TERM_TEXT', label: 'Laufzeit & Kündigung', type: 'textarea', span: true },
|
||||
{ key: 'HAS_SLA', label: 'SLA vorhanden', type: 'boolean' },
|
||||
{ key: 'SLA_URL', label: 'SLA-URL' },
|
||||
{ key: 'HAS_EXPORT_POLICY', label: 'Datenexport/Löschung geregelt', type: 'boolean' },
|
||||
{ key: 'EXPORT_POLICY_TEXT', label: 'Datenexport-Regelung (Text)', type: 'textarea', span: true },
|
||||
{ key: 'HAS_WITHDRAWAL', label: 'Widerrufsrecht (B2C digital)', type: 'boolean' },
|
||||
{ key: 'CONSUMER_WITHDRAWAL_TEXT', label: 'Widerrufsbelehrung (Text)', type: 'textarea', span: true },
|
||||
{ key: 'LIMITATION_CAP_TEXT', label: 'Haftungsdeckel B2B (Text)' },
|
||||
// ── Impressum ────────────────────────────────────────────────────────────
|
||||
{ key: 'HAS_REGULATED_PROFESSION', label: 'Reglementierter Beruf', type: 'boolean' },
|
||||
{ key: 'REGULATED_PROFESSION_TEXT', label: 'Berufsrecht-Text', type: 'textarea', span: true },
|
||||
{ key: 'HAS_EDITORIAL_RESPONSIBLE', label: 'V.i.S.d.P. (redaktionell)', type: 'boolean' },
|
||||
{ key: 'EDITORIAL_RESPONSIBLE_NAME', label: 'V.i.S.d.P. Name' },
|
||||
{ key: 'EDITORIAL_RESPONSIBLE_ADDRESS', label: 'V.i.S.d.P. Adresse' },
|
||||
{ key: 'HAS_DISPUTE_RESOLUTION', label: 'Streitbeilegungshinweis', type: 'boolean' },
|
||||
{ key: 'DISPUTE_RESOLUTION_TEXT', label: 'Streitbeilegungstext', type: 'textarea', span: true },
|
||||
],
|
||||
}
|
||||
|
||||
// Available module definitions (id → display label)
|
||||
export const MODULE_LABELS: Record<string, string> = {
|
||||
CLOUD_EXPORT_DELETE_DE: 'Datenexport & Löschrecht',
|
||||
B2C_WITHDRAWAL_DE: 'Widerrufsrecht (B2C)',
|
||||
}
|
||||
@@ -1,811 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { useEinwilligungen, EinwilligungenProvider } from '@/lib/sdk/einwilligungen/context'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import {
|
||||
LegalTemplateResult,
|
||||
TemplateType,
|
||||
LicenseType,
|
||||
TEMPLATE_TYPE_LABELS,
|
||||
LICENSE_TYPE_LABELS,
|
||||
} from '@/lib/sdk/types'
|
||||
import { LegalTemplateResult } from '@/lib/sdk/types'
|
||||
import { DataPointsPreview } from './components/DataPointsPreview'
|
||||
import { DocumentValidation } from './components/DocumentValidation'
|
||||
import { generateAllPlaceholders } from '@/lib/sdk/document-generator/datapoint-helpers'
|
||||
import { loadAllTemplates } from './searchTemplates'
|
||||
import {
|
||||
TemplateContext, EMPTY_CONTEXT,
|
||||
contextToPlaceholders, getRelevantSections,
|
||||
getUncoveredPlaceholders, getMissingRequired,
|
||||
} from './contextBridge'
|
||||
import {
|
||||
runRuleset, getDocType, applyBlockRemoval,
|
||||
buildBoolContext, applyConditionalBlocks,
|
||||
type RuleInput, type RuleEngineResult,
|
||||
} from './ruleEngine'
|
||||
|
||||
// =============================================================================
|
||||
// CATEGORY CONFIG
|
||||
// =============================================================================
|
||||
|
||||
const CATEGORIES: { key: string; label: string; types: string[] | null }[] = [
|
||||
{ key: 'all', label: 'Alle', types: null },
|
||||
{ key: 'privacy_policy', label: 'Datenschutz', types: ['privacy_policy'] },
|
||||
{ key: 'terms', label: 'AGB', types: ['terms_of_service', 'agb', 'clause'] },
|
||||
{ key: 'impressum', label: 'Impressum', types: ['impressum'] },
|
||||
{ key: 'dpa', label: 'AVV/DPA', types: ['dpa'] },
|
||||
{ key: 'nda', label: 'NDA', types: ['nda'] },
|
||||
{ key: 'sla', label: 'SLA', types: ['sla'] },
|
||||
{ key: 'acceptable_use', label: 'AUP', types: ['acceptable_use'] },
|
||||
{ key: 'widerruf', label: 'Widerruf', types: ['widerruf'] },
|
||||
{ key: 'cookie', label: 'Cookie', types: ['cookie_policy', 'cookie_banner'] },
|
||||
{ key: 'cloud', label: 'Cloud', types: ['cloud_service_agreement'] },
|
||||
{ key: 'misc', label: 'Weitere', types: ['community_guidelines', 'copyright_policy', 'data_usage_clause'] },
|
||||
{ key: 'dsfa', label: 'DSFA', types: ['dsfa'] },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// CONTEXT FORM CONFIG
|
||||
// =============================================================================
|
||||
|
||||
const SECTION_LABELS: Record<keyof TemplateContext, string> = {
|
||||
PROVIDER: 'Anbieter',
|
||||
CUSTOMER: 'Kunde / Gegenpartei',
|
||||
SERVICE: 'Dienst / Produkt',
|
||||
LEGAL: 'Rechtliches',
|
||||
PRIVACY: 'Datenschutz',
|
||||
SLA: 'Service Level (SLA)',
|
||||
PAYMENTS: 'Zahlungskonditionen',
|
||||
SECURITY: 'Sicherheit & Logs',
|
||||
NDA: 'Geheimhaltung (NDA)',
|
||||
CONSENT: 'Cookie / Einwilligung',
|
||||
HOSTING: 'Hosting-Provider',
|
||||
FEATURES: 'Dokument-Features & Textbausteine',
|
||||
}
|
||||
|
||||
type FieldType = 'text' | 'email' | 'number' | 'select' | 'textarea' | 'boolean'
|
||||
interface FieldDef {
|
||||
key: string
|
||||
label: string
|
||||
type?: FieldType
|
||||
opts?: string[]
|
||||
span?: boolean
|
||||
nullable?: boolean
|
||||
}
|
||||
|
||||
const SECTION_FIELDS: Record<keyof TemplateContext, FieldDef[]> = {
|
||||
PROVIDER: [
|
||||
{ key: 'LEGAL_NAME', label: 'Firmenname' },
|
||||
{ key: 'EMAIL', label: 'Kontakt-E-Mail', type: 'email' },
|
||||
{ key: 'LEGAL_FORM', label: 'Rechtsform' },
|
||||
{ key: 'ADDRESS_LINE', label: 'Adresse' },
|
||||
{ key: 'POSTAL_CODE', label: 'PLZ' },
|
||||
{ key: 'CITY', label: 'Stadt' },
|
||||
{ key: 'WEBSITE_URL', label: 'Website-URL' },
|
||||
{ key: 'CEO_NAME', label: 'Geschäftsführer' },
|
||||
{ key: 'REGISTER_COURT', label: 'Registergericht' },
|
||||
{ key: 'REGISTER_NUMBER', label: 'HRB-Nummer' },
|
||||
{ key: 'VAT_ID', label: 'USt-ID' },
|
||||
{ key: 'PHONE', label: 'Telefon' },
|
||||
],
|
||||
CUSTOMER: [
|
||||
{ key: 'LEGAL_NAME', label: 'Name / Firma' },
|
||||
{ key: 'EMAIL', label: 'E-Mail', type: 'email' },
|
||||
{ key: 'CONTACT_NAME', label: 'Ansprechpartner' },
|
||||
{ key: 'ADDRESS_LINE', label: 'Adresse' },
|
||||
{ key: 'POSTAL_CODE', label: 'PLZ' },
|
||||
{ key: 'CITY', label: 'Stadt' },
|
||||
{ key: 'COUNTRY', label: 'Land' },
|
||||
{ key: 'IS_CONSUMER', label: 'Verbraucher (B2C)', type: 'boolean' },
|
||||
{ key: 'IS_BUSINESS', label: 'Unternehmer (B2B)', type: 'boolean' },
|
||||
],
|
||||
SERVICE: [
|
||||
{ key: 'NAME', label: 'Dienstname' },
|
||||
{ key: 'DESCRIPTION', label: 'Beschreibung', type: 'textarea', span: true },
|
||||
{ key: 'MODEL', label: 'Modell', type: 'select', opts: ['SaaS', 'PaaS', 'IaaS', 'OnPrem', 'Hybrid'] },
|
||||
{ key: 'TIER', label: 'Plan / Tier' },
|
||||
{ key: 'DATA_LOCATION', label: 'Datenspeicherort' },
|
||||
{ key: 'EXPORT_WINDOW_DAYS', label: 'Export-Frist (Tage)', type: 'number' },
|
||||
{ key: 'MIN_TERM_MONTHS', label: 'Mindestlaufzeit (Monate)', type: 'number' },
|
||||
{ key: 'TERMINATION_NOTICE_DAYS', label: 'Kündigungsfrist (Tage)', type: 'number' },
|
||||
],
|
||||
LEGAL: [
|
||||
{ key: 'GOVERNING_LAW', label: 'Anwendbares Recht' },
|
||||
{ key: 'JURISDICTION_CITY', label: 'Gerichtsstand (Stadt)' },
|
||||
{ key: 'VERSION_DATE', label: 'Versionsstand (JJJJ-MM-TT)' },
|
||||
{ key: 'EFFECTIVE_DATE', label: 'Gültig ab (JJJJ-MM-TT)' },
|
||||
],
|
||||
PRIVACY: [
|
||||
{ key: 'DPO_NAME', label: 'DSB-Name' },
|
||||
{ key: 'DPO_EMAIL', label: 'DSB-E-Mail', type: 'email' },
|
||||
{ key: 'CONTACT_EMAIL', label: 'Datenschutz-Kontakt', type: 'email' },
|
||||
{ key: 'PRIVACY_POLICY_URL', label: 'Datenschutz-URL' },
|
||||
{ key: 'COOKIE_POLICY_URL', label: 'Cookie-Policy-URL' },
|
||||
{ key: 'ANALYTICS_RETENTION_MONTHS', label: 'Analytics-Aufbewahrung (Monate)', type: 'number' },
|
||||
{ key: 'SUPERVISORY_AUTHORITY_NAME', label: 'Aufsichtsbehörde' },
|
||||
],
|
||||
SLA: [
|
||||
{ key: 'AVAILABILITY_PERCENT', label: 'Verfügbarkeit (%)', type: 'number' },
|
||||
{ key: 'MAINTENANCE_NOTICE_HOURS', label: 'Wartungsankündigung (h)', type: 'number' },
|
||||
{ key: 'SUPPORT_EMAIL', label: 'Support-E-Mail', type: 'email' },
|
||||
{ key: 'SUPPORT_HOURS', label: 'Support-Zeiten' },
|
||||
{ key: 'RESPONSE_CRITICAL_H', label: 'Reaktion Kritisch (h)', type: 'number' },
|
||||
{ key: 'RESOLUTION_CRITICAL_H', label: 'Lösung Kritisch (h)', type: 'number' },
|
||||
{ key: 'RESPONSE_HIGH_H', label: 'Reaktion Hoch (h)', type: 'number' },
|
||||
{ key: 'RESOLUTION_HIGH_H', label: 'Lösung Hoch (h)', type: 'number' },
|
||||
{ key: 'RESPONSE_MEDIUM_H', label: 'Reaktion Mittel (h)', type: 'number' },
|
||||
{ key: 'RESOLUTION_MEDIUM_H', label: 'Lösung Mittel (h)', type: 'number' },
|
||||
{ key: 'RESPONSE_LOW_H', label: 'Reaktion Niedrig (h)', type: 'number' },
|
||||
],
|
||||
PAYMENTS: [
|
||||
{ key: 'MONTHLY_FEE_EUR', label: 'Monatl. Gebühr (EUR)', type: 'number' },
|
||||
{ key: 'PAYMENT_DUE_DAY', label: 'Fälligkeitstag', type: 'number' },
|
||||
{ key: 'PAYMENT_METHOD', label: 'Zahlungsmethode' },
|
||||
{ key: 'PAYMENT_DAYS', label: 'Zahlungsziel (Tage)', type: 'number' },
|
||||
],
|
||||
SECURITY: [
|
||||
{ key: 'INCIDENT_NOTICE_HOURS', label: 'Meldepflicht Vorfälle (h)', type: 'number' },
|
||||
{ key: 'LOG_RETENTION_DAYS', label: 'Log-Aufbewahrung (Tage)', type: 'number' },
|
||||
{ key: 'SECURITY_LOG_RETENTION_DAYS', label: 'Sicherheits-Log (Tage)', type: 'number' },
|
||||
],
|
||||
NDA: [
|
||||
{ key: 'PURPOSE', label: 'Zweck', type: 'textarea', span: true },
|
||||
{ key: 'DURATION_YEARS', label: 'Laufzeit (Jahre)', type: 'number' },
|
||||
{ key: 'PENALTY_AMOUNT_EUR', label: 'Vertragsstrafe EUR (leer = keine)', type: 'number', nullable: true },
|
||||
],
|
||||
CONSENT: [
|
||||
{ key: 'WEBSITE_NAME', label: 'Website-Name' },
|
||||
{ key: 'ANALYTICS_TOOLS', label: 'Analytics-Tools (leer = kein Block)', nullable: true },
|
||||
{ key: 'MARKETING_PARTNERS', label: 'Marketing-Partner (leer = kein Block)', nullable: true },
|
||||
],
|
||||
HOSTING: [
|
||||
{ key: 'PROVIDER_NAME', label: 'Hosting-Anbieter' },
|
||||
{ key: 'COUNTRY', label: 'Hosting-Land' },
|
||||
{ key: 'CONTRACT_TYPE', label: 'Vertragstyp (z. B. AVV nach Art. 28 DSGVO)' },
|
||||
],
|
||||
FEATURES: [
|
||||
// ── DSI / Cookie ─────────────────────────────────────────────────────────
|
||||
{ key: 'CONSENT_WITHDRAWAL_PATH', label: 'Einwilligungs-Widerrufspfad' },
|
||||
{ key: 'SECURITY_MEASURES_SUMMARY', label: 'Sicherheitsmaßnahmen (kurz)' },
|
||||
{ key: 'DATA_SUBJECT_REQUEST_CHANNEL', label: 'Kanal für Betroffenenanfragen' },
|
||||
{ key: 'HAS_THIRD_COUNTRY', label: 'Drittlandübermittlung möglich', type: 'boolean' },
|
||||
{ key: 'TRANSFER_GUARDS', label: 'Garantien (z. B. SCC)' },
|
||||
// ── Cookie/Consent ───────────────────────────────────────────────────────
|
||||
{ key: 'HAS_FUNCTIONAL_COOKIES', label: 'Funktionale Cookies aktiviert', type: 'boolean' },
|
||||
{ key: 'CMP_NAME', label: 'Consent-Manager-Name (optional)' },
|
||||
{ key: 'CMP_LOGS_CONSENTS', label: 'Consent-Protokollierung aktiv', type: 'boolean' },
|
||||
{ key: 'ANALYTICS_TOOLS_DETAIL', label: 'Analyse-Tools (Detailtext)', type: 'textarea', span: true },
|
||||
{ key: 'MARKETING_TOOLS_DETAIL', label: 'Marketing-Tools (Detailtext)', type: 'textarea', span: true },
|
||||
// ── Service-Features ─────────────────────────────────────────────────────
|
||||
{ key: 'HAS_ACCOUNT', label: 'Nutzerkonten vorhanden', type: 'boolean' },
|
||||
{ key: 'HAS_PAYMENTS', label: 'Zahlungsabwicklung vorhanden', type: 'boolean' },
|
||||
{ key: 'PAYMENT_PROVIDER_DETAIL', label: 'Zahlungsanbieter (Detailtext)', type: 'textarea', span: true },
|
||||
{ key: 'HAS_SUPPORT', label: 'Support-Funktion vorhanden', type: 'boolean' },
|
||||
{ key: 'SUPPORT_CHANNELS_TEXT', label: 'Support-Kanäle / Zeiten' },
|
||||
{ key: 'HAS_NEWSLETTER', label: 'Newsletter vorhanden', type: 'boolean' },
|
||||
{ key: 'NEWSLETTER_PROVIDER_DETAIL', label: 'Newsletter-Anbieter (Detailtext)', type: 'textarea', span: true },
|
||||
{ key: 'HAS_SOCIAL_MEDIA', label: 'Social-Media-Präsenz', type: 'boolean' },
|
||||
{ key: 'SOCIAL_MEDIA_DETAIL', label: 'Social-Media-Details', type: 'textarea', span: true },
|
||||
// ── AGB ──────────────────────────────────────────────────────────────────
|
||||
{ key: 'HAS_PAID_PLANS', label: 'Kostenpflichtige Pläne', type: 'boolean' },
|
||||
{ key: 'PRICES_TEXT', label: 'Preise (Text/Link)', type: 'textarea', span: true },
|
||||
{ key: 'PAYMENT_TERMS_TEXT', label: 'Zahlungsbedingungen', type: 'textarea', span: true },
|
||||
{ key: 'CONTRACT_TERM_TEXT', label: 'Laufzeit & Kündigung', type: 'textarea', span: true },
|
||||
{ key: 'HAS_SLA', label: 'SLA vorhanden', type: 'boolean' },
|
||||
{ key: 'SLA_URL', label: 'SLA-URL' },
|
||||
{ key: 'HAS_EXPORT_POLICY', label: 'Datenexport/Löschung geregelt', type: 'boolean' },
|
||||
{ key: 'EXPORT_POLICY_TEXT', label: 'Datenexport-Regelung (Text)', type: 'textarea', span: true },
|
||||
{ key: 'HAS_WITHDRAWAL', label: 'Widerrufsrecht (B2C digital)', type: 'boolean' },
|
||||
{ key: 'CONSUMER_WITHDRAWAL_TEXT', label: 'Widerrufsbelehrung (Text)', type: 'textarea', span: true },
|
||||
{ key: 'LIMITATION_CAP_TEXT', label: 'Haftungsdeckel B2B (Text)' },
|
||||
// ── Impressum ────────────────────────────────────────────────────────────
|
||||
{ key: 'HAS_REGULATED_PROFESSION', label: 'Reglementierter Beruf', type: 'boolean' },
|
||||
{ key: 'REGULATED_PROFESSION_TEXT', label: 'Berufsrecht-Text', type: 'textarea', span: true },
|
||||
{ key: 'HAS_EDITORIAL_RESPONSIBLE', label: 'V.i.S.d.P. (redaktionell)', type: 'boolean' },
|
||||
{ key: 'EDITORIAL_RESPONSIBLE_NAME', label: 'V.i.S.d.P. Name' },
|
||||
{ key: 'EDITORIAL_RESPONSIBLE_ADDRESS', label: 'V.i.S.d.P. Adresse' },
|
||||
{ key: 'HAS_DISPUTE_RESOLUTION', label: 'Streitbeilegungshinweis', type: 'boolean' },
|
||||
{ key: 'DISPUTE_RESOLUTION_TEXT', label: 'Streitbeilegungstext', type: 'textarea', span: true },
|
||||
],
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SMALL COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function LicenseBadge({ licenseId, small = false }: { licenseId: LicenseType | null; small?: boolean }) {
|
||||
if (!licenseId) return null
|
||||
const colors: Partial<Record<LicenseType, string>> = {
|
||||
public_domain: 'bg-green-100 text-green-700 border-green-200',
|
||||
cc0: 'bg-green-100 text-green-700 border-green-200',
|
||||
unlicense: 'bg-green-100 text-green-700 border-green-200',
|
||||
mit: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
cc_by_4: 'bg-purple-100 text-purple-700 border-purple-200',
|
||||
reuse_notice: 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
}
|
||||
return (
|
||||
<span className={`${small ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-1 text-xs'} rounded border ${colors[licenseId] || 'bg-gray-100 text-gray-600 border-gray-200'}`}>
|
||||
{LICENSE_TYPE_LABELS[licenseId] || licenseId}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LIBRARY CARD
|
||||
// =============================================================================
|
||||
|
||||
function LibraryCard({
|
||||
template,
|
||||
expanded,
|
||||
onTogglePreview,
|
||||
onUse,
|
||||
}: {
|
||||
template: LegalTemplateResult
|
||||
expanded: boolean
|
||||
onTogglePreview: () => void
|
||||
onUse: () => void
|
||||
}) {
|
||||
const typeLabel = template.templateType
|
||||
? (TEMPLATE_TYPE_LABELS[template.templateType as TemplateType] || template.templateType)
|
||||
: null
|
||||
const placeholderCount = template.placeholders?.length ?? 0
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden hover:border-purple-300 transition-colors">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<h3 className="font-medium text-gray-900 text-sm leading-snug">
|
||||
{template.documentTitle || 'Vorlage'}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-400 uppercase shrink-0">{template.language}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap mb-3">
|
||||
{typeLabel && (
|
||||
<span className="text-xs text-purple-600 bg-purple-50 px-2 py-0.5 rounded">
|
||||
{typeLabel}
|
||||
</span>
|
||||
)}
|
||||
<LicenseBadge licenseId={template.licenseId as LicenseType} small />
|
||||
{placeholderCount > 0 && (
|
||||
<span className="text-xs text-gray-500">{placeholderCount} Platzh.</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onTogglePreview}
|
||||
className="flex-1 text-xs px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors"
|
||||
>
|
||||
{expanded ? 'Vorschau ▲' : 'Vorschau ▼'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onUse}
|
||||
className="flex-1 text-xs px-3 py-1.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Verwenden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-gray-100 bg-gray-50 p-4 max-h-[32rem] overflow-y-auto">
|
||||
<pre className="text-xs text-gray-600 whitespace-pre-wrap font-mono leading-relaxed">
|
||||
{template.text}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONTEXT SECTION FORM
|
||||
// =============================================================================
|
||||
|
||||
function ContextSectionForm({
|
||||
section,
|
||||
context,
|
||||
onChange,
|
||||
}: {
|
||||
section: keyof TemplateContext
|
||||
context: TemplateContext
|
||||
onChange: (section: keyof TemplateContext, key: string, value: unknown) => void
|
||||
}) {
|
||||
const fields = SECTION_FIELDS[section]
|
||||
const sectionData = context[section] as unknown as Record<string, unknown>
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{fields.map((field) => {
|
||||
const rawValue = sectionData[field.key]
|
||||
const inputCls = 'w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-400'
|
||||
|
||||
if (field.type === 'boolean') {
|
||||
return (
|
||||
<div key={field.key} className="flex items-center gap-2 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`${section}-${field.key}`}
|
||||
checked={!!rawValue}
|
||||
onChange={(e) => onChange(section, field.key, e.target.checked)}
|
||||
className="w-4 h-4 accent-purple-600"
|
||||
/>
|
||||
<label htmlFor={`${section}-${field.key}`} className="text-sm text-gray-700">
|
||||
{field.label}
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'select' && field.opts) {
|
||||
return (
|
||||
<div key={field.key} className={field.span ? 'md:col-span-2' : ''}>
|
||||
<label className="block text-xs text-gray-500 mb-1">{field.label}</label>
|
||||
<select
|
||||
value={String(rawValue ?? '')}
|
||||
onChange={(e) => onChange(section, field.key, e.target.value)}
|
||||
className={inputCls}
|
||||
>
|
||||
{field.opts.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
return (
|
||||
<div key={field.key} className={field.span ? 'md:col-span-2' : ''}>
|
||||
<label className="block text-xs text-gray-500 mb-1">{field.label}</label>
|
||||
<textarea
|
||||
value={String(rawValue ?? '')}
|
||||
onChange={(e) => onChange(section, field.key, field.nullable && e.target.value === '' ? null : e.target.value)}
|
||||
rows={3}
|
||||
className={`${inputCls} resize-none`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
return (
|
||||
<div key={field.key} className={field.span ? 'md:col-span-2' : ''}>
|
||||
<label className="block text-xs text-gray-500 mb-1">{field.label}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={rawValue === null || rawValue === undefined ? '' : String(rawValue)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
onChange(section, field.key, field.nullable && v === '' ? null : v === '' ? '' : Number(v))
|
||||
}}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// default: text / email
|
||||
return (
|
||||
<div key={field.key} className={field.span ? 'md:col-span-2' : ''}>
|
||||
<label className="block text-xs text-gray-500 mb-1">{field.label}</label>
|
||||
<input
|
||||
type={field.type === 'email' ? 'email' : 'text'}
|
||||
value={rawValue === null || rawValue === undefined ? '' : String(rawValue)}
|
||||
onChange={(e) => onChange(section, field.key, field.nullable && e.target.value === '' ? null : e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GENERATOR SECTION
|
||||
// =============================================================================
|
||||
|
||||
// Available module definitions (id → display label)
|
||||
const MODULE_LABELS: Record<string, string> = {
|
||||
CLOUD_EXPORT_DELETE_DE: 'Datenexport & Löschrecht',
|
||||
B2C_WITHDRAWAL_DE: 'Widerrufsrecht (B2C)',
|
||||
}
|
||||
|
||||
function GeneratorSection({
|
||||
template,
|
||||
context,
|
||||
onContextChange,
|
||||
extraPlaceholders,
|
||||
onExtraChange,
|
||||
onClose,
|
||||
enabledModules,
|
||||
onModuleToggle,
|
||||
}: {
|
||||
template: LegalTemplateResult
|
||||
context: TemplateContext
|
||||
onContextChange: (section: keyof TemplateContext, key: string, value: unknown) => void
|
||||
extraPlaceholders: Record<string, string>
|
||||
onExtraChange: (key: string, value: string) => void
|
||||
onClose: () => void
|
||||
enabledModules: string[]
|
||||
onModuleToggle: (mod: string, checked: boolean) => void
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<'placeholders' | 'preview'>('placeholders')
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['PROVIDER', 'LEGAL']))
|
||||
|
||||
const placeholders = template.placeholders || []
|
||||
const relevantSections = useMemo(() => getRelevantSections(placeholders), [placeholders])
|
||||
const uncovered = useMemo(() => getUncoveredPlaceholders(placeholders, context), [placeholders, context])
|
||||
const missing = useMemo(() => getMissingRequired(placeholders, context), [placeholders, context])
|
||||
|
||||
// Rule engine evaluation
|
||||
const ruleResult = useMemo((): RuleEngineResult | null => {
|
||||
if (!template) return null
|
||||
return runRuleset({
|
||||
doc_type: getDocType(template.templateType ?? '', template.language ?? 'de'),
|
||||
render: { lang: template.language ?? 'de', variant: 'standard' },
|
||||
context,
|
||||
modules: { enabled: enabledModules },
|
||||
} satisfies RuleInput)
|
||||
}, [template, context, enabledModules])
|
||||
|
||||
const allPlaceholderValues = useMemo(() => ({
|
||||
...contextToPlaceholders(ruleResult?.contextAfterDefaults ?? context),
|
||||
...extraPlaceholders,
|
||||
}), [context, extraPlaceholders, ruleResult])
|
||||
|
||||
// Boolean context for {{#IF}} rendering
|
||||
const boolCtx = useMemo(
|
||||
() => ruleResult ? buildBoolContext(ruleResult.contextAfterDefaults, ruleResult.computedFlags) : {},
|
||||
[ruleResult]
|
||||
)
|
||||
|
||||
const renderedContent = useMemo(() => {
|
||||
// 1. Remove ruleset-driven blocks ([BLOCK:ID])
|
||||
let content = applyBlockRemoval(template.text, ruleResult?.removedBlocks ?? [])
|
||||
// 2. Evaluate {{#IF}} / {{#IF_NOT}} / {{#IF_ANY}} directives
|
||||
content = applyConditionalBlocks(content, boolCtx)
|
||||
// 3. Substitute placeholders
|
||||
for (const [key, value] of Object.entries(allPlaceholderValues)) {
|
||||
if (value) {
|
||||
content = content.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value)
|
||||
}
|
||||
}
|
||||
return content
|
||||
}, [template.text, allPlaceholderValues, ruleResult, boolCtx])
|
||||
|
||||
// Compute which modules are relevant (mentioned in violations/warnings)
|
||||
const relevantModules = useMemo(() => {
|
||||
if (!ruleResult) return []
|
||||
const mentioned = new Set<string>()
|
||||
const allIssues = [...ruleResult.violations, ...ruleResult.warnings]
|
||||
for (const issue of allIssues) {
|
||||
if (issue.phase === 'module_requirements') {
|
||||
// Extract module ID from message
|
||||
for (const modId of Object.keys(MODULE_LABELS)) {
|
||||
if (issue.message.includes(modId)) mentioned.add(modId)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also show modules that are enabled but not mentioned
|
||||
for (const mod of enabledModules) {
|
||||
if (mod in MODULE_LABELS) mentioned.add(mod)
|
||||
}
|
||||
return [...mentioned]
|
||||
}, [ruleResult, enabledModules])
|
||||
|
||||
const handleCopy = () => navigator.clipboard.writeText(renderedContent)
|
||||
|
||||
const handleExportMarkdown = () => {
|
||||
const blob = new Blob([renderedContent], { type: 'text/markdown' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${(template.documentTitle || 'dokument').replace(/\s+/g, '-').toLowerCase()}.md`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const toggleSection = (sec: string) => {
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(sec)) next.delete(sec)
|
||||
else next.add(sec)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Auto-expand all relevant sections on first render
|
||||
useEffect(() => {
|
||||
if (relevantSections.length > 0) {
|
||||
setExpandedSections(new Set(relevantSections))
|
||||
}
|
||||
}, [template.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Computed flags pills config
|
||||
const flagPills: { key: string; label: string; color: string }[] = ruleResult ? [
|
||||
{ key: 'IS_B2C', label: 'B2C', color: 'bg-blue-100 text-blue-700' },
|
||||
{ key: 'SERVICE_IS_SAAS', label: 'SaaS', color: 'bg-green-100 text-green-700' },
|
||||
{ key: 'HAS_PENALTY', label: 'Vertragsstrafe', color: 'bg-orange-100 text-orange-700' },
|
||||
{ key: 'HAS_ANALYTICS', label: 'Analytics', color: 'bg-gray-100 text-gray-600' },
|
||||
] : []
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border-2 border-purple-300 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-purple-50 px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<svg className="w-5 h-5 text-purple-600 shrink-0" 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>
|
||||
<div>
|
||||
<div className="text-xs text-purple-500 font-medium uppercase tracking-wide">Generator</div>
|
||||
<div className="font-semibold text-gray-900 text-sm">{template.documentTitle}</div>
|
||||
</div>
|
||||
{/* Computed flags pills */}
|
||||
{ruleResult && (
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{flagPills.map(({ key, label, color }) =>
|
||||
ruleResult.computedFlags[key] ? (
|
||||
<span key={key} className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${color}`}>
|
||||
{label}
|
||||
</span>
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors shrink-0" aria-label="Schließen">
|
||||
<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>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex border-b border-gray-200 px-6">
|
||||
{(['placeholders', 'preview'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-3 text-sm font-medium transition-colors border-b-2 -mb-px ${
|
||||
activeTab === tab
|
||||
? 'text-purple-600 border-purple-600'
|
||||
: 'text-gray-500 border-transparent hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab === 'placeholders' ? 'Kontext ausfüllen' : 'Vorschau & Export'}
|
||||
{tab === 'placeholders' && missing.length > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 text-[10px] bg-orange-100 text-orange-600 rounded-full">
|
||||
{missing.length}
|
||||
</span>
|
||||
)}
|
||||
{tab === 'preview' && ruleResult && ruleResult.violations.length > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 text-[10px] bg-red-100 text-red-600 rounded-full">
|
||||
{ruleResult.violations.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="p-6">
|
||||
{activeTab === 'placeholders' && (
|
||||
<div className="space-y-4">
|
||||
{placeholders.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 text-center py-4">
|
||||
Keine Platzhalter — Vorlage kann direkt verwendet werden.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Relevant sections */}
|
||||
{relevantSections.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Alle Platzhalter müssen manuell befüllt werden.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{relevantSections.map((section) => (
|
||||
<div key={section} className="border border-gray-200 rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleSection(section)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors text-left"
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-800">{SECTION_LABELS[section]}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-400 transition-transform ${expandedSections.has(section) ? '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>
|
||||
{expandedSections.has(section) && (
|
||||
<div className="p-4 border-t border-gray-100">
|
||||
<ContextSectionForm
|
||||
section={section}
|
||||
context={context}
|
||||
onChange={onContextChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Uncovered / manual placeholders */}
|
||||
{uncovered.length > 0 && (
|
||||
<div className="border border-dashed border-gray-300 rounded-xl p-4">
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">
|
||||
Weitere Platzhalter (manuell ausfüllen)
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{uncovered.map((ph) => (
|
||||
<div key={ph}>
|
||||
<label className="block text-xs text-gray-500 mb-1 font-mono">{ph}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={extraPlaceholders[ph] || ''}
|
||||
onChange={(e) => onExtraChange(ph, e.target.value)}
|
||||
placeholder={`Wert für ${ph}`}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-400"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Module toggles */}
|
||||
{relevantModules.length > 0 && (
|
||||
<div className="border border-gray-200 rounded-xl p-4">
|
||||
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3">Module</p>
|
||||
<div className="space-y-2">
|
||||
{relevantModules.map((modId) => (
|
||||
<label key={modId} className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabledModules.includes(modId)}
|
||||
onChange={(e) => onModuleToggle(modId, e.target.checked)}
|
||||
className="w-4 h-4 accent-purple-600"
|
||||
/>
|
||||
<span className="text-xs font-mono text-gray-600">{modId}</span>
|
||||
{MODULE_LABELS[modId] && (
|
||||
<span className="text-xs text-gray-500">{MODULE_LABELS[modId]}</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation summary + CTA */}
|
||||
<div className="flex items-center justify-between pt-2 flex-wrap gap-3">
|
||||
<div>
|
||||
{missing.length > 0 ? (
|
||||
<span className="text-sm text-orange-600">
|
||||
⚠ {missing.length} Pflichtfeld{missing.length > 1 ? 'er' : ''} fehlt{missing.length === 1 ? '' : 'en'}
|
||||
<span className="ml-1 text-xs text-orange-400">
|
||||
({missing.map((m) => m.replace(/\{\{|\}\}/g, '')).slice(0, 3).join(', ')}{missing.length > 3 ? ` +${missing.length - 3}` : ''})
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-green-600">Alle Pflichtfelder ausgefüllt</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setActiveTab('preview')}
|
||||
className="px-5 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Zur Vorschau →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'preview' && (
|
||||
<div className="space-y-4">
|
||||
{/* Rule engine banners */}
|
||||
{ruleResult && ruleResult.violations.length > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||
<p className="text-sm font-semibold text-red-700 mb-2">
|
||||
🔴 {ruleResult.violations.length} Fehler
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{ruleResult.violations.map((v) => (
|
||||
<li key={v.id} className="text-xs text-red-600">
|
||||
<span className="font-mono font-medium">[{v.id}]</span> {v.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{ruleResult && ruleResult.warnings.filter((w) => w.id !== 'WARN_LEGAL_REVIEW').length > 0 && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4">
|
||||
<ul className="space-y-1">
|
||||
{ruleResult.warnings
|
||||
.filter((w) => w.id !== 'WARN_LEGAL_REVIEW')
|
||||
.map((w) => (
|
||||
<li key={w.id} className="text-xs text-yellow-700">
|
||||
🟡 <span className="font-mono font-medium">[{w.id}]</span> {w.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{ruleResult && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3">
|
||||
<p className="text-xs text-blue-700">
|
||||
ℹ️ Rechtlicher Hinweis: Diese Vorlage ist MIT-lizenziert. Vor Produktionseinsatz
|
||||
wird eine rechtliche Überprüfung dringend empfohlen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{ruleResult && ruleResult.appliedDefaults.length > 0 && (
|
||||
<p className="text-xs text-gray-400">
|
||||
Defaults angewendet: {ruleResult.appliedDefaults.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<span className="text-sm text-gray-600">
|
||||
{missing.length > 0 && (
|
||||
<span className="text-orange-600">
|
||||
⚠ {missing.length} Platzhalter noch nicht ausgefüllt
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Kopieren
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportMarkdown}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Markdown
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
||||
</svg>
|
||||
PDF drucken
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6 max-h-[600px] overflow-y-auto">
|
||||
<pre className="text-sm text-gray-800 whitespace-pre-wrap leading-relaxed font-sans">
|
||||
{renderedContent}
|
||||
</pre>
|
||||
</div>
|
||||
{template.attributionRequired && template.attributionText && (
|
||||
<div className="text-xs text-orange-600 bg-orange-50 p-3 rounded-lg border border-orange-200">
|
||||
<strong>Attribution erforderlich:</strong> {template.attributionText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
import { TemplateContext, EMPTY_CONTEXT } from './contextBridge'
|
||||
import { CATEGORIES } from './_constants'
|
||||
import TemplateLibrary from './_components/TemplateLibrary'
|
||||
import GeneratorSection from './_components/GeneratorSection'
|
||||
|
||||
function DocumentGeneratorPageInner() {
|
||||
const { state } = useSDK()
|
||||
@@ -970,115 +177,21 @@ function DocumentGeneratorPageInner() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* SECTION 1: TEMPLATE-BIBLIOTHEK */}
|
||||
{/* ================================================================= */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between flex-wrap gap-3">
|
||||
<h2 className="font-semibold text-gray-900">Template-Bibliothek</h2>
|
||||
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
|
||||
{(['all', 'de', 'en'] as const).map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => setActiveLanguage(lang)}
|
||||
className={`px-3 py-1.5 text-xs rounded-md font-medium transition-colors ${
|
||||
activeLanguage === lang
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{lang === 'all' ? 'Alle' : lang.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<TemplateLibrary
|
||||
allTemplates={allTemplates}
|
||||
filteredTemplates={filteredTemplates}
|
||||
isLoadingLibrary={isLoadingLibrary}
|
||||
activeCategory={activeCategory}
|
||||
onCategoryChange={setActiveCategory}
|
||||
activeLanguage={activeLanguage}
|
||||
onLanguageChange={setActiveLanguage}
|
||||
librarySearch={librarySearch}
|
||||
onSearchChange={setLibrarySearch}
|
||||
expandedPreviewId={expandedPreviewId}
|
||||
onTogglePreview={(id) => setExpandedPreviewId((prev) => (prev === id ? null : id))}
|
||||
onUseTemplate={handleUseTemplate}
|
||||
/>
|
||||
|
||||
{/* Category pills */}
|
||||
<div className="px-6 py-3 border-b border-gray-100 flex gap-2 flex-wrap">
|
||||
{CATEGORIES.map((cat) => {
|
||||
const count = cat.types === null
|
||||
? allTemplates.filter(t => activeLanguage === 'all' || t.language === activeLanguage).length
|
||||
: allTemplates.filter(t =>
|
||||
cat.types!.includes(t.templateType || '') &&
|
||||
(activeLanguage === 'all' || t.language === activeLanguage)
|
||||
).length
|
||||
return (
|
||||
<button
|
||||
key={cat.key}
|
||||
onClick={() => setActiveCategory(cat.key)}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||
activeCategory === cat.key
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{cat.label}
|
||||
{count > 0 && (
|
||||
<span className={`ml-1.5 ${activeCategory === cat.key ? 'text-purple-200' : 'text-gray-400'}`}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-6 py-3 border-b border-gray-100">
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={librarySearch}
|
||||
onChange={(e) => setLibrarySearch(e.target.value)}
|
||||
placeholder="Vorlage suchen... (optional)"
|
||||
className="w-full pl-9 pr-4 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-purple-400"
|
||||
/>
|
||||
{librarySearch && (
|
||||
<button
|
||||
onClick={() => setLibrarySearch('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template grid */}
|
||||
<div className="p-6">
|
||||
{isLoadingLibrary ? (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<div className="text-4xl mb-3">📄</div>
|
||||
<p>Keine Vorlagen für diese Auswahl</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredTemplates.map((template) => (
|
||||
<LibraryCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
expanded={expandedPreviewId === template.id}
|
||||
onTogglePreview={() =>
|
||||
setExpandedPreviewId((prev) => (prev === template.id ? null : template.id))
|
||||
}
|
||||
onUse={() => handleUseTemplate(template)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* SECTION 2: GENERATOR (visible only when activeTemplate is set) */}
|
||||
{/* ================================================================= */}
|
||||
{activeTemplate && (
|
||||
<div ref={generatorRef} className="space-y-6">
|
||||
<GeneratorSection
|
||||
|
||||
189
admin-compliance/app/sdk/dsb-portal/_components/AufgabenTab.tsx
Normal file
189
admin-compliance/app/sdk/dsb-portal/_components/AufgabenTab.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Task, TASK_CATEGORIES, PRIORITY_LABELS, PRIORITY_COLORS,
|
||||
TASK_STATUS_LABELS, TASK_STATUS_COLORS, apiFetch, formatDate,
|
||||
} from './types'
|
||||
import {
|
||||
Skeleton, Modal, Badge, FormLabel, FormInput, FormTextarea,
|
||||
FormSelect, PrimaryButton, SecondaryButton, ErrorState, EmptyState,
|
||||
IconTask, IconPlus, IconCheck, IconCalendar,
|
||||
} from './ui-primitives'
|
||||
|
||||
export function AufgabenTab({
|
||||
assignmentId,
|
||||
addToast,
|
||||
}: {
|
||||
assignmentId: string
|
||||
addToast: (msg: string, type?: 'success' | 'error') => void
|
||||
}) {
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [newDesc, setNewDesc] = useState('')
|
||||
const [newCategory, setNewCategory] = useState(TASK_CATEGORIES[0])
|
||||
const [newPriority, setNewPriority] = useState('medium')
|
||||
const [newDueDate, setNewDueDate] = useState('')
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const params = statusFilter !== 'all' ? `?status=${statusFilter}` : ''
|
||||
const data = await apiFetch<Task[]>(`/api/sdk/v1/dsb/assignments/${assignmentId}/tasks${params}`)
|
||||
setTasks(data)
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden der Aufgaben')
|
||||
} finally { setLoading(false) }
|
||||
}, [assignmentId, statusFilter])
|
||||
|
||||
useEffect(() => { fetchTasks() }, [fetchTasks])
|
||||
|
||||
const handleCreateTask = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
await apiFetch<Task>(`/api/sdk/v1/dsb/assignments/${assignmentId}/tasks`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: newTitle, description: newDesc, category: newCategory,
|
||||
priority: newPriority, due_date: newDueDate || null,
|
||||
}),
|
||||
})
|
||||
addToast('Aufgabe erstellt')
|
||||
setShowModal(false)
|
||||
setNewTitle(''); setNewDesc(''); setNewCategory(TASK_CATEGORIES[0])
|
||||
setNewPriority('medium'); setNewDueDate('')
|
||||
fetchTasks()
|
||||
} catch (e: unknown) {
|
||||
addToast(e instanceof Error ? e.message : 'Fehler', 'error')
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
const handleCompleteTask = async (taskId: string) => {
|
||||
try {
|
||||
await apiFetch(`/api/sdk/v1/dsb/tasks/${taskId}/complete`, { method: 'POST' })
|
||||
addToast('Aufgabe abgeschlossen')
|
||||
fetchTasks()
|
||||
} catch (e: unknown) {
|
||||
addToast(e instanceof Error ? e.message : 'Fehler', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const statusFilters = [
|
||||
{ value: 'all', label: 'Alle' },
|
||||
{ value: 'open', label: 'Offen' },
|
||||
{ value: 'in_progress', label: 'In Bearbeitung' },
|
||||
{ value: 'completed', label: 'Erledigt' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{statusFilters.map((f) => (
|
||||
<button key={f.value} onClick={() => setStatusFilter(f.value)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
statusFilter === f.value ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}>{f.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<PrimaryButton onClick={() => setShowModal(true)} className="flex items-center gap-1.5">
|
||||
<IconPlus /> Neue Aufgabe
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-16 rounded-lg" />)}
|
||||
</div>
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={fetchTasks} />
|
||||
) : tasks.length === 0 ? (
|
||||
<EmptyState icon={<IconTask className="w-7 h-7" />} title="Keine Aufgaben"
|
||||
description="Erstellen Sie eine neue Aufgabe um zu beginnen." />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{tasks.map((task) => (
|
||||
<div key={task.id} className="bg-white border border-gray-200 rounded-lg p-4 hover:border-purple-300 transition-colors">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className={`font-medium ${task.status === 'completed' ? 'line-through text-gray-400' : 'text-gray-900'}`}>
|
||||
{task.title}
|
||||
</h4>
|
||||
<Badge label={task.category} className="bg-purple-50 text-purple-600 border-purple-200" />
|
||||
<Badge label={PRIORITY_LABELS[task.priority] || task.priority}
|
||||
className={PRIORITY_COLORS[task.priority] || 'bg-gray-100 text-gray-500'} />
|
||||
<Badge label={TASK_STATUS_LABELS[task.status] || task.status}
|
||||
className={TASK_STATUS_COLORS[task.status] || 'bg-gray-100 text-gray-500'} />
|
||||
</div>
|
||||
{task.description && <p className="text-sm text-gray-500 mt-1 line-clamp-2">{task.description}</p>}
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-gray-400">
|
||||
{task.due_date && (
|
||||
<span className="flex items-center gap-1"><IconCalendar className="w-3 h-3" />Frist: {formatDate(task.due_date)}</span>
|
||||
)}
|
||||
<span>Erstellt: {formatDate(task.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{task.status !== 'completed' && task.status !== 'cancelled' && (
|
||||
<button onClick={() => handleCompleteTask(task.id)} title="Aufgabe abschliessen"
|
||||
className="p-2 rounded-lg text-green-600 hover:bg-green-50 transition-colors flex-shrink-0">
|
||||
<IconCheck className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create task modal */}
|
||||
<Modal open={showModal} onClose={() => setShowModal(false)} title="Neue Aufgabe erstellen">
|
||||
<form onSubmit={handleCreateTask} className="space-y-4">
|
||||
<div>
|
||||
<FormLabel htmlFor="task-title">Titel *</FormLabel>
|
||||
<FormInput id="task-title" value={newTitle} onChange={setNewTitle} placeholder="Aufgabentitel" required />
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="task-desc">Beschreibung</FormLabel>
|
||||
<FormTextarea id="task-desc" value={newDesc} onChange={setNewDesc} placeholder="Beschreibung der Aufgabe..." />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FormLabel htmlFor="task-cat">Kategorie</FormLabel>
|
||||
<FormSelect id="task-cat" value={newCategory} onChange={setNewCategory}
|
||||
options={TASK_CATEGORIES.map((c) => ({ value: c, label: c }))} />
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="task-prio">Prioritaet</FormLabel>
|
||||
<FormSelect id="task-prio" value={newPriority} onChange={setNewPriority}
|
||||
options={[
|
||||
{ value: 'urgent', label: 'Dringend' }, { value: 'high', label: 'Hoch' },
|
||||
{ value: 'medium', label: 'Mittel' }, { value: 'low', label: 'Niedrig' },
|
||||
]} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="task-due">Faelligkeitsdatum</FormLabel>
|
||||
<FormInput id="task-due" type="date" value={newDueDate} onChange={setNewDueDate} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<SecondaryButton onClick={() => setShowModal(false)}>Abbrechen</SecondaryButton>
|
||||
<PrimaryButton type="submit" disabled={saving || !newTitle.trim()}>
|
||||
{saving ? 'Erstelle...' : 'Aufgabe erstellen'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
126
admin-compliance/app/sdk/dsb-portal/_components/DetailView.tsx
Normal file
126
admin-compliance/app/sdk/dsb-portal/_components/DetailView.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { AssignmentOverview, ASSIGNMENT_STATUS_LABELS, ASSIGNMENT_STATUS_COLORS, formatDate } from './types'
|
||||
import {
|
||||
Badge, ComplianceBar, HoursBar,
|
||||
IconBack, IconTask, IconClock, IconMail, IconSettings, IconShield,
|
||||
} from './ui-primitives'
|
||||
import { AufgabenTab } from './AufgabenTab'
|
||||
import { ZeiterfassungTab } from './ZeiterfassungTab'
|
||||
import { KommunikationTab } from './KommunikationTab'
|
||||
import { EinstellungenTab } from './EinstellungenTab'
|
||||
|
||||
type DetailTab = 'aufgaben' | 'zeit' | 'kommunikation' | 'einstellungen'
|
||||
|
||||
export function DetailView({
|
||||
assignment,
|
||||
onBack,
|
||||
onUpdate,
|
||||
addToast,
|
||||
}: {
|
||||
assignment: AssignmentOverview
|
||||
onBack: () => void
|
||||
onUpdate: () => void
|
||||
addToast: (msg: string, type?: 'success' | 'error') => void
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>('aufgaben')
|
||||
|
||||
const tabs: { id: DetailTab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'aufgaben', label: 'Aufgaben', icon: <IconTask className="w-4 h-4" /> },
|
||||
{ id: 'zeit', label: 'Zeiterfassung', icon: <IconClock className="w-4 h-4" /> },
|
||||
{ id: 'kommunikation', label: 'Kommunikation', icon: <IconMail className="w-4 h-4" /> },
|
||||
{ id: 'einstellungen', label: 'Einstellungen', icon: <IconSettings className="w-4 h-4" /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back + Header */}
|
||||
<div>
|
||||
<button onClick={onBack}
|
||||
className="flex items-center gap-2 text-sm text-purple-600 hover:text-purple-800 font-medium mb-4 transition-colors">
|
||||
<IconBack className="w-4 h-4" /> Zurueck zur Uebersicht
|
||||
</button>
|
||||
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center text-purple-600">
|
||||
<IconShield className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">{assignment.tenant_name}</h2>
|
||||
<p className="text-sm text-gray-400 font-mono">{assignment.tenant_slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
label={ASSIGNMENT_STATUS_LABELS[assignment.status] || assignment.status}
|
||||
className={ASSIGNMENT_STATUS_COLORS[assignment.status] || 'bg-gray-100 text-gray-600'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-5 pt-5 border-t border-gray-100">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Vertragsbeginn</p>
|
||||
<p className="text-sm font-medium text-gray-700">{formatDate(assignment.contract_start)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Vertragsende</p>
|
||||
<p className="text-sm font-medium text-gray-700">
|
||||
{assignment.contract_end ? formatDate(assignment.contract_end) : 'Unbefristet'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Compliance-Score</p>
|
||||
<div className="mt-1"><ComplianceBar score={assignment.compliance_score} /></div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400">Stunden diesen Monat</p>
|
||||
<div className="mt-1"><HoursBar used={assignment.hours_this_month} budget={assignment.hours_budget} /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{assignment.notes && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<p className="text-xs text-gray-400 mb-1">Anmerkungen</p>
|
||||
<p className="text-sm text-gray-600">{assignment.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-0 -mb-px overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-5 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? 'border-purple-600 text-purple-700'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}>
|
||||
{tab.icon} {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div>
|
||||
{activeTab === 'aufgaben' && <AufgabenTab assignmentId={assignment.id} addToast={addToast} />}
|
||||
{activeTab === 'zeit' && (
|
||||
<ZeiterfassungTab assignmentId={assignment.id} monthlyBudget={assignment.monthly_hours_budget} addToast={addToast} />
|
||||
)}
|
||||
{activeTab === 'kommunikation' && <KommunikationTab assignmentId={assignment.id} addToast={addToast} />}
|
||||
{activeTab === 'einstellungen' && (
|
||||
<EinstellungenTab assignment={assignment} onUpdate={onUpdate} addToast={addToast} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { AssignmentOverview, ASSIGNMENT_STATUS_LABELS, apiFetch } from './types'
|
||||
import { FormLabel, FormInput, FormTextarea, PrimaryButton } from './ui-primitives'
|
||||
|
||||
export function EinstellungenTab({
|
||||
assignment,
|
||||
onUpdate,
|
||||
addToast,
|
||||
}: {
|
||||
assignment: AssignmentOverview
|
||||
onUpdate: () => void
|
||||
addToast: (msg: string, type?: 'success' | 'error') => void
|
||||
}) {
|
||||
const [status, setStatus] = useState(assignment.status)
|
||||
const [budget, setBudget] = useState(String(assignment.monthly_hours_budget))
|
||||
const [notes, setNotes] = useState(assignment.notes || '')
|
||||
const [contractStart, setContractStart] = useState(assignment.contract_start?.slice(0, 10) || '')
|
||||
const [contractEnd, setContractEnd] = useState(assignment.contract_end?.slice(0, 10) || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await apiFetch(`/api/sdk/v1/dsb/assignments/${assignment.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
status,
|
||||
monthly_hours_budget: parseFloat(budget) || 0,
|
||||
notes,
|
||||
contract_start: contractStart || null,
|
||||
contract_end: contractEnd || null,
|
||||
}),
|
||||
})
|
||||
addToast('Einstellungen gespeichert')
|
||||
onUpdate()
|
||||
} catch (e: unknown) {
|
||||
addToast(e instanceof Error ? e.message : 'Fehler beim Speichern', 'error')
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* Status */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">Status</h4>
|
||||
<div className="flex gap-2">
|
||||
{(['active', 'paused', 'terminated'] as const).map((s) => (
|
||||
<button key={s} onClick={() => setStatus(s)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
status === s
|
||||
? s === 'active' ? 'bg-green-100 text-green-700 border-green-300'
|
||||
: s === 'paused' ? 'bg-yellow-100 text-yellow-700 border-yellow-300'
|
||||
: 'bg-red-100 text-red-700 border-red-300'
|
||||
: 'bg-white text-gray-500 border-gray-200 hover:bg-gray-50'
|
||||
}`}>
|
||||
{ASSIGNMENT_STATUS_LABELS[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contract period */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">Vertragszeitraum</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FormLabel htmlFor="s-start">Vertragsbeginn</FormLabel>
|
||||
<FormInput id="s-start" type="date" value={contractStart} onChange={setContractStart} />
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel htmlFor="s-end">Vertragsende</FormLabel>
|
||||
<FormInput id="s-end" type="date" value={contractEnd} onChange={setContractEnd} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Budget */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">Monatliches Stundenbudget</h4>
|
||||
<div className="max-w-xs">
|
||||
<FormInput type="number" value={budget} onChange={setBudget} min={0} max={999} step={1} />
|
||||
<p className="text-xs text-gray-400 mt-1">Stunden pro Monat</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">Anmerkungen</h4>
|
||||
<FormTextarea value={notes} onChange={setNotes} placeholder="Interne Anmerkungen zum Mandat..." rows={4} />
|
||||
</div>
|
||||
|
||||
{/* Save */}
|
||||
<div className="flex justify-end">
|
||||
<PrimaryButton onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Speichere...' : 'Einstellungen speichern'}
|
||||
</PrimaryButton>
|
||||
</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