Compare commits
300 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf9d8a5ed3 | |||
| d45e08e25f | |||
| 3dbf3aa34a | |||
| 77308b783f | |||
| 9797234ff6 | |||
| 7080eb5f45 | |||
| c93cf2719a | |||
| 7a27dbc01b | |||
| de35dfce18 | |||
| 69240faf24 | |||
| f34305c0a1 | |||
| 2b5376ed54 | |||
| 958c03ab40 | |||
| fca67c1f43 | |||
| 70af018da5 | |||
| 0182c91ef9 | |||
| a67cfa7c4a | |||
| 3b7ab4cbd7 | |||
| 3469105d18 | |||
| 1414c63515 | |||
| 9f87bc5a2c | |||
| f5f4de7359 | |||
| 38d15d4d29 | |||
| 003eafa75d | |||
| b82853a95b | |||
| c060ac222a | |||
| 659c0505f8 | |||
| 02c2325e1b | |||
| d72aa10691 | |||
| 3c05ff8ef6 | |||
| 935c9205b9 | |||
| 826ce2a1b8 | |||
| bd2d6976d6 | |||
| a5d1814605 | |||
| ba07a7f6e6 | |||
| 708c61e50d | |||
| dc55253b9d | |||
| 8069d0ea89 | |||
| 4e9043f26d | |||
| 29fbd03c79 | |||
| 98e5b1a8aa | |||
| b175212516 | |||
| 16190583d1 | |||
| 70c9bfc069 | |||
| 4b9317b4fd | |||
| e4431da8d2 | |||
| 65f978368d | |||
| a530edb994 | |||
| 256deb70c7 | |||
| eac42d4154 | |||
| 33bf2b7c5a | |||
| 3e61f381a7 | |||
| cca714755a | |||
| 6940271672 | |||
| 5e317d2f0f | |||
| 64e3a47b8c | |||
| 81a0568537 | |||
| d0d1b38f5c | |||
| d31c2fe018 | |||
| 8ad0519367 | |||
| 7a5301064c | |||
| b2c1f0ae84 | |||
| 733d2bcc7b | |||
| 977e63f372 | |||
| be2ac762bd | |||
| 1bd892afbf | |||
| c702260ec1 | |||
| 8bb90d73e5 | |||
| 185d680669 | |||
| 0b9150f16f | |||
| 0326d5baab | |||
| c867478791 | |||
| 979fe20ea5 | |||
| de808190dd | |||
| 08fcb5f239 | |||
| e785b6d695 | |||
| 7be34552bb | |||
| be9cfdc2d4 | |||
| b42e1cd091 | |||
| 1c828a5843 | |||
| 4a7e09bbb0 | |||
| edbf6d2be5 | |||
| 06bfbd1dca | |||
| 74f00bbb0f | |||
| 128967fa3d | |||
| baca0f6b80 | |||
| 407a9503e4 | |||
| 1fd7ea6139 | |||
| ce77cde309 | |||
| a127dd971b | |||
| 65b4857be5 | |||
| 93028b443e | |||
| 7d9f5a1f76 | |||
| 6ce5b4bf41 | |||
| 078f936449 | |||
| ed3ebbc246 | |||
| 4e865d2997 | |||
| f5664612ad | |||
| 134b7e7709 | |||
| 12f2503873 | |||
| 6586d2cb5e | |||
| df15f6f098 | |||
| bcf78c120a | |||
| 1866bb11ae | |||
| f3751a4efa | |||
| b6ad958b69 | |||
| 66d30568e2 | |||
| 36afbadc01 | |||
| 7ca3624a1f | |||
| 397de741c1 | |||
| 051890c370 | |||
| 90da26745b | |||
| 0d0e705117 | |||
| b214cbc003 | |||
| 19d8a7e2b9 | |||
| b8770e1b9c | |||
| 6af9353bad | |||
| 4279197954 | |||
| 0c25832b5c | |||
| 916337b503 | |||
| fde2f551d7 | |||
| 3c7ed65f86 | |||
| 02ff96f74e | |||
| e03a86a9bb | |||
| 36c6101b91 | |||
| e80bbe000f | |||
| 6f776b2fa8 | |||
| a0bb9e3aed | |||
| f93901ba77 | |||
| cb8fb65d3e | |||
| af5ab9127a | |||
| 8f169cbae3 | |||
| 285b74382a | |||
| cc919eb608 | |||
| 6cb5da56b3 | |||
| 6bd09d7676 | |||
| 53c641800f | |||
| 350476b392 | |||
| 91d6d8b1a7 | |||
| 85d261a3f8 | |||
| 289ec5f396 | |||
| dabc2358ab | |||
| 58f370f4ff | |||
| bdbc30e47b | |||
| 9c0d471277 | |||
| 9cbbc6ee2f | |||
| 5ea83e9b33 | |||
| 9a9a11b248 | |||
| 26b222d53d | |||
| d339d1edc7 | |||
| 6e995b52d1 | |||
| 52bb766a04 | |||
| 8afc7dbff4 | |||
| 9b17e4a282 | |||
| 049b28f107 | |||
| 17254789e0 | |||
| 1ca6c77c26 | |||
| 94ae2fdc01 | |||
| fbaca53c32 | |||
| 8a974e1f97 | |||
| 345ea70844 | |||
| a14e5ad97d | |||
| df463dbce7 | |||
| 82951785ec | |||
| 6d2616cad7 | |||
| 05d98ea95f | |||
| d2dc0c9fe4 | |||
| 99ef9873ad | |||
| c7e197d107 | |||
| 80ae196853 | |||
| 561150b5a8 | |||
| f07c4db164 | |||
| f201c01a06 | |||
| 77a497d930 | |||
| 33f0a64ff6 | |||
| 1b8e9881bb | |||
| c075ecb721 | |||
| 2e29b611c9 | |||
| 6387b6950a | |||
| 1f5d1a0b79 | |||
| 8682522212 | |||
| 2143840ee7 | |||
| 4d708b4443 | |||
| 4bfb438c92 | |||
| 0371eecc03 | |||
| 2f0f76e365 | |||
| 4f92e5056c | |||
| 6da9972ef4 | |||
| c284cefada | |||
| 53f6f30cf0 | |||
| a6618af5ed | |||
| 2b4ff9f422 | |||
| 84b21cad08 | |||
| 95baf60da3 | |||
| 9fe7759973 | |||
| f737bfc4db | |||
| 7ab1476d8f | |||
| 225456ec14 | |||
| c719b1ca5f | |||
| 9df2a001bb | |||
| c47450fe58 | |||
| bb1f5d6c94 | |||
| 0837680e03 | |||
| f74b786c6f | |||
| 7ebd25c59c | |||
| e0f59cdf82 | |||
| d3c8811fdb | |||
| c89a68e59e | |||
| eb4ea8bc42 | |||
| 060f351da7 | |||
| c55d0ab12a | |||
| 02468c94c0 | |||
| 630fffc0cc | |||
| 965af3a34c | |||
| c3fcfe88ee | |||
| 36d9f929c6 | |||
| 4c92b17617 | |||
| 9b4be663f7 | |||
| ce52dd153e | |||
| 3aff80fb0c | |||
| ca6da1acea | |||
| 40e2c76ab3 | |||
| c5678c7101 | |||
| 9423b1d1b9 | |||
| 252d4f25c8 | |||
| 7d24ba0b40 | |||
| 65e856f37a | |||
| 8f4a23a32d | |||
| e853a47879 | |||
| e077bde074 | |||
| f340d33eba | |||
| a56ea2c843 | |||
| 64700b355e | |||
| 4b9cf34243 | |||
| 5298467275 | |||
| 91b4034fee | |||
| 1b37b2aeea | |||
| 4a688098e8 | |||
| a2492f0b7e | |||
| fe6764df9a | |||
| db697924ed | |||
| f9a1fe21dc | |||
| 17c67b4f25 | |||
| cb2d503e84 | |||
| dccd9d09e5 | |||
| ca21feedc8 | |||
| 0a6ec9235e | |||
| c5b22e0c99 | |||
| 0f3ec9061e | |||
| e318215cc5 | |||
| 6864849115 | |||
| f6536e8d08 | |||
| e3f26d7572 | |||
| a3619c10d7 | |||
| d880c9d098 | |||
| d3b43250b8 | |||
| d1fb19810b | |||
| 062d607da9 | |||
| ef8eead513 | |||
| e58c96eb70 | |||
| 03c17987a1 | |||
| 9f4c4abb84 | |||
| d942b21354 | |||
| 4ff6050f43 | |||
| 42e02fe72d | |||
| 3984f39329 | |||
| 4417938558 | |||
| 90c7f02b40 | |||
| f591871277 | |||
| bae59e2ce0 | |||
| 58957a4aaa | |||
| cedc5de15d | |||
| 5eeef3a9c3 | |||
| 891fc5bea0 | |||
| fff47cc52e | |||
| 0f3ba9c207 | |||
| b53b36fdc5 | |||
| 2c9cea74e3 | |||
| 85c4cbbf37 | |||
| 4bf92f42b8 | |||
| 8336c01c5c | |||
| e35db90232 | |||
| 53774886e7 | |||
| 5c5054f740 | |||
| 642382cbe8 | |||
| f219b9c244 | |||
| 16c40ddae4 | |||
| b7f9099ad9 | |||
| f3c0481631 | |||
| d105842bf2 | |||
| 15d1e118ed | |||
| 0ba76d041a | |||
| 4298ae17ab | |||
| 0266dfd011 | |||
| 6a77cf6a89 | |||
| 10e4e8472b | |||
| 2134383b5a | |||
| ac8eb1bf99 | |||
| 3c9ac03ccc | |||
| b39c1d5dce |
@@ -114,3 +114,39 @@ docs-src/control_generator_routes.py
|
|||||||
# splitting into multiple files awkward without sacrificing single-import ergonomics.
|
# splitting into multiple files awkward without sacrificing single-import ergonomics.
|
||||||
consent-sdk/src/mobile/flutter/consent_sdk.dart
|
consent-sdk/src/mobile/flutter/consent_sdk.dart
|
||||||
consent-sdk/src/mobile/ios/ConsentManager.swift
|
consent-sdk/src/mobile/ios/ConsentManager.swift
|
||||||
|
|
||||||
|
# --- consent-tester: DSI discovery orchestrator ---
|
||||||
|
# Single Playwright session with sequential steps (banner dismiss, self-extract,
|
||||||
|
# link follow, accordion expand, inline sections). Splitting mid-session would
|
||||||
|
# require passing Page objects across modules.
|
||||||
|
consent-tester/services/dsi_discovery.py
|
||||||
|
|
||||||
|
# --- backend-compliance: unified compliance check orchestrator ---
|
||||||
|
# Sequential 7-step pipeline (text resolve, profile detect, check documents,
|
||||||
|
# banner scan, cross-check, profile extract, report). Phase 5 split target.
|
||||||
|
backend-compliance/compliance/api/agent_compliance_check_routes.py
|
||||||
|
|
||||||
|
# --- docs-src: binary office files (not source code) ---
|
||||||
|
# (Also excluded by extension in scripts/check-loc.sh — kept here for legibility.)
|
||||||
|
docs-src/Breakpilot ComplAI Finanzplan.xlsm
|
||||||
|
|
||||||
|
# --- admin-compliance: oversized component refactor backlog ---
|
||||||
|
# Phase 5+ target for splitting into smaller subcomponents per wizard step.
|
||||||
|
admin-compliance/components/sdk/ai-act/DecisionTreeWizard.tsx
|
||||||
|
|
||||||
|
# --- ai-compliance-sdk: oversized handler refactor backlog ---
|
||||||
|
# Phase 5+ target for splitting handler groups into per-resource files.
|
||||||
|
ai-compliance-sdk/internal/api/handlers/tender_handlers.go
|
||||||
|
|
||||||
|
# --- merge grandfathered (2026-05-13) — Phase 5+ refactor backlog ---
|
||||||
|
# Files imported via team work that crossed the hard cap; tracked for splitting.
|
||||||
|
consent-tester/checks/banner_checks.py
|
||||||
|
consent-tester/services/banner_detector.py
|
||||||
|
backend-compliance/compliance/api/agent_doc_check_routes.py
|
||||||
|
backend-compliance/compliance/services/service_registry.py
|
||||||
|
backend-compliance/compliance/services/dsr_workflow_service.py
|
||||||
|
ai-compliance-sdk/internal/iace/hazard_patterns_forestry_conveyor.go
|
||||||
|
admin-compliance/app/sdk/compliance-scope/page.tsx
|
||||||
|
|
||||||
|
# --- zeroclaw: ground-truth corpus (test fixture data, not source) ---
|
||||||
|
zeroclaw/docs/ground-truth/06-spiegel-dsi-fulltext.txt
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Build + push compliance service images to registry.meghsakha.com
|
# Build + push compliance service images to registry.meghsakha.com
|
||||||
# and trigger orca redeploy on every push to main that touches a service.
|
# and trigger orca redeploy after CI passes on main.
|
||||||
|
#
|
||||||
|
# This workflow is gated on the CI workflow completing successfully.
|
||||||
|
# It does not run independently — if CI fails, builds + deploy are skipped.
|
||||||
|
# Per-service builds are gated on detect-changes so only services with
|
||||||
|
# modified files are rebuilt; trigger-orca runs only if at least one build
|
||||||
|
# succeeded and none failed.
|
||||||
#
|
#
|
||||||
# Requires Gitea Actions secrets:
|
# Requires Gitea Actions secrets:
|
||||||
# REGISTRY_USERNAME / REGISTRY_PASSWORD — registry.meghsakha.com credentials
|
# REGISTRY_USERNAME / REGISTRY_PASSWORD — registry.meghsakha.com credentials
|
||||||
@@ -8,24 +14,68 @@
|
|||||||
name: Build + Deploy
|
name: Build + Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_run:
|
||||||
|
workflows: ["CI"]
|
||||||
|
types: [completed]
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
|
||||||
- 'admin-compliance/**'
|
|
||||||
- 'backend-compliance/**'
|
|
||||||
- 'ai-compliance-sdk/**'
|
|
||||||
- 'developer-portal/**'
|
|
||||||
- 'compliance-tts-service/**'
|
|
||||||
- 'document-crawler/**'
|
|
||||||
- 'dsms-gateway/**'
|
|
||||||
- 'dsms-node/**'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ── per-service builds run in parallel ────────────────────────────────────
|
# ── gate: only proceed if CI succeeded ────────────────────────────────────
|
||||||
|
ci-passed:
|
||||||
|
runs-on: docker
|
||||||
|
container: alpine:3.20
|
||||||
|
if: github.event.workflow_run.conclusion == 'success'
|
||||||
|
steps:
|
||||||
|
- name: CI passed, proceeding with build + deploy
|
||||||
|
run: echo "CI run ${{ github.event.workflow_run.id }} succeeded on ${{ github.event.workflow_run.head_branch }} @ ${{ github.event.workflow_run.head_sha }}"
|
||||||
|
|
||||||
|
# ── detect which services changed since the last successful build ────────
|
||||||
|
# Diff base = the last-build/main git tag, set by mark-last-build at the
|
||||||
|
# end of every successful run. Works across squash merges, multi-commit
|
||||||
|
# raw pushes, and force pushes (force pushes leave a stale tag → diff
|
||||||
|
# shows symmetric differences → safe over-rebuild). If the tag doesn't
|
||||||
|
# exist yet, scripts/detect-changes.sh falls back to rebuilding all.
|
||||||
|
detect-changes:
|
||||||
|
runs-on: docker
|
||||||
|
container: alpine:3.20
|
||||||
|
needs: ci-passed
|
||||||
|
outputs:
|
||||||
|
admin: ${{ steps.diff.outputs.admin }}
|
||||||
|
backend: ${{ steps.diff.outputs.backend }}
|
||||||
|
sdk: ${{ steps.diff.outputs.sdk }}
|
||||||
|
portal: ${{ steps.diff.outputs.portal }}
|
||||||
|
tts: ${{ steps.diff.outputs.tts }}
|
||||||
|
crawler: ${{ steps.diff.outputs.crawler }}
|
||||||
|
dsms_gateway: ${{ steps.diff.outputs.dsms_gateway }}
|
||||||
|
dsms_node: ${{ steps.diff.outputs.dsms_node }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git bash
|
||||||
|
git clone --depth 200 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
git fetch --tags origin || true
|
||||||
|
- name: Resolve base SHA from last-build/main tag
|
||||||
|
run: |
|
||||||
|
BASE=$(git rev-parse --verify refs/tags/last-build/main 2>/dev/null || true)
|
||||||
|
echo "Base SHA: ${BASE:-<none, will rebuild all>}"
|
||||||
|
# Deepen if base isn't yet in the shallow clone.
|
||||||
|
if [ -n "$BASE" ] && ! git rev-parse --verify "${BASE}^{commit}" >/dev/null 2>&1; then
|
||||||
|
git fetch --unshallow origin 2>/dev/null \
|
||||||
|
|| git fetch --depth=10000 origin 2>/dev/null \
|
||||||
|
|| true
|
||||||
|
fi
|
||||||
|
echo "BASE_SHA=${BASE}" >> "$GITHUB_ENV"
|
||||||
|
- name: Detect changes
|
||||||
|
id: diff
|
||||||
|
run: bash scripts/detect-changes.sh
|
||||||
|
|
||||||
|
# ── per-service builds run in parallel (only changed services) ────────────
|
||||||
|
|
||||||
build-admin-compliance:
|
build-admin-compliance:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.admin == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -49,6 +99,8 @@ jobs:
|
|||||||
build-backend-compliance:
|
build-backend-compliance:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.backend == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -72,6 +124,8 @@ jobs:
|
|||||||
build-ai-sdk:
|
build-ai-sdk:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.sdk == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -95,6 +149,8 @@ jobs:
|
|||||||
build-developer-portal:
|
build-developer-portal:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.portal == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -118,6 +174,8 @@ jobs:
|
|||||||
build-tts:
|
build-tts:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.tts == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -141,6 +199,8 @@ jobs:
|
|||||||
build-document-crawler:
|
build-document-crawler:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.crawler == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -164,6 +224,8 @@ jobs:
|
|||||||
build-dsms-gateway:
|
build-dsms-gateway:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.dsms_gateway == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -187,6 +249,8 @@ jobs:
|
|||||||
build-dsms-node:
|
build-dsms-node:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: docker:27-cli
|
container: docker:27-cli
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.dsms_node == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -207,7 +271,52 @@ jobs:
|
|||||||
docker push registry.meghsakha.com/breakpilot/compliance-dsms-node:latest
|
docker push registry.meghsakha.com/breakpilot/compliance-dsms-node:latest
|
||||||
docker push registry.meghsakha.com/breakpilot/compliance-dsms-node:${SHORT_SHA}
|
docker push registry.meghsakha.com/breakpilot/compliance-dsms-node:${SHORT_SHA}
|
||||||
|
|
||||||
# ── orca redeploy (only after all builds succeed) ─────────────────────────
|
# ── advance the last-build/main tag — the diff base for future runs ──────
|
||||||
|
# Runs when no build failed. Covers two cases:
|
||||||
|
# - at least one service was rebuilt → mark this SHA as the new baseline
|
||||||
|
# - all services were skipped (nothing changed) → still advance the tag
|
||||||
|
# so we don't keep re-evaluating the same skipped commits forever
|
||||||
|
# Skips if any build failed → tag stays put → next push retries those
|
||||||
|
# services from the previous known-good base.
|
||||||
|
mark-last-build:
|
||||||
|
runs-on: docker
|
||||||
|
container: alpine:3.20
|
||||||
|
needs:
|
||||||
|
- build-admin-compliance
|
||||||
|
- build-backend-compliance
|
||||||
|
- build-ai-sdk
|
||||||
|
- build-developer-portal
|
||||||
|
- build-tts
|
||||||
|
- build-document-crawler
|
||||||
|
- build-dsms-gateway
|
||||||
|
- build-dsms-node
|
||||||
|
if: |
|
||||||
|
always() &&
|
||||||
|
!contains(needs.*.result, 'failure') &&
|
||||||
|
!contains(needs.*.result, 'cancelled')
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git
|
||||||
|
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
- name: Force-push last-build/main tag
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
SHA="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||||
|
echo "Advancing last-build/main → ${SHA}"
|
||||||
|
git tag -f last-build/main "$SHA"
|
||||||
|
# Encode token into the push URL (no on-disk credential persistence).
|
||||||
|
PUSH_URL="${GITHUB_SERVER_URL/https:\/\//https:\/\/x-access-token:${GITEA_TOKEN}@}/${GITHUB_REPOSITORY}.git"
|
||||||
|
git push --force "$PUSH_URL" "refs/tags/last-build/main"
|
||||||
|
echo "Tag last-build/main now at ${SHA}"
|
||||||
|
|
||||||
|
# ── orca redeploy — runs only if at least one build succeeded ─────────────
|
||||||
|
# `always()` lets this run when some builds are skipped (unchanged services).
|
||||||
|
# The contains() checks ensure we only redeploy when something actually built
|
||||||
|
# and no build failed.
|
||||||
|
|
||||||
trigger-orca:
|
trigger-orca:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
@@ -221,6 +330,11 @@ jobs:
|
|||||||
- build-document-crawler
|
- build-document-crawler
|
||||||
- build-dsms-gateway
|
- build-dsms-gateway
|
||||||
- build-dsms-node
|
- build-dsms-node
|
||||||
|
if: |
|
||||||
|
always() &&
|
||||||
|
contains(needs.*.result, 'success') &&
|
||||||
|
!contains(needs.*.result, 'failure') &&
|
||||||
|
!contains(needs.*.result, 'cancelled')
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout (for SHA)
|
- name: Checkout (for SHA)
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -19,6 +19,49 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
|
# ── Change detection (always runs first) ─────────────────────────────────
|
||||||
|
# Diff base:
|
||||||
|
# PR → merge-base with the PR base branch
|
||||||
|
# push → last-build/main tag (set by build-push-deploy after a green build)
|
||||||
|
# Falls back to "rebuild all" when the base is missing or unreachable.
|
||||||
|
detect-changes:
|
||||||
|
runs-on: docker
|
||||||
|
container: alpine:3.20
|
||||||
|
outputs:
|
||||||
|
admin: ${{ steps.diff.outputs.admin }}
|
||||||
|
backend: ${{ steps.diff.outputs.backend }}
|
||||||
|
sdk: ${{ steps.diff.outputs.sdk }}
|
||||||
|
portal: ${{ steps.diff.outputs.portal }}
|
||||||
|
tts: ${{ steps.diff.outputs.tts }}
|
||||||
|
crawler: ${{ steps.diff.outputs.crawler }}
|
||||||
|
dsms_gateway: ${{ steps.diff.outputs.dsms_gateway }}
|
||||||
|
dsms_node: ${{ steps.diff.outputs.dsms_node }}
|
||||||
|
any_python: ${{ steps.diff.outputs.any_python }}
|
||||||
|
any_node: ${{ steps.diff.outputs.any_node }}
|
||||||
|
any: ${{ steps.diff.outputs.any }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
run: |
|
||||||
|
apk add --no-cache git bash
|
||||||
|
git clone --depth 200 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
|
||||||
|
git fetch --depth 200 origin "${GITHUB_BASE_REF}" || true
|
||||||
|
else
|
||||||
|
git fetch --tags origin || true
|
||||||
|
fi
|
||||||
|
- name: Resolve base SHA
|
||||||
|
run: |
|
||||||
|
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
|
||||||
|
BASE=$(git merge-base "origin/${GITHUB_BASE_REF}" HEAD 2>/dev/null || true)
|
||||||
|
else
|
||||||
|
BASE=$(git rev-parse --verify refs/tags/last-build/main 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
echo "Base SHA: ${BASE:-<none>}"
|
||||||
|
echo "BASE_SHA=${BASE}" >> "$GITHUB_ENV"
|
||||||
|
- name: Detect changes
|
||||||
|
id: diff
|
||||||
|
run: bash scripts/detect-changes.sh
|
||||||
|
|
||||||
# ── Branch naming convention (PR only) ──────────────────────────────────
|
# ── Branch naming convention (PR only) ──────────────────────────────────
|
||||||
branch-name:
|
branch-name:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
@@ -55,10 +98,12 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── LOC budget (always) ──────────────────────────────────────────────────
|
# ── LOC budget (only if files changed) ───────────────────────────────────
|
||||||
loc-budget:
|
loc-budget:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: alpine:3.20
|
container: alpine:3.20
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.any == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -86,10 +131,11 @@ jobs:
|
|||||||
--redact \
|
--redact \
|
||||||
|| { echo "::error::Secrets detected — remove them before merging."; exit 1; }
|
|| { echo "::error::Secrets detected — remove them before merging."; exit 1; }
|
||||||
|
|
||||||
# ── Go lint + build (PR only) ────────────────────────────────────────────
|
# ── Go lint + build (PR only, gated on ai-compliance-sdk changes) ────────
|
||||||
go-lint:
|
go-lint:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
if: github.event_name == 'pull_request'
|
needs: detect-changes
|
||||||
|
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.sdk == 'true'
|
||||||
container: golangci/golangci-lint:v1.62-alpine
|
container: golangci/golangci-lint:v1.62-alpine
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -107,10 +153,11 @@ jobs:
|
|||||||
cd ai-compliance-sdk
|
cd ai-compliance-sdk
|
||||||
go build ./...
|
go build ./...
|
||||||
|
|
||||||
# ── Python lint + import check (PR only) ────────────────────────────────
|
# ── Python lint + import check (PR only, gated on python service changes) ─
|
||||||
python-lint:
|
python-lint:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
if: github.event_name == 'pull_request'
|
needs: detect-changes
|
||||||
|
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any_python == 'true'
|
||||||
container: python:3.12-slim
|
container: python:3.12-slim
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -137,10 +184,11 @@ jobs:
|
|||||||
python -c "import compliance; print('Import OK')" \
|
python -c "import compliance; print('Import OK')" \
|
||||||
|| { echo "::error::compliance package fails to import — missing import or syntax error."; exit 1; }
|
|| { echo "::error::compliance package fails to import — missing import or syntax error."; exit 1; }
|
||||||
|
|
||||||
# ── Node.js lint + type-check (PR only) ─────────────────────────────────
|
# ── Node.js lint + type-check (PR only, gated on Next.js service changes) ─
|
||||||
nodejs-lint:
|
nodejs-lint:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
if: github.event_name == 'pull_request'
|
needs: detect-changes
|
||||||
|
if: github.event_name == 'pull_request' && needs.detect-changes.outputs.any_node == 'true'
|
||||||
container: node:20-alpine
|
container: node:20-alpine
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -158,10 +206,12 @@ jobs:
|
|||||||
done
|
done
|
||||||
exit $fail
|
exit $fail
|
||||||
|
|
||||||
# ── Node.js build — next build (PR + push to main) ───────────────────────
|
# ── Node.js build — next build (gated on Next.js service changes) ───────
|
||||||
nodejs-build:
|
nodejs-build:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: node:20-alpine
|
container: node:20-alpine
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.any_node == 'true'
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
@@ -244,10 +294,12 @@ jobs:
|
|||||||
- name: Vulnerability scan (fail on high+)
|
- name: Vulnerability scan (fail on high+)
|
||||||
run: grype sbom:sbom-out/sbom.cdx.json --fail-on high -q
|
run: grype sbom:sbom-out/sbom.cdx.json --fail-on high -q
|
||||||
|
|
||||||
# ── Tests (PR + push to main) ─────────────────────────────────────────────
|
# ── Tests (gated per service) ────────────────────────────────────────────
|
||||||
test-go:
|
test-go:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: golang:1.24-alpine
|
container: golang:1.24-alpine
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.sdk == 'true'
|
||||||
env:
|
env:
|
||||||
CGO_ENABLED: "0"
|
CGO_ENABLED: "0"
|
||||||
steps:
|
steps:
|
||||||
@@ -265,6 +317,8 @@ jobs:
|
|||||||
test-python-backend:
|
test-python-backend:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: python:3.12-slim
|
container: python:3.12-slim
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.backend == 'true'
|
||||||
env:
|
env:
|
||||||
CI: "true"
|
CI: "true"
|
||||||
steps:
|
steps:
|
||||||
@@ -284,6 +338,8 @@ jobs:
|
|||||||
test-python-document-crawler:
|
test-python-document-crawler:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: python:3.12-slim
|
container: python:3.12-slim
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.crawler == 'true'
|
||||||
env:
|
env:
|
||||||
CI: "true"
|
CI: "true"
|
||||||
steps:
|
steps:
|
||||||
@@ -303,6 +359,8 @@ jobs:
|
|||||||
test-python-dsms-gateway:
|
test-python-dsms-gateway:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: python:3.12-slim
|
container: python:3.12-slim
|
||||||
|
needs: detect-changes
|
||||||
|
if: needs.detect-changes.outputs.dsms_gateway == 'true'
|
||||||
env:
|
env:
|
||||||
CI: "true"
|
CI: "true"
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ offiziellen Quellen und gibst praxisnahe Hinweise.
|
|||||||
- NIST SP 800-218 (SSDF) — Secure Software Development Framework
|
- NIST SP 800-218 (SSDF) — Secure Software Development Framework
|
||||||
- NIST Cybersecurity Framework (CSF) 2.0 — Govern, Identify, Protect, Detect, Respond, Recover
|
- NIST Cybersecurity Framework (CSF) 2.0 — Govern, Identify, Protect, Detect, Respond, Recover
|
||||||
- OECD AI Principles — Verantwortungsvolle KI, Transparenz, Accountability
|
- OECD AI Principles — Verantwortungsvolle KI, Transparenz, Accountability
|
||||||
|
- OSHA 29 CFR 1910 Subpart O — US-Maschinensicherheit (Machine Guarding, als Referenz/Vergleich)
|
||||||
|
- Harmonisierte Normen (EN/ISO) — Normnummern, Titel, Status (aktiv/zurueckgezogen), NICHT Normtexte
|
||||||
|
- BAuA Technische Regeln — TRBS (Betriebssicherheit), TRGS (Gefahrstoffe), ASR (Arbeitsstaetten)
|
||||||
|
- EuGH-Urteile — Schrems II, Planet49, SCHUFA Scoring, Google Fonts, Normen-Copyright (C-588/21 P)
|
||||||
|
- EU 2018/1725 — Datenschutz EU-Organe
|
||||||
- EU-IFRS (Verordnung 2023/1803) — EU-uebernommene International Financial Reporting Standards
|
- EU-IFRS (Verordnung 2023/1803) — EU-uebernommene International Financial Reporting Standards
|
||||||
- EFRAG Endorsement Status — Uebersicht welche IFRS-Standards EU-endorsed sind
|
- EFRAG Endorsement Status — Uebersicht welche IFRS-Standards EU-endorsed sind
|
||||||
|
|
||||||
@@ -239,6 +244,6 @@ bedeutet LinkedIn Insight (EU/Irland) wird geladen, Facebook Pixel (USA) wird bl
|
|||||||
Kein anderes CMP bietet dieses Feature.
|
Kein anderes CMP bietet dieses Feature.
|
||||||
|
|
||||||
## Eskalation
|
## Eskalation
|
||||||
- Bei Fragen ausserhalb des Kompetenzbereichs: Hoeflich ablehnen und auf Fachanwalt verweisen
|
- Bei Fragen ausserhalb des Kompetenzbereichs: Wenn die Frage harmlos ist (z.B. "Hast Du Informationen zu X?"), kurz mit Ja/Nein antworten und anbieten konkreter zu helfen. NUR bei sensiblen oder rechtsberatenden Fragen hoeflich ablehnen und auf Fachanwalt verweisen.
|
||||||
- Bei widerspruechlichen Rechtslagen: Beide Positionen darstellen und DSB-Konsultation empfehlen
|
- Bei widerspruechlichen Rechtslagen: Beide Positionen darstellen und DSB-Konsultation empfehlen
|
||||||
- Bei dringenden Datenpannen: Auf 72-Stunden-Frist (Art. 33 DSGVO) hinweisen und Notfallplan-Modul empfehlen
|
- Bei dringenden Datenpannen: Auf 72-Stunden-Frist (Art. 33 DSGVO) hinweisen und Notfallplan-Modul empfehlen
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ export async function handleV2Draft(body: Record<string, unknown>): Promise<Next
|
|||||||
const promptHash = computeChecksumSync({ factsString, tagsString, termsString, styleString, disallowedString })
|
const promptHash = computeChecksumSync({ factsString, tagsString, termsString, styleString, disallowedString })
|
||||||
|
|
||||||
const v2RagCfg = DOCUMENT_RAG_CONFIG[documentType]
|
const v2RagCfg = DOCUMENT_RAG_CONFIG[documentType]
|
||||||
const v2RagContext = await queryRAG(v2RagCfg.query, 3, v2RagCfg.collection)
|
const v2RagContext = v2RagCfg ? await queryRAG(v2RagCfg.query, 3, v2RagCfg.collection) : null
|
||||||
|
|
||||||
const proseBlocks = DOCUMENT_PROSE_BLOCKS[documentType] || DOCUMENT_PROSE_BLOCKS.tom
|
const proseBlocks = DOCUMENT_PROSE_BLOCKS[documentType] || DOCUMENT_PROSE_BLOCKS.tom
|
||||||
const generatedBlocks: ProseBlockOutput[] = []
|
const generatedBlocks: ProseBlockOutput[] = []
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export async function handleV1Draft(body: Record<string, unknown>): Promise<Next
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ragCfg = DOCUMENT_RAG_CONFIG[documentType]
|
const ragCfg = DOCUMENT_RAG_CONFIG[documentType]
|
||||||
const ragContext = await queryRAG(ragCfg.query, 3, ragCfg.collection)
|
const ragContext = ragCfg ? await queryRAG(ragCfg.query, 3, ragCfg.collection) : null
|
||||||
|
|
||||||
let v1SystemPrompt = V1_SYSTEM_PROMPT
|
let v1SystemPrompt = V1_SYSTEM_PROMPT
|
||||||
if (ragContext) {
|
if (ragContext) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { DOCUMENT_SCOPE_MATRIX, DOCUMENT_TYPE_LABELS, getDepthLevelNumeric } from '@/lib/sdk/compliance-scope-types'
|
import { DOCUMENT_SCOPE_MATRIX_CORE, DOCUMENT_TYPE_LABELS, getDepthLevelNumeric } from '@/lib/sdk/compliance-scope-types'
|
||||||
import type { ScopeDocumentType, ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types'
|
import type { ScopeDocumentType, ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types'
|
||||||
import type { ValidationContext, ValidationResult, ValidationFinding } from '@/lib/sdk/drafting-engine/types'
|
import type { ValidationContext, ValidationResult, ValidationFinding } from '@/lib/sdk/drafting-engine/types'
|
||||||
import { buildCrossCheckPrompt } from '@/lib/sdk/drafting-engine/prompts/validate-cross-check'
|
import { buildCrossCheckPrompt } from '@/lib/sdk/drafting-engine/prompts/validate-cross-check'
|
||||||
@@ -94,7 +94,7 @@ function deterministicCheck(
|
|||||||
const findings: ValidationFinding[] = []
|
const findings: ValidationFinding[] = []
|
||||||
const level = validationContext.scopeLevel
|
const level = validationContext.scopeLevel
|
||||||
const levelNumeric = getDepthLevelNumeric(level)
|
const levelNumeric = getDepthLevelNumeric(level)
|
||||||
const req = DOCUMENT_SCOPE_MATRIX[documentType]?.[level]
|
const req = DOCUMENT_SCOPE_MATRIX_CORE[documentType]?.[level]
|
||||||
|
|
||||||
// Check 1: Ist das Dokument auf diesem Level erforderlich?
|
// Check 1: Ist das Dokument auf diesem Level erforderlich?
|
||||||
if (req && !req.required && levelNumeric < 3) {
|
if (req && !req.required && levelNumeric < 3) {
|
||||||
@@ -109,8 +109,8 @@ function deterministicCheck(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check 2: VVT vorhanden wenn erforderlich?
|
// Check 2: VVT vorhanden wenn erforderlich?
|
||||||
const vvtReq = DOCUMENT_SCOPE_MATRIX.vvt[level]
|
const vvtReq = DOCUMENT_SCOPE_MATRIX_CORE.vvt?.[level]
|
||||||
if (vvtReq.required && validationContext.crossReferences.vvtCategories.length === 0) {
|
if (vvtReq?.required && validationContext.crossReferences.vvtCategories.length === 0) {
|
||||||
findings.push({
|
findings.push({
|
||||||
id: 'DET-VVT-MISSING',
|
id: 'DET-VVT-MISSING',
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
const CONSENT_URL = process.env.CONSENT_TESTER_URL || 'http://bp-compliance-consent-tester:8094'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.text()
|
||||||
|
const response = await fetch(`${CONSENT_URL}/authenticated-scan`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(120000),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json({ error: `Auth-Test: ${response.status}` }, { status: response.status })
|
||||||
|
}
|
||||||
|
return NextResponse.json(await response.json())
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: 'Auth-Test fehlgeschlagen' }, { status: 503 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:80
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { url } = body
|
const { url, categories = [] } = body
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return NextResponse.json({ error: 'URL erforderlich' }, { status: 400 })
|
return NextResponse.json({ error: 'URL erforderlich' }, { status: 400 })
|
||||||
@@ -21,7 +21,7 @@ export async function POST(request: NextRequest) {
|
|||||||
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/banner-check`, {
|
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/banner-check`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ url }),
|
body: JSON.stringify({ url, categories }),
|
||||||
signal: AbortSignal.timeout(120000), // 2 min for Playwright
|
signal: AbortSignal.timeout(120000), // 2 min for Playwright
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.text()
|
||||||
|
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/compare`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(300000),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json({ error: `Backend: ${response.status}` }, { status: response.status })
|
||||||
|
}
|
||||||
|
return NextResponse.json(await response.json())
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: 'Vergleich fehlgeschlagen' }, { status: 503 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Unified Compliance Check Proxy
|
||||||
|
* POST: start check for all documents, GET: poll status
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.text()
|
||||||
|
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/compliance-check`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data, { status: response.status })
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json({ error: 'Pruefung konnte nicht gestartet werden' }, { status: 503 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const checkId = request.nextUrl.searchParams.get('check_id')
|
||||||
|
if (!checkId) return NextResponse.json({ error: 'check_id required' }, { status: 400 })
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${BACKEND_URL}/api/compliance/agent/compliance-check/${checkId}`,
|
||||||
|
{ signal: AbortSignal.timeout(10000) },
|
||||||
|
)
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Status-Abfrage fehlgeschlagen' }, { status: 503 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Consent Test API Proxy
|
||||||
|
* POST /api/sdk/v1/agent/consent-test → consent-tester:8094/scan → email via backend
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const CONSENT_TESTER_URL = process.env.CONSENT_TESTER_URL || 'http://bp-compliance-consent-tester:8094'
|
||||||
|
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
interface Violation { service: string; severity: string; text: string; legal_ref: string }
|
||||||
|
|
||||||
|
function buildEmailHtml(data: any): string {
|
||||||
|
const url = data.url || ''
|
||||||
|
const banner = data.banner_detected ? data.banner_provider : 'Nicht erkannt'
|
||||||
|
const phases = data.phases || {}
|
||||||
|
const summary = data.summary || {}
|
||||||
|
|
||||||
|
const sev = (s: string) => s === 'CRITICAL'
|
||||||
|
? '<span style="background:#991b1b;color:white;padding:2px 6px;border-radius:3px;font-size:11px;">KRITISCH</span>'
|
||||||
|
: '<span style="background:#ea580c;color:white;padding:2px 6px;border-radius:3px;font-size:11px;">HOCH</span>'
|
||||||
|
|
||||||
|
const violationRows = (violations: Violation[]) => violations.length === 0
|
||||||
|
? '<tr><td colspan="3" style="padding:6px;color:#16a34a;">✓ Keine Verstoesse</td></tr>'
|
||||||
|
: violations.map(v =>
|
||||||
|
`<tr><td style="padding:6px;">${sev(v.severity)}</td><td style="padding:6px;font-weight:600;">${v.service}</td><td style="padding:6px;">${v.text}<br><span style="color:#6b7280;font-size:11px;">${v.legal_ref}</span></td></tr>`
|
||||||
|
).join('')
|
||||||
|
|
||||||
|
const undocRows = (items: string[]) => items.length === 0
|
||||||
|
? ''
|
||||||
|
: items.map(s => `<tr><td style="padding:6px;">⚠</td><td style="padding:6px;font-weight:600;">${s}</td><td style="padding:6px;">Nicht in Cookie-Policy dokumentiert</td></tr>`).join('')
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="font-family:-apple-system,sans-serif;max-width:700px;margin:0 auto;">
|
||||||
|
<div style="background:linear-gradient(135deg,#1e1b4b,#312e81);color:white;padding:20px 24px;border-radius:12px 12px 0 0;">
|
||||||
|
<h2 style="margin:0;font-size:18px;">Cookie-Consent-Test</h2>
|
||||||
|
<p style="margin:4px 0 0;opacity:0.8;font-size:13px;">${url}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding:20px 24px;border:1px solid #e2e8f0;border-top:none;">
|
||||||
|
<table style="width:100%;border-collapse:collapse;margin-bottom:20px;">
|
||||||
|
<tr><td style="padding:6px 0;color:#64748b;width:160px;">Cookie-Banner</td><td style="padding:6px 0;font-weight:600;">${data.banner_detected ? '✓ ' + banner : '✗ Nicht erkannt'}</td></tr>
|
||||||
|
<tr><td style="padding:6px 0;color:#64748b;">Kritische Verstoesse</td><td style="padding:6px 0;"><strong style="color:${summary.critical > 0 ? '#dc2626' : '#16a34a'}">${summary.critical || 0}</strong></td></tr>
|
||||||
|
<tr><td style="padding:6px 0;color:#64748b;">Hohe Verstoesse</td><td style="padding:6px 0;"><strong style="color:${summary.high > 0 ? '#ea580c' : '#16a34a'}">${summary.high || 0}</strong></td></tr>
|
||||||
|
<tr><td style="padding:6px 0;color:#64748b;">Undokumentiert</td><td style="padding:6px 0;">${summary.undocumented || 0}</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3 style="color:#1e293b;font-size:14px;margin:20px 0 8px;border-bottom:2px solid #e2e8f0;padding-bottom:6px;">
|
||||||
|
🔍 Phase A: Vor Einwilligung
|
||||||
|
</h3>
|
||||||
|
<p style="color:#64748b;font-size:12px;margin:0 0 8px;">Was laedt OHNE dass der Nutzer etwas geklickt hat?</p>
|
||||||
|
<table style="width:100%;border-collapse:collapse;">${violationRows(phases.before_consent?.violations || [])}</table>
|
||||||
|
|
||||||
|
${data.banner_detected ? `
|
||||||
|
<h3 style="color:#1e293b;font-size:14px;margin:20px 0 8px;border-bottom:2px solid #e2e8f0;padding-bottom:6px;">
|
||||||
|
🚫 Phase B: Nach Ablehnung
|
||||||
|
</h3>
|
||||||
|
<p style="color:#64748b;font-size:12px;margin:0 0 8px;">Was laedt NACHDEM der Nutzer "Nur notwendige" geklickt hat?</p>
|
||||||
|
<table style="width:100%;border-collapse:collapse;">${violationRows(phases.after_reject?.violations || [])}</table>
|
||||||
|
|
||||||
|
<h3 style="color:#1e293b;font-size:14px;margin:20px 0 8px;border-bottom:2px solid #e2e8f0;padding-bottom:6px;">
|
||||||
|
✅ Phase C: Nach Zustimmung
|
||||||
|
</h3>
|
||||||
|
<p style="color:#64748b;font-size:12px;margin:0 0 8px;">Was laedt NACHDEM der Nutzer "Alle akzeptieren" geklickt hat?</p>
|
||||||
|
<table style="width:100%;border-collapse:collapse;">${undocRows(phases.after_accept?.undocumented || [])}</table>
|
||||||
|
${(phases.after_accept?.undocumented?.length || 0) === 0 ? '<p style="color:#16a34a;font-size:13px;">✓ Alle Dienste dokumentiert</p>' : ''}
|
||||||
|
` : `
|
||||||
|
<div style="background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:12px;margin:12px 0;">
|
||||||
|
<strong style="color:#dc2626;">Kein Cookie-Banner erkannt.</strong>
|
||||||
|
Alle Tracking-Dienste laden ohne Einwilligung — Verstoss gegen §25 TDDDG.
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
|
||||||
|
${(summary.critical || 0) > 0 ? `
|
||||||
|
<div style="background:#fef2f2;border-left:4px solid #dc2626;padding:12px 16px;margin-top:20px;">
|
||||||
|
<strong style="color:#991b1b;">⚠ KRITISCH:</strong> Tracking-Dienste laden trotz Ablehnung.
|
||||||
|
Dies ist ein schwerer Verstoss gegen §25 TDDDG und kann als Dark Pattern gewertet werden.
|
||||||
|
Sofortige Korrektur der Cookie-Banner-Konfiguration empfohlen.
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background:#f8fafc;padding:12px 24px;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px;">
|
||||||
|
<p style="color:#94a3b8;font-size:11px;margin:0;">
|
||||||
|
Automatisch erstellt vom BreakPilot Compliance Agent (Playwright + Chromium)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const url = body.url
|
||||||
|
|
||||||
|
// Step 1: Run consent test
|
||||||
|
const response = await fetch(`${CONSENT_TESTER_URL}/scan`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(180000),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Consent-Tester: ${response.status}`, detail: errorText },
|
||||||
|
{ status: response.status }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
// Step 2: Send email with phase-structured findings
|
||||||
|
try {
|
||||||
|
const total = (data.summary?.total_violations || 0)
|
||||||
|
const severity = (data.summary?.critical || 0) > 0 ? 'KRITISCH' : total > 0 ? 'FINDINGS' : 'OK'
|
||||||
|
await fetch(`${BACKEND_URL}/api/compliance/agent/notify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
recipient: body.recipient || 'dsb@breakpilot.local',
|
||||||
|
subject: `[COOKIE-TEST] [${severity}] ${url} — ${total} Verstoesse`,
|
||||||
|
body_html: buildEmailHtml({ ...data, url }),
|
||||||
|
role: total > 0 ? 'Datenschutzbeauftragter' : 'Kein Handlungsbedarf',
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
})
|
||||||
|
} catch (emailErr) {
|
||||||
|
console.warn('Email send failed (non-blocking):', emailErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Consent test proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Cookie-Test fehlgeschlagen oder Timeout' },
|
||||||
|
{ status: 503 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Text Extraction Proxy — extract text from a URL via consent-tester
|
||||||
|
* POST: { url: string } -> { text, word_count, title, error }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.text()
|
||||||
|
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/extract-text`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(120000),
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
return NextResponse.json(data, { status: response.status })
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ text: '', word_count: 0, title: '', error: 'Text-Extraktion fehlgeschlagen' },
|
||||||
|
{ status: 503 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Agent Notify API Proxy
|
||||||
|
* POST /api/sdk/v1/agent/notify → backend-compliance /api/compliance/agent/notify
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.text()
|
||||||
|
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/notify`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
return NextResponse.json({ error: errorText }, { status: response.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await response.json())
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Agent notify proxy error:', error)
|
||||||
|
return NextResponse.json({ error: 'Email-Versand fehlgeschlagen' }, { status: 503 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ export async function POST(request: NextRequest) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body,
|
body,
|
||||||
signal: AbortSignal.timeout(30000), // 30s — just needs to start the job
|
signal: AbortSignal.timeout(300000), // 5 min — multi-page scan + LLM calls
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* PDF Export Proxy
|
||||||
|
* POST /api/sdk/v1/agent/scans/pdf → backend /api/compliance/agent/scans/pdf
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.text()
|
||||||
|
|
||||||
|
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/scans/pdf`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json({ error: 'PDF generation failed' }, { status: response.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfBytes = await response.arrayBuffer()
|
||||||
|
return new NextResponse(pdfBytes, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Content-Disposition': 'attachment; filename="compliance-report.pdf"',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PDF proxy error:', error)
|
||||||
|
return NextResponse.json({ error: 'PDF generation failed' }, { status: 503 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* DSMS Gateway Proxy — forwards verify/history requests to dsms-gateway.
|
||||||
|
*/
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const DSMS_URL = process.env.DSMS_GATEWAY_URL || 'http://dsms-gateway:8082'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path } = await params
|
||||||
|
const target = `${DSMS_URL}/api/v1/${path.join('/')}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(target, {
|
||||||
|
headers: { Authorization: 'Bearer system-frontend' },
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
})
|
||||||
|
const data = await resp.json()
|
||||||
|
return NextResponse.json(data, { status: resp.status })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'DSMS not available' }, { status: 503 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,12 +23,13 @@ function getTenantId(request: NextRequest): string {
|
|||||||
*/
|
*/
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const { id } = await params
|
||||||
const tenantId = getTenantId(request)
|
const tenantId = getTenantId(request)
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${BACKEND_URL}/api/compliance/einwilligungen/consents/${params.id}/history`,
|
`${BACKEND_URL}/api/compliance/einwilligungen/consents/${id}/history`,
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { Pool } from 'pg'
|
||||||
|
|
||||||
|
// Disable SSL rejection for self-signed certs
|
||||||
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
|
||||||
|
|
||||||
|
const dbUrl = process.env.COMPLIANCE_DATABASE_URL ||
|
||||||
|
process.env.DATABASE_URL ||
|
||||||
|
'postgresql://breakpilot:breakpilot123@bp-core-postgres:5432/breakpilot_db'
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString: dbUrl })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MC API that returns data in the same format as the canonical controls
|
||||||
|
* endpoint. This allows the MC page to reuse ControlListView components.
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const endpoint = searchParams.get('endpoint') || 'controls'
|
||||||
|
|
||||||
|
switch (endpoint) {
|
||||||
|
case 'frameworks':
|
||||||
|
return NextResponse.json([])
|
||||||
|
|
||||||
|
case 'controls':
|
||||||
|
return handleControls(searchParams)
|
||||||
|
|
||||||
|
case 'controls-count':
|
||||||
|
return handleCount(searchParams)
|
||||||
|
|
||||||
|
case 'controls-meta':
|
||||||
|
return handleMeta(searchParams)
|
||||||
|
|
||||||
|
case 'control':
|
||||||
|
return handleDetail(searchParams)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: 'unknown' }, { status: 400 })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: String(e) }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleControls(params: URLSearchParams) {
|
||||||
|
const search = params.get('search') || ''
|
||||||
|
const limit = Math.min(parseInt(params.get('limit') || '50'), 200)
|
||||||
|
const offset = parseInt(params.get('offset') || '0')
|
||||||
|
const sort = params.get('sort') || 'control_id'
|
||||||
|
const order = params.get('order') === 'desc' ? 'DESC' : 'ASC'
|
||||||
|
|
||||||
|
let where = "WHERE 1=1"
|
||||||
|
const args: unknown[] = []
|
||||||
|
let idx = 1
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where += ` AND mc.canonical_name ILIKE $${idx}`
|
||||||
|
args.push(`%${search}%`)
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
const severity = params.get('severity') || ''
|
||||||
|
if (severity) {
|
||||||
|
if (severity === 'high') { where += ` AND mc.total_controls > 100` }
|
||||||
|
else if (severity === 'medium') { where += ` AND mc.total_controls BETWEEN 20 AND 100` }
|
||||||
|
else if (severity === 'low') { where += ` AND mc.total_controls < 20` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = params.get('domain') || ''
|
||||||
|
if (domain) {
|
||||||
|
where += ` AND mc.canonical_name LIKE $${idx}`
|
||||||
|
args.push(`${domain}%`)
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortCol = sort === 'control_id' ? 'mc.master_control_id' :
|
||||||
|
sort === 'created_at' ? 'mc.created_at' :
|
||||||
|
sort === 'source' ? 'mc.canonical_name' : 'mc.master_control_id'
|
||||||
|
|
||||||
|
args.push(limit, offset)
|
||||||
|
const res = await pool.query(`
|
||||||
|
SELECT mc.master_control_id as control_id,
|
||||||
|
mc.canonical_name as title,
|
||||||
|
'Master Control mit ' || mc.total_controls || ' Atomic Controls' as objective,
|
||||||
|
CASE WHEN mc.total_controls > 100 THEN 'high'
|
||||||
|
WHEN mc.total_controls > 20 THEN 'medium'
|
||||||
|
ELSE 'low' END as severity,
|
||||||
|
'master_control' as category,
|
||||||
|
mc.total_controls,
|
||||||
|
mc.phases_covered,
|
||||||
|
mc.id,
|
||||||
|
mc.created_at
|
||||||
|
FROM compliance.master_controls mc
|
||||||
|
${where}
|
||||||
|
ORDER BY ${sortCol} ${order}
|
||||||
|
LIMIT $${idx} OFFSET $${idx + 1}
|
||||||
|
`, args)
|
||||||
|
|
||||||
|
// Map to canonical control format
|
||||||
|
const controls = res.rows.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
control_id: r.control_id,
|
||||||
|
title: r.title,
|
||||||
|
objective: r.objective,
|
||||||
|
severity: r.severity,
|
||||||
|
category: r.category,
|
||||||
|
release_state: 'active',
|
||||||
|
source_citation: null,
|
||||||
|
verification_method: null,
|
||||||
|
evidence_type: null,
|
||||||
|
target_audience: [],
|
||||||
|
requirements: [],
|
||||||
|
test_procedure: [],
|
||||||
|
evidence: [],
|
||||||
|
open_anchors: [],
|
||||||
|
total_controls: r.total_controls,
|
||||||
|
phases_covered: r.phases_covered,
|
||||||
|
created_at: r.created_at,
|
||||||
|
scope: { platforms: [], components: [], data_classes: [] },
|
||||||
|
risk_score: null,
|
||||||
|
implementation_effort: null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json(controls)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCount(params: URLSearchParams) {
|
||||||
|
const search = params.get('search') || ''
|
||||||
|
let where = "WHERE 1=1"
|
||||||
|
const args: unknown[] = []
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where += ` AND mc.canonical_name ILIKE $1`
|
||||||
|
args.push(`%${search}%`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await pool.query(
|
||||||
|
`SELECT count(*) FROM compliance.master_controls mc ${where}`, args
|
||||||
|
)
|
||||||
|
return NextResponse.json({ total: parseInt(res.rows[0].count) })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMeta(params: URLSearchParams) {
|
||||||
|
const res = await pool.query(`
|
||||||
|
SELECT count(*) as total,
|
||||||
|
count(CASE WHEN total_controls > 100 THEN 1 END) as high_count,
|
||||||
|
count(CASE WHEN total_controls BETWEEN 20 AND 100 THEN 1 END) as medium_count,
|
||||||
|
count(CASE WHEN total_controls < 20 THEN 1 END) as low_count
|
||||||
|
FROM compliance.master_controls
|
||||||
|
`)
|
||||||
|
const r = res.rows[0]
|
||||||
|
|
||||||
|
// Get top L1 tokens as "domains"
|
||||||
|
const domainRes = await pool.query(`
|
||||||
|
SELECT split_part(canonical_name, '_', 1) as domain, count(*) as count
|
||||||
|
FROM compliance.master_controls
|
||||||
|
GROUP BY 1 ORDER BY 2 DESC LIMIT 30
|
||||||
|
`)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
total: parseInt(r.total),
|
||||||
|
severity_counts: {
|
||||||
|
high: parseInt(r.high_count),
|
||||||
|
medium: parseInt(r.medium_count),
|
||||||
|
low: parseInt(r.low_count),
|
||||||
|
},
|
||||||
|
domains: domainRes.rows.map(d => ({ domain: d.domain, count: parseInt(d.count) })),
|
||||||
|
sources: [],
|
||||||
|
no_source_count: 0,
|
||||||
|
release_state_counts: { active: parseInt(r.total) },
|
||||||
|
verification_method_counts: {},
|
||||||
|
category_counts: {},
|
||||||
|
evidence_type_counts: {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDetail(params: URLSearchParams) {
|
||||||
|
const id = params.get('id') || ''
|
||||||
|
const res = await pool.query(`
|
||||||
|
SELECT mc.id, mc.master_control_id as control_id, mc.canonical_name as title,
|
||||||
|
'Master Control mit ' || mc.total_controls || ' Atomic Controls' as objective,
|
||||||
|
mc.total_controls, mc.phases_covered, mc.phase_control_count, mc.created_at
|
||||||
|
FROM compliance.master_controls mc
|
||||||
|
WHERE mc.master_control_id = $1 OR mc.id::text = $1
|
||||||
|
`, [id])
|
||||||
|
|
||||||
|
if (res.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const mc = res.rows[0]
|
||||||
|
|
||||||
|
// Load members
|
||||||
|
const membersRes = await pool.query(`
|
||||||
|
SELECT cc.control_id, cc.title, cc.severity, mcm.phase, mcm.action
|
||||||
|
FROM compliance.master_control_members mcm
|
||||||
|
JOIN compliance.canonical_controls cc ON cc.id = mcm.control_uuid
|
||||||
|
WHERE mcm.master_control_uuid = $1
|
||||||
|
ORDER BY mcm.phase, cc.control_id
|
||||||
|
LIMIT 100
|
||||||
|
`, [mc.id])
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id: mc.id,
|
||||||
|
control_id: mc.control_id,
|
||||||
|
title: mc.title,
|
||||||
|
objective: mc.objective,
|
||||||
|
severity: mc.total_controls > 100 ? 'high' : mc.total_controls > 20 ? 'medium' : 'low',
|
||||||
|
category: 'master_control',
|
||||||
|
release_state: 'active',
|
||||||
|
total_controls: mc.total_controls,
|
||||||
|
phases_covered: mc.phases_covered,
|
||||||
|
phase_control_count: mc.phase_control_count,
|
||||||
|
members: membersRes.rows,
|
||||||
|
requirements: membersRes.rows.map((m: { control_id: string; title: string; phase: string }) =>
|
||||||
|
`[${m.phase}] ${m.control_id}: ${m.title}`
|
||||||
|
),
|
||||||
|
test_procedure: [],
|
||||||
|
evidence: [],
|
||||||
|
open_anchors: [],
|
||||||
|
target_audience: [],
|
||||||
|
source_citation: null,
|
||||||
|
scope: { platforms: [], components: [], data_classes: [] },
|
||||||
|
risk_score: null,
|
||||||
|
implementation_effort: null,
|
||||||
|
created_at: mc.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -39,14 +39,14 @@ async function proxy(request: NextRequest, params: { path?: string[] }, method:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest, { params }: { params: { path?: string[] } }) {
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
|
||||||
return proxy(request, params, 'GET')
|
return proxy(request, await params, 'GET')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest, { params }: { params: { path?: string[] } }) {
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
|
||||||
return proxy(request, params, 'POST')
|
return proxy(request, await params, 'POST')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(request: NextRequest, { params }: { params: { path?: string[] } }) {
|
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
|
||||||
return proxy(request, params, 'DELETE')
|
return proxy(request, await params, 'DELETE')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78
|
|||||||
/**
|
/**
|
||||||
* Proxy: /api/sdk/v1/ucca/decision-tree/... → Go Backend /sdk/v1/ucca/decision-tree/...
|
* Proxy: /api/sdk/v1/ucca/decision-tree/... → Go Backend /sdk/v1/ucca/decision-tree/...
|
||||||
*/
|
*/
|
||||||
async function proxyRequest(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
async function proxyRequest(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
|
||||||
const { path } = await params
|
const { path } = await params
|
||||||
const subPath = path ? path.join('/') : ''
|
const subPath = path ? path.join('/') : ''
|
||||||
const search = request.nextUrl.search || ''
|
const search = request.nextUrl.search || ''
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
|
|
||||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
|
||||||
const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Proxy: GET /api/sdk/v1/ucca/decision-tree → Go Backend GET /sdk/v1/ucca/decision-tree
|
|
||||||
* Returns the decision tree definition (questions, structure)
|
|
||||||
*/
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const tenantID = request.headers.get('X-Tenant-ID') || DEFAULT_TENANT
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${SDK_URL}/sdk/v1/ucca/decision-tree`, {
|
|
||||||
headers: { 'X-Tenant-ID': tenantID },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text()
|
|
||||||
console.error('Decision tree GET error:', errorText)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Backend error', details: errorText },
|
|
||||||
{ status: response.status }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
return NextResponse.json(data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Decision tree proxy error:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to connect to AI compliance backend' },
|
|
||||||
{ status: 503 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Vendor Assessment Status/Detail Proxy
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const { id } = await params
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`${BACKEND_URL}/api/vendor-compliance/assessments/${id}`,
|
||||||
|
{ signal: AbortSignal.timeout(10000) },
|
||||||
|
)
|
||||||
|
const data = await resp.json()
|
||||||
|
return NextResponse.json(data, { status: resp.status })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Assessment status proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Backend nicht erreichbar' },
|
||||||
|
{ status: 503 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const { id } = await params
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`${BACKEND_URL}/api/vendor-compliance/assessments/${id}/approve`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const data = await resp.json()
|
||||||
|
return NextResponse.json(data, { status: resp.status })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Assessment approve proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Backend nicht erreichbar' },
|
||||||
|
{ status: 503 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Vendor Assessment API Proxy
|
||||||
|
* Proxies to backend-compliance (Python FastAPI)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.text()
|
||||||
|
const resp = await fetch(`${BACKEND_URL}/api/vendor-compliance/assessments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
})
|
||||||
|
const data = await resp.json()
|
||||||
|
return NextResponse.json(data, { status: resp.status })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Vendor assessment proxy error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Backend nicht erreichbar' },
|
||||||
|
{ status: 503 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${BACKEND_URL}/api/vendor-compliance/assessments`, {
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
})
|
||||||
|
const data = await resp.json()
|
||||||
|
return NextResponse.json(data)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Vendor assessment list proxy error:', error)
|
||||||
|
return NextResponse.json({ assessments: [] })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { COMPANY_PROFILE_PRESETS, type CompanyProfilePreset } from '@/lib/sdk/company-profile-presets'
|
||||||
|
import { DOC_LABELS, CATEGORY_COLORS } from './doc-labels'
|
||||||
|
|
||||||
|
export function PresetSection({ projectId }: { projectId?: string }) {
|
||||||
|
const [selectedPreset, setSelectedPreset] = useState<CompanyProfilePreset | null>(null)
|
||||||
|
|
||||||
|
// Group recommended docs by category
|
||||||
|
const groupedDocs = selectedPreset
|
||||||
|
? selectedPreset.recommendedDocs.reduce<Record<string, string[]>>((acc, docType) => {
|
||||||
|
const info = DOC_LABELS[docType]
|
||||||
|
if (!info) return acc
|
||||||
|
if (!acc[info.category]) acc[info.category] = []
|
||||||
|
acc[info.category].push(info.label)
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
: null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gradient-to-br from-purple-50 to-white rounded-xl border border-purple-200 p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-gray-900">Schnellstart: Welcher Unternehmenstyp sind Sie?</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Waehlen Sie Ihre Branche — wir zeigen Ihnen welche Dokumente Sie benoetigen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preset Cards */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
|
||||||
|
{COMPANY_PROFILE_PRESETS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.id}
|
||||||
|
onClick={() => setSelectedPreset(selectedPreset?.id === preset.id ? null : preset)}
|
||||||
|
className={`flex flex-col items-center gap-2 p-3 rounded-xl transition-all text-center ${
|
||||||
|
selectedPreset?.id === preset.id
|
||||||
|
? 'bg-purple-100 border-2 border-purple-500 shadow-md'
|
||||||
|
: 'bg-white border border-gray-200 hover:border-purple-300 hover:shadow-sm'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-2xl">{preset.icon}</span>
|
||||||
|
<span className={`text-xs font-medium ${selectedPreset?.id === preset.id ? 'text-purple-700' : 'text-gray-900'}`}>
|
||||||
|
{preset.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-400 leading-tight">{preset.description}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Document Preview — shown when a preset is selected */}
|
||||||
|
{selectedPreset && groupedDocs && (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">
|
||||||
|
{selectedPreset.icon} {selectedPreset.label} — Ihre Dokumente
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{selectedPreset.recommendedDocs.length} Dokumente werden fuer Sie vorbereitet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={projectId
|
||||||
|
? `/sdk/company-profile?project=${projectId}&preset=${selectedPreset.id}`
|
||||||
|
: `/sdk/company-profile?preset=${selectedPreset.id}`}
|
||||||
|
className="px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 transition-colors"
|
||||||
|
>
|
||||||
|
Jetzt starten
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||||
|
{Object.entries(groupedDocs).map(([category, docs]) => (
|
||||||
|
<div key={category} className="space-y-1.5">
|
||||||
|
<span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium ${CATEGORY_COLORS[category] || 'bg-gray-100 text-gray-600'}`}>
|
||||||
|
{category}
|
||||||
|
</span>
|
||||||
|
{docs.map((doc) => (
|
||||||
|
<div key={doc} className="text-xs text-gray-700 pl-1">
|
||||||
|
{doc}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Complete mapping of all document template types to display labels and categories.
|
||||||
|
* Used by PresetSection to show categorized document previews.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DOC_LABELS: Record<string, { label: string; category: string }> = {
|
||||||
|
// ── Website ──────────────────────────────────────────────────────
|
||||||
|
privacy_policy: { label: 'Datenschutzerklaerung', category: 'Website' },
|
||||||
|
impressum: { label: 'Impressum', category: 'Website' },
|
||||||
|
cookie_policy: { label: 'Cookie-Richtlinie', category: 'Website' },
|
||||||
|
cookie_banner: { label: 'Cookie-Banner-Texte', category: 'Website' },
|
||||||
|
|
||||||
|
// ── Vertraege ────────────────────────────────────────────────────
|
||||||
|
agb: { label: 'AGB', category: 'Vertraege' },
|
||||||
|
dpa: { label: 'AVV (Auftragsverarbeitung)', category: 'Vertraege' },
|
||||||
|
nda: { label: 'Geheimhaltungsvereinbarung', category: 'Vertraege' },
|
||||||
|
sla: { label: 'Service Level Agreement', category: 'Vertraege' },
|
||||||
|
terms_of_use: { label: 'Nutzungsbedingungen', category: 'Vertraege' },
|
||||||
|
cloud_service_agreement: { label: 'Cloud-Vertrag', category: 'Vertraege' },
|
||||||
|
data_usage_clause: { label: 'Datennutzungsklausel', category: 'Vertraege' },
|
||||||
|
|
||||||
|
// ── Plattform ────────────────────────────────────────────────────
|
||||||
|
community_guidelines: { label: 'Community Guidelines', category: 'Plattform' },
|
||||||
|
acceptable_use: { label: 'Acceptable Use Policy', category: 'Plattform' },
|
||||||
|
media_content_policy: { label: 'Medien-Richtlinie', category: 'Plattform' },
|
||||||
|
copyright_policy: { label: 'Urheberrechtsrichtlinie', category: 'Plattform' },
|
||||||
|
|
||||||
|
// ── E-Commerce ───────────────────────────────────────────────────
|
||||||
|
widerruf: { label: 'Widerrufsbelehrung', category: 'E-Commerce' },
|
||||||
|
|
||||||
|
// ── HR / Personal ────────────────────────────────────────────────
|
||||||
|
employee_dsi: { label: 'Mitarbeiter-DSI', category: 'HR' },
|
||||||
|
applicant_dsi: { label: 'Bewerber-DSI', category: 'HR' },
|
||||||
|
whistleblower_policy: { label: 'Whistleblower-Richtlinie', category: 'HR' },
|
||||||
|
employee_security_policy: { label: 'Mitarbeiter-Sicherheitsrichtlinie', category: 'HR' },
|
||||||
|
security_awareness_policy: { label: 'Security-Awareness-Richtlinie', category: 'HR' },
|
||||||
|
remote_work_policy: { label: 'Remote-Work-Richtlinie', category: 'HR' },
|
||||||
|
offboarding_policy: { label: 'Offboarding-Richtlinie', category: 'HR' },
|
||||||
|
|
||||||
|
// ── Datenschutz (DSGVO) ──────────────────────────────────────────
|
||||||
|
tom_documentation: { label: 'TOM-Dokumentation', category: 'Datenschutz' },
|
||||||
|
vvt_register: { label: 'Verarbeitungsverzeichnis', category: 'Datenschutz' },
|
||||||
|
loeschkonzept: { label: 'Loeschkonzept', category: 'Datenschutz' },
|
||||||
|
dsfa: { label: 'Datenschutz-Folgenabschaetzung', category: 'Datenschutz' },
|
||||||
|
pflichtenregister: { label: 'Pflichtenregister', category: 'Datenschutz' },
|
||||||
|
data_protection_concept: { label: 'Datenschutzkonzept', category: 'Datenschutz' },
|
||||||
|
consent_texts: { label: 'Einwilligungstexte', category: 'Datenschutz' },
|
||||||
|
informationspflichten: { label: 'Informationspflichten', category: 'Datenschutz' },
|
||||||
|
verpflichtungserklaerung: { label: 'Verpflichtungserklaerung', category: 'Datenschutz' },
|
||||||
|
social_media_dsi: { label: 'Social-Media-DSI', category: 'Datenschutz' },
|
||||||
|
video_conference_dsi: { label: 'Videokonferenz-DSI', category: 'Datenschutz' },
|
||||||
|
|
||||||
|
// ── Daten-Policies ───────────────────────────────────────────────
|
||||||
|
data_protection_policy: { label: 'Datenschutzrichtlinie', category: 'Daten-Governance' },
|
||||||
|
data_classification_policy: { label: 'Datenklassifizierung', category: 'Daten-Governance' },
|
||||||
|
data_retention_policy: { label: 'Aufbewahrungsrichtlinie', category: 'Daten-Governance' },
|
||||||
|
data_transfer_policy: { label: 'Datentransfer-Richtlinie', category: 'Daten-Governance' },
|
||||||
|
privacy_incident_policy: { label: 'Datenschutzvorfall-Richtlinie', category: 'Daten-Governance' },
|
||||||
|
|
||||||
|
// ── Betroffenenrechte ────────────────────────────────────────────
|
||||||
|
dsr_process_art15: { label: 'Auskunftsrecht (Art. 15)', category: 'Betroffenenrechte' },
|
||||||
|
dsr_process_art16: { label: 'Berichtigungsrecht (Art. 16)', category: 'Betroffenenrechte' },
|
||||||
|
dsr_process_art17: { label: 'Loeschungsrecht (Art. 17)', category: 'Betroffenenrechte' },
|
||||||
|
dsr_process_art18: { label: 'Einschraenkungsrecht (Art. 18)', category: 'Betroffenenrechte' },
|
||||||
|
dsr_process_art19: { label: 'Mitteilungspflicht (Art. 19)', category: 'Betroffenenrechte' },
|
||||||
|
dsr_process_art20: { label: 'Datenportabilitaet (Art. 20)', category: 'Betroffenenrechte' },
|
||||||
|
dsr_process_art21: { label: 'Widerspruchsrecht (Art. 21)', category: 'Betroffenenrechte' },
|
||||||
|
|
||||||
|
// ── IT-Sicherheit (Konzepte) ─────────────────────────────────────
|
||||||
|
it_security_concept: { label: 'IT-Sicherheitskonzept', category: 'IT-Sicherheit' },
|
||||||
|
backup_recovery_concept: { label: 'Backup- & Recovery-Konzept', category: 'IT-Sicherheit' },
|
||||||
|
logging_concept: { label: 'Logging-Konzept', category: 'IT-Sicherheit' },
|
||||||
|
incident_response_plan: { label: 'Incident-Response-Plan', category: 'IT-Sicherheit' },
|
||||||
|
access_control_concept: { label: 'Zugriffskonzept', category: 'IT-Sicherheit' },
|
||||||
|
risk_management_concept: { label: 'Risikomanagement-Konzept', category: 'IT-Sicherheit' },
|
||||||
|
isms_manual: { label: 'ISMS-Handbuch', category: 'IT-Sicherheit' },
|
||||||
|
|
||||||
|
// ── IT-Sicherheit (Policies) ─────────────────────────────────────
|
||||||
|
information_security_policy: { label: 'Informationssicherheitsrichtlinie', category: 'IT-Policies' },
|
||||||
|
access_control_policy: { label: 'Zugriffskontrollrichtlinie', category: 'IT-Policies' },
|
||||||
|
password_policy: { label: 'Passwortrichtlinie', category: 'IT-Policies' },
|
||||||
|
encryption_policy: { label: 'Verschluesselungsrichtlinie', category: 'IT-Policies' },
|
||||||
|
logging_policy: { label: 'Protokollierungsrichtlinie', category: 'IT-Policies' },
|
||||||
|
backup_policy: { label: 'Datensicherungsrichtlinie', category: 'IT-Policies' },
|
||||||
|
incident_response_policy: { label: 'Incident-Response-Richtlinie', category: 'IT-Policies' },
|
||||||
|
change_management_policy: { label: 'Change-Management-Richtlinie', category: 'IT-Policies' },
|
||||||
|
patch_management_policy: { label: 'Patch-Management-Richtlinie', category: 'IT-Policies' },
|
||||||
|
asset_management_policy: { label: 'Asset-Management-Richtlinie', category: 'IT-Policies' },
|
||||||
|
cloud_security_policy: { label: 'Cloud-Security-Richtlinie', category: 'IT-Policies' },
|
||||||
|
devsecops_policy: { label: 'DevSecOps-Richtlinie', category: 'IT-Policies' },
|
||||||
|
secrets_management_policy: { label: 'Secrets-Management-Richtlinie', category: 'IT-Policies' },
|
||||||
|
vulnerability_management_policy: { label: 'Schwachstellenmanagement', category: 'IT-Policies' },
|
||||||
|
|
||||||
|
// ── Lieferanten / Drittanbieter ──────────────────────────────────
|
||||||
|
vendor_risk_management_policy: { label: 'Lieferanten-Risikomanagement', category: 'Lieferanten' },
|
||||||
|
third_party_security_policy: { label: 'Drittanbieter-Sicherheit', category: 'Lieferanten' },
|
||||||
|
supplier_security_policy: { label: 'Lieferanten-Anforderungen', category: 'Lieferanten' },
|
||||||
|
transfer_impact_assessment: { label: 'Transfer Impact Assessment', category: 'Lieferanten' },
|
||||||
|
scc_companion: { label: 'SCC-Begleitdokument', category: 'Lieferanten' },
|
||||||
|
|
||||||
|
// ── BCM / Notfall ────────────────────────────────────────────────
|
||||||
|
business_continuity_policy: { label: 'Business-Continuity', category: 'BCM' },
|
||||||
|
disaster_recovery_policy: { label: 'Disaster-Recovery', category: 'BCM' },
|
||||||
|
crisis_management_policy: { label: 'Krisenmanagement', category: 'BCM' },
|
||||||
|
|
||||||
|
// ── KI / Cyber ───────────────────────────────────────────────────
|
||||||
|
ai_usage_policy: { label: 'KI-Nutzungsrichtlinie', category: 'KI & Cyber' },
|
||||||
|
cybersecurity_policy: { label: 'Cybersecurity-Richtlinie (CRA)', category: 'KI & Cyber' },
|
||||||
|
byod_policy: { label: 'BYOD-Richtlinie', category: 'KI & Cyber' },
|
||||||
|
|
||||||
|
// ── SOP ──────────────────────────────────────────────────────────
|
||||||
|
standard_operating_procedure: { label: 'Standard Operating Procedure', category: 'Prozesse' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CATEGORY_COLORS: Record<string, string> = {
|
||||||
|
Website: 'bg-blue-50 text-blue-700',
|
||||||
|
Vertraege: 'bg-purple-50 text-purple-700',
|
||||||
|
Plattform: 'bg-indigo-50 text-indigo-700',
|
||||||
|
'E-Commerce': 'bg-green-50 text-green-700',
|
||||||
|
HR: 'bg-amber-50 text-amber-700',
|
||||||
|
Datenschutz: 'bg-red-50 text-red-700',
|
||||||
|
'Daten-Governance': 'bg-rose-50 text-rose-700',
|
||||||
|
Betroffenenrechte: 'bg-fuchsia-50 text-fuchsia-700',
|
||||||
|
'IT-Sicherheit': 'bg-gray-100 text-gray-700',
|
||||||
|
'IT-Policies': 'bg-slate-100 text-slate-700',
|
||||||
|
Lieferanten: 'bg-orange-50 text-orange-700',
|
||||||
|
BCM: 'bg-yellow-50 text-yellow-700',
|
||||||
|
'KI & Cyber': 'bg-cyan-50 text-cyan-700',
|
||||||
|
Marketing: 'bg-pink-50 text-pink-700',
|
||||||
|
Prozesse: 'bg-teal-50 text-teal-700',
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface AuthCheck {
|
||||||
|
found: boolean
|
||||||
|
text: string
|
||||||
|
legal_ref: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthData {
|
||||||
|
url: string
|
||||||
|
authenticated: boolean
|
||||||
|
login_error: string
|
||||||
|
checks: Record<string, AuthCheck>
|
||||||
|
findings_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHECK_LABELS: Record<string, { label: string; icon: string }> = {
|
||||||
|
cancel_subscription: { label: 'Kuendigungsbutton (2 Klicks)', icon: '🚫' },
|
||||||
|
delete_account: { label: 'Konto loeschen', icon: '🗑️' },
|
||||||
|
export_data: { label: 'Daten exportieren', icon: '📥' },
|
||||||
|
consent_settings: { label: 'Einwilligungen widerrufen', icon: '⚙️' },
|
||||||
|
profile_visible: { label: 'Profildaten einsehen', icon: '👤' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthTestResult({ data }: { data: AuthData }) {
|
||||||
|
if (!data.authenticated) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<p className="text-sm font-medium text-red-800">Login fehlgeschlagen</p>
|
||||||
|
<p className="text-xs text-red-600 mt-1">{data.login_error || 'Credentials oder Formular nicht erkannt'}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded-full bg-green-500" />
|
||||||
|
<span className="text-sm font-medium text-gray-900">Erfolgreich eingeloggt</span>
|
||||||
|
<span className={`ml-auto text-xs px-2 py-1 rounded font-medium ${data.findings_count > 0 ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}`}>
|
||||||
|
{data.findings_count} fehlende Funktionen
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(data.checks).map(([key, check]) => {
|
||||||
|
const info = CHECK_LABELS[key] || { label: key, icon: '❓' }
|
||||||
|
return (
|
||||||
|
<div key={key} className={`flex items-center gap-3 p-3 rounded-lg border ${check.found ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}`}>
|
||||||
|
<span className="text-lg">{info.icon}</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className={`text-sm font-medium ${check.found ? 'text-green-800' : 'text-red-800'}`}>
|
||||||
|
{check.found ? '✓' : '✗'} {info.label}
|
||||||
|
</p>
|
||||||
|
{check.text && <p className="text-xs text-gray-500 mt-0.5">{check.text}</p>}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-gray-400">{check.legal_ref}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.findings_count > 0 && (
|
||||||
|
<div className="bg-red-50 border-l-4 border-red-500 p-3 text-xs text-red-700">
|
||||||
|
<strong>{data.findings_count} Pflichtfunktion(en) fehlen.</strong> Der Nutzer kann seine Rechte
|
||||||
|
nach DSGVO nicht vollstaendig ausueben.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,14 +31,51 @@ interface BannerResult {
|
|||||||
after_reject: { cookies: string[]; scripts: string[]; new_tracking: string[]; violations: any[] }
|
after_reject: { cookies: string[]; scripts: string[]; new_tracking: string[]; violations: any[] }
|
||||||
after_accept: { cookies: string[]; scripts: string[]; new_tracking: string[]; undocumented: string[] }
|
after_accept: { cookies: string[]; scripts: string[]; new_tracking: string[]; undocumented: string[] }
|
||||||
}
|
}
|
||||||
|
email_status?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ id: 'all', label: 'Alle Kategorien' },
|
||||||
|
{ id: 'necessary', label: 'Notwendig' },
|
||||||
|
{ id: 'statistics', label: 'Statistik' },
|
||||||
|
{ id: 'marketing', label: 'Marketing' },
|
||||||
|
{ id: 'functional', label: 'Funktional' },
|
||||||
|
{ id: 'preferences', label: 'Praeferenzen' },
|
||||||
|
]
|
||||||
|
|
||||||
export function BannerCheckTab() {
|
export function BannerCheckTab() {
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState(() =>
|
||||||
|
typeof window !== 'undefined' ? localStorage.getItem('banner-check-url') || '' : ''
|
||||||
|
)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [progress, setProgress] = useState('')
|
const [progress, setProgress] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [result, setResult] = useState<BannerResult | null>(null)
|
const [result, setResult] = useState<BannerResult | null>(() => {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
try { const s = localStorage.getItem('banner-check-result'); return s ? JSON.parse(s) : null } catch { return null }
|
||||||
|
})
|
||||||
|
const [categories, setCategories] = useState<string[]>(['all'])
|
||||||
|
const [useAgent, setUseAgent] = useState(false)
|
||||||
|
const [mcResults, setMcResults] = useState<any>(null)
|
||||||
|
const [history, setHistory] = useState<{ url: string; date: string; provider: string; violations: number; pct: number; resultKey: string }[]>(() => {
|
||||||
|
if (typeof window === 'undefined') return []
|
||||||
|
try { return JSON.parse(localStorage.getItem('banner-check-history') || '[]') } catch { return [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Persist URL
|
||||||
|
React.useEffect(() => { localStorage.setItem('banner-check-url', url) }, [url])
|
||||||
|
|
||||||
|
const toggleCategory = (id: string) => {
|
||||||
|
if (id === 'all') {
|
||||||
|
setCategories(['all'])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCategories(prev => {
|
||||||
|
const without = prev.filter(c => c !== 'all' && c !== id)
|
||||||
|
const next = prev.includes(id) ? without : [...without, id]
|
||||||
|
return next.length === 0 ? ['all'] : next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleScan = async (e: React.FormEvent) => {
|
const handleScan = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -49,15 +86,64 @@ export function BannerCheckTab() {
|
|||||||
setResult(null)
|
setResult(null)
|
||||||
setProgress('Cookie-Banner wird analysiert...')
|
setProgress('Cookie-Banner wird analysiert...')
|
||||||
|
|
||||||
|
const selectedCategories = categories.includes('all') ? [] : categories
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/sdk/v1/agent/banner-check', {
|
const res = await fetch('/api/sdk/v1/agent/banner-check', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ url: url.trim() }),
|
body: JSON.stringify({ url: url.trim(), categories: selectedCategories }),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error(`Fehler: ${res.status}`)
|
if (!res.ok) throw new Error(`Fehler: ${res.status}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setResult(data)
|
setResult(data)
|
||||||
|
localStorage.setItem('banner-check-result', JSON.stringify(data))
|
||||||
|
|
||||||
|
// If agent mode: also run cookie doc-check with 381 MCs
|
||||||
|
if (useAgent) {
|
||||||
|
setProgress('KI-Agent prueft Cookie-Richtlinie (381 MCs)...')
|
||||||
|
try {
|
||||||
|
const mcRes = await fetch('/api/sdk/v1/agent/doc-check', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
entries: [{ doc_type: 'cookie', label: 'Cookie-Richtlinie', url: url.trim() }],
|
||||||
|
recipient: 'dsb@breakpilot.local',
|
||||||
|
use_agent: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (mcRes.ok) {
|
||||||
|
const { check_id } = await mcRes.json()
|
||||||
|
if (check_id) {
|
||||||
|
for (let i = 0; i < 60; i++) {
|
||||||
|
await new Promise(r => setTimeout(r, 3000))
|
||||||
|
const poll = await fetch(`/api/sdk/v1/agent/doc-check?check_id=${check_id}`)
|
||||||
|
if (!poll.ok) continue
|
||||||
|
const pd = await poll.json()
|
||||||
|
if (pd.progress) setProgress(`KI-Agent: ${pd.progress}`)
|
||||||
|
if (pd.status === 'completed' && pd.result) { setMcResults(pd.result); break }
|
||||||
|
if (pd.status === 'failed') break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* agent check is optional */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to history with persistent result
|
||||||
|
const violations = data.structured_checks?.filter((c: CheckItem) => !c.passed && !c.skipped).length || 0
|
||||||
|
const resultKey = `banner-check-result-${Date.now()}`
|
||||||
|
try { localStorage.setItem(resultKey, JSON.stringify(data)) } catch { /* quota */ }
|
||||||
|
const entry = {
|
||||||
|
url: url.trim(),
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
provider: data.banner_provider || 'Unbekannt',
|
||||||
|
violations,
|
||||||
|
pct: data.completeness_pct ?? 0,
|
||||||
|
resultKey,
|
||||||
|
}
|
||||||
|
const updated = [entry, ...history].slice(0, 30)
|
||||||
|
setHistory(updated)
|
||||||
|
localStorage.setItem('banner-check-history', JSON.stringify(updated))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -66,12 +152,26 @@ export function BannerCheckTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadFromHistory = (entry: { url: string; resultKey?: string }) => {
|
||||||
|
setUrl(entry.url)
|
||||||
|
if (entry.resultKey) {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(entry.resultKey)
|
||||||
|
if (saved) { setResult(JSON.parse(saved)); return }
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
// Fallback: load last result
|
||||||
|
try {
|
||||||
|
const last = localStorage.getItem('banner-check-result')
|
||||||
|
if (last) setResult(JSON.parse(last))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const structuredChecks = result?.structured_checks || []
|
const structuredChecks = result?.structured_checks || []
|
||||||
const hasStructured = structuredChecks.length > 0
|
const hasStructured = structuredChecks.length > 0
|
||||||
const compPct = result?.completeness_pct ?? 0
|
const compPct = result?.completeness_pct ?? 0
|
||||||
const corrPct = result?.correctness_pct ?? 0
|
const corrPct = result?.correctness_pct ?? 0
|
||||||
|
|
||||||
// Build ChecklistView-compatible result for structured checks
|
|
||||||
const checklistResults = hasStructured ? [{
|
const checklistResults = hasStructured ? [{
|
||||||
label: `Cookie-Banner: ${result?.banner_provider || 'Unbekannt'}`,
|
label: `Cookie-Banner: ${result?.banner_provider || 'Unbekannt'}`,
|
||||||
url: url,
|
url: url,
|
||||||
@@ -94,22 +194,59 @@ export function BannerCheckTab() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleScan} className="flex gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<input
|
<button type="button" onClick={() => setUseAgent(!useAgent)}
|
||||||
type="url" value={url} onChange={e => setUrl(e.target.value)}
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
|
||||||
placeholder="https://www.example.com/"
|
useAgent ? 'bg-emerald-100 border-emerald-300 text-emerald-800' : 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-gray-100'
|
||||||
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm"
|
}`}>
|
||||||
disabled={loading} required
|
<span className={`w-2 h-2 rounded-full ${useAgent ? 'bg-emerald-500' : 'bg-gray-300'}`} />
|
||||||
/>
|
{useAgent ? 'KI-Agent aktiv (381 Cookie-MCs)' : 'KI-Agent aus'}
|
||||||
<button type="submit" disabled={loading || !url.trim()}
|
|
||||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center gap-2 text-sm font-medium">
|
|
||||||
{loading ? (
|
|
||||||
<><svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
||||||
</svg>Pruefe...</>
|
|
||||||
) : 'Banner pruefen'}
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleScan} className="space-y-3">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<input
|
||||||
|
type="url" value={url} onChange={e => setUrl(e.target.value)}
|
||||||
|
placeholder="https://www.example.com/"
|
||||||
|
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm"
|
||||||
|
disabled={loading} required
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={loading || !url.trim()}
|
||||||
|
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center gap-2 text-sm font-medium whitespace-nowrap">
|
||||||
|
{loading ? (
|
||||||
|
<><svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>Pruefe...</>
|
||||||
|
) : 'Banner pruefen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CATEGORIES.map(cat => (
|
||||||
|
<label key={cat.id}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium cursor-pointer border transition-colors ${
|
||||||
|
categories.includes(cat.id)
|
||||||
|
? 'bg-purple-100 border-purple-300 text-purple-800'
|
||||||
|
: 'bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input type="checkbox" checked={categories.includes(cat.id)}
|
||||||
|
onChange={() => toggleCategory(cat.id)} className="sr-only" />
|
||||||
|
<span className={`w-3 h-3 rounded-sm border flex items-center justify-center ${
|
||||||
|
categories.includes(cat.id) ? 'bg-purple-600 border-purple-600' : 'border-gray-400'
|
||||||
|
}`}>
|
||||||
|
{categories.includes(cat.id) && (
|
||||||
|
<svg className="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 12 12">
|
||||||
|
<path d="M10 3L4.5 8.5 2 6" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{cat.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{progress && (
|
{progress && (
|
||||||
@@ -128,68 +265,97 @@ export function BannerCheckTab() {
|
|||||||
|
|
||||||
{result && (
|
{result && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 3-Phase Summary Card */}
|
|
||||||
{result.phases && (
|
{result.phases && (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
||||||
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-2xl">
|
<span className="text-2xl">{result.banner_detected ? '🛡️' : '⚠️'}</span>
|
||||||
{result.banner_detected ? '\u{1F6E1}\u{FE0F}' : '\u26A0\u{FE0F}'}
|
|
||||||
</span>
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-gray-900">
|
<h3 className="text-sm font-semibold text-gray-900">
|
||||||
{result.banner_detected
|
{result.banner_detected
|
||||||
? `Banner erkannt: ${result.banner_provider || 'Unbekannter Anbieter'}`
|
? `Banner erkannt: ${result.banner_provider || 'Unbekannter Anbieter'}`
|
||||||
: 'Kein Cookie-Banner erkannt'}
|
: 'Kein Cookie-Banner erkannt'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">
|
<p className="text-xs text-gray-500 mt-0.5">3-Phasen-Analyse: Cookies und Scripts vor/nach Interaktion</p>
|
||||||
3-Phasen-Analyse: Cookies und Scripts vor/nach Interaktion
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-3 grid grid-cols-3 gap-4">
|
<div className="px-6 py-3 grid grid-cols-3 gap-4">
|
||||||
<PhaseBox
|
<PhaseBox label="Vor Consent" icon="🔒"
|
||||||
label="Vor Consent"
|
|
||||||
icon="\uD83D\uDD12"
|
|
||||||
cookies={result.phases.before_consent.cookies?.length ?? 0}
|
cookies={result.phases.before_consent.cookies?.length ?? 0}
|
||||||
scripts={result.phases.before_consent.scripts?.length ?? 0}
|
scripts={result.phases.before_consent.scripts?.length ?? 0}
|
||||||
violations={result.phases.before_consent.violations?.length ?? 0}
|
violations={result.phases.before_consent.violations?.length ?? 0} />
|
||||||
/>
|
<PhaseBox label="Nach Ablehnen" icon="🚫"
|
||||||
<PhaseBox
|
|
||||||
label="Nach Ablehnen"
|
|
||||||
icon="\uD83D\uDEAB"
|
|
||||||
cookies={result.phases.after_reject.cookies?.length ?? 0}
|
cookies={result.phases.after_reject.cookies?.length ?? 0}
|
||||||
scripts={result.phases.after_reject.scripts?.length ?? 0}
|
scripts={result.phases.after_reject.scripts?.length ?? 0}
|
||||||
violations={result.phases.after_reject.violations?.length ?? 0}
|
violations={result.phases.after_reject.violations?.length ?? 0} />
|
||||||
/>
|
<PhaseBox label="Nach Akzeptieren" icon="✅"
|
||||||
<PhaseBox
|
|
||||||
label="Nach Akzeptieren"
|
|
||||||
icon="\u2705"
|
|
||||||
cookies={result.phases.after_accept.cookies?.length ?? 0}
|
cookies={result.phases.after_accept.cookies?.length ?? 0}
|
||||||
scripts={result.phases.after_accept.scripts?.length ?? 0}
|
scripts={result.phases.after_accept.scripts?.length ?? 0}
|
||||||
violations={0}
|
violations={0} />
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Structured L1/L2 Checklist */}
|
|
||||||
{hasStructured && (
|
{hasStructured && (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||||
<ChecklistView results={checklistResults} />
|
<ChecklistView results={checklistResults} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{result.email_status && (
|
||||||
|
<div className="text-xs text-gray-500 flex items-center gap-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${result.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
||||||
|
E-Mail: {result.email_status === 'sent' ? 'Gesendet' : result.email_status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* MC Agent Results (Cookie-Richtlinie) */}
|
||||||
|
{mcResults?.results && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-800 mb-3">KI-Agent: Cookie-Richtlinie (381 MCs)</h4>
|
||||||
|
<ChecklistView results={mcResults.results} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!result.banner_detected && !hasStructured && (
|
{!result.banner_detected && !hasStructured && (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Kein Cookie-Banner auf dieser Seite gefunden. Falls Cookies gesetzt werden, ist ein Banner nach ss25 TDDDG Pflicht.
|
Kein Cookie-Banner auf dieser Seite gefunden. Falls Cookies gesetzt werden, ist ein Banner nach §25 TDDDG Pflicht.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* History */}
|
||||||
|
{history.length > 0 && (
|
||||||
|
<div className="border border-gray-200 rounded-xl p-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">Letzte Banner-Checks</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{history.map((h, i) => (
|
||||||
|
<button key={i} onClick={() => loadFromHistory(h)}
|
||||||
|
className="w-full flex items-center justify-between p-2.5 rounded-lg border border-gray-100 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium text-gray-900 truncate">{h.url}</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{new Date(h.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||||
|
{' · '}{h.provider}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 shrink-0 ml-3">
|
||||||
|
<span className={`text-xs font-medium ${h.violations > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||||
|
{h.violations} Findings
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs font-medium ${h.pct === 100 ? 'text-green-700' : h.pct >= 50 ? 'text-yellow-700' : 'text-red-700'}`}>
|
||||||
|
{h.pct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -201,14 +367,8 @@ function PhaseBox({ label, icon, cookies, scripts, violations }: {
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-lg">{icon}</div>
|
<div className="text-lg">{icon}</div>
|
||||||
<div className="text-xs font-medium text-gray-700">{label}</div>
|
<div className="text-xs font-medium text-gray-700">{label}</div>
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<div className="text-xs text-gray-500 mt-1">{cookies} Cookies, {scripts} Scripts</div>
|
||||||
{cookies} Cookies, {scripts} Scripts
|
{violations > 0 && <div className="text-xs text-red-600 font-medium">{violations} Verstoesse</div>}
|
||||||
</div>
|
|
||||||
{violations > 0 && (
|
|
||||||
<div className="text-xs text-red-600 font-medium">
|
|
||||||
{violations} Verstoesse
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ interface DocResult {
|
|||||||
checks: CheckItem[]
|
checks: CheckItem[]
|
||||||
findings_count: number
|
findings_count: number
|
||||||
error: string
|
error: string
|
||||||
|
scenario?: string // regenerate | fix | import | skip
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCENARIO_LABELS: Record<string, { label: string; color: string; bg: string }> = {
|
||||||
|
regenerate: { label: 'Neugenerierung', color: 'text-red-700', bg: 'bg-red-100' },
|
||||||
|
fix: { label: 'Korrekturen', color: 'text-amber-700', bg: 'bg-amber-100' },
|
||||||
|
import: { label: 'Konform', color: 'text-green-700', bg: 'bg-green-100' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const DOC_TYPE_LABELS: Record<string, string> = {
|
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||||
@@ -46,7 +53,7 @@ function groupChecks(checks: CheckItem[]): GroupedCheck[] {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function CheckIcon({ passed, skipped }: { passed: boolean; skipped?: boolean }) {
|
function CheckIcon({ passed, skipped, isInfo }: { passed: boolean; skipped?: boolean; isInfo?: boolean }) {
|
||||||
if (skipped) {
|
if (skipped) {
|
||||||
return (
|
return (
|
||||||
<svg className="w-4 h-4 text-gray-300 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 text-gray-300 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -61,6 +68,13 @@ function CheckIcon({ passed, skipped }: { passed: boolean; skipped?: boolean })
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (isInfo) {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 text-gray-400 mt-0.5 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<svg className="w-4 h-4 text-red-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 text-red-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
@@ -84,14 +98,23 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
|||||||
|
|
||||||
if (!results || results.length === 0) return null
|
if (!results || results.length === 0) return null
|
||||||
|
|
||||||
const totalOk = results.filter(r => r.completeness_pct === 100).length
|
const scenarioCounts = {
|
||||||
|
regenerate: results.filter(r => r.scenario === 'regenerate').length,
|
||||||
|
fix: results.filter(r => r.scenario === 'fix').length,
|
||||||
|
import: results.filter(r => r.scenario === 'import').length,
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
<h3 className="text-sm font-semibold text-gray-800">
|
<h3 className="text-sm font-semibold text-gray-800">
|
||||||
Dokumenten-Pruefung ({results.length} Dokumente, {totalOk} vollstaendig)
|
Dokumenten-Pruefung ({results.length} Dokumente)
|
||||||
</h3>
|
</h3>
|
||||||
|
<div className="flex gap-2 text-[10px]">
|
||||||
|
{scenarioCounts.import > 0 && <span className="bg-green-100 text-green-700 px-2 py-0.5 rounded-full">{scenarioCounts.import} konform</span>}
|
||||||
|
{scenarioCounts.fix > 0 && <span className="bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full">{scenarioCounts.fix} Korrekturen</span>}
|
||||||
|
{scenarioCounts.regenerate > 0 && <span className="bg-red-100 text-red-700 px-2 py-0.5 rounded-full">{scenarioCounts.regenerate} Neugenerierung</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -104,8 +127,9 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
|||||||
const typeLabel = DOC_TYPE_LABELS[r.doc_type] || r.doc_type
|
const typeLabel = DOC_TYPE_LABELS[r.doc_type] || r.doc_type
|
||||||
const grouped = groupChecks(r.checks)
|
const grouped = groupChecks(r.checks)
|
||||||
const l1Checks = r.checks.filter(c => (c.level ?? 1) === 1)
|
const l1Checks = r.checks.filter(c => (c.level ?? 1) === 1)
|
||||||
|
const l1Scoreable = l1Checks.filter(c => c.severity !== 'INFO')
|
||||||
const l2Active = r.checks.filter(c => (c.level ?? 1) === 2 && !c.skipped)
|
const l2Active = r.checks.filter(c => (c.level ?? 1) === 2 && !c.skipped)
|
||||||
const l1Passed = l1Checks.filter(c => c.passed).length
|
const l1Passed = l1Scoreable.filter(c => c.passed).length
|
||||||
const l2Passed = l2Active.filter(c => c.passed).length
|
const l2Passed = l2Active.filter(c => c.passed).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -123,10 +147,17 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
|||||||
{typeLabel}
|
{typeLabel}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm font-medium text-gray-900 truncate">{r.label}</div>
|
<div className="text-sm font-medium text-gray-900 truncate flex items-center gap-2">
|
||||||
|
{r.label}
|
||||||
|
{r.scenario && SCENARIO_LABELS[r.scenario] && (
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${SCENARIO_LABELS[r.scenario].bg} ${SCENARIO_LABELS[r.scenario].color}`}>
|
||||||
|
{SCENARIO_LABELS[r.scenario].label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="text-xs text-gray-500 truncate">
|
<div className="text-xs text-gray-500 truncate">
|
||||||
{l1Checks.length > 0
|
{l1Checks.length > 0
|
||||||
? `${l1Passed}/${l1Checks.length} Pflichtangaben`
|
? `${l1Passed}/${l1Scoreable.length} Pflichtangaben`
|
||||||
+ (l2Active.length > 0 ? `, ${l2Passed}/${l2Active.length} Detailpruefungen` : '')
|
+ (l2Active.length > 0 ? `, ${l2Passed}/${l2Active.length} Detailpruefungen` : '')
|
||||||
: r.url}
|
: r.url}
|
||||||
</div>
|
</div>
|
||||||
@@ -137,8 +168,9 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
|||||||
<span className="text-xs text-red-600 font-medium">Fehler</span>
|
<span className="text-xs text-red-600 font-medium">Fehler</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2" title={`Pflichtangaben: ${l1Passed}/${l1Scoreable.length}`}>
|
||||||
<div className="w-16 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
<span className="text-[10px] text-gray-400 w-7">Pflicht</span>
|
||||||
|
<div className="w-14 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||||
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${pct}%` }} />
|
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${pct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-xs font-medium w-10 text-right ${
|
<span className={`text-xs font-medium w-10 text-right ${
|
||||||
@@ -146,8 +178,9 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
|||||||
}`}>{pct}%</span>
|
}`}>{pct}%</span>
|
||||||
</div>
|
</div>
|
||||||
{l2Active.length > 0 && (
|
{l2Active.length > 0 && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2" title={`Detailpruefung: ${l2Passed}/${l2Active.length}`}>
|
||||||
<div className="w-16 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
<span className="text-[10px] text-gray-400 w-7">Detail</span>
|
||||||
|
<div className="w-14 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||||
<div className={`h-full rounded-full ${cBarColor}`} style={{ width: `${cpct}%` }} />
|
<div className={`h-full rounded-full ${cBarColor}`} style={{ width: `${cpct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-medium w-10 text-right text-blue-600">{cpct}%</span>
|
<span className="text-xs font-medium w-10 text-right text-blue-600">{cpct}%</span>
|
||||||
@@ -164,13 +197,18 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
|||||||
<p className="text-sm text-red-600">{r.error}</p>
|
<p className="text-sm text-red-600">{r.error}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{grouped.map((g) => (
|
{grouped.map((g) => {
|
||||||
|
const l1Info = g.check.severity === 'INFO' && !g.check.passed
|
||||||
|
return (
|
||||||
<div key={g.check.id}>
|
<div key={g.check.id}>
|
||||||
{/* L1 check */}
|
{/* L1 check */}
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<CheckIcon passed={g.check.passed} />
|
<CheckIcon passed={g.check.passed} isInfo={l1Info} />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className={`text-sm ${g.check.passed ? 'text-gray-700' : 'text-red-700 font-medium'}`}>
|
<div className={`text-sm ${
|
||||||
|
g.check.passed ? 'text-gray-700'
|
||||||
|
: l1Info ? 'text-gray-500' : 'text-red-700 font-medium'
|
||||||
|
}`}>
|
||||||
{g.check.label}
|
{g.check.label}
|
||||||
{g.children.length > 0 && <L2Summary>{g.children}</L2Summary>}
|
{g.children.length > 0 && <L2Summary>{g.children}</L2Summary>}
|
||||||
</div>
|
</div>
|
||||||
@@ -180,7 +218,7 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!g.check.passed && g.check.hint && (
|
{!g.check.passed && g.check.hint && (
|
||||||
<div className="text-xs text-red-600/80 mt-0.5">
|
<div className={`text-xs mt-0.5 ${l1Info ? 'text-gray-400' : 'text-red-600/80'}`}>
|
||||||
{g.check.hint}
|
{g.check.hint}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -190,13 +228,16 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
|||||||
{/* L2 children — always visible */}
|
{/* L2 children — always visible */}
|
||||||
{g.children.length > 0 && (
|
{g.children.length > 0 && (
|
||||||
<div className="ml-6 mt-0.5 mb-1 space-y-0.5 border-l-2 border-gray-200 pl-3">
|
<div className="ml-6 mt-0.5 mb-1 space-y-0.5 border-l-2 border-gray-200 pl-3">
|
||||||
{g.children.map((ch) => (
|
{g.children.map((ch) => {
|
||||||
|
const chInfo = ch.severity === 'INFO' && !ch.passed && !ch.skipped
|
||||||
|
return (
|
||||||
<div key={ch.id} className="flex items-start gap-2">
|
<div key={ch.id} className="flex items-start gap-2">
|
||||||
<CheckIcon passed={ch.passed} skipped={ch.skipped} />
|
<CheckIcon passed={ch.passed} skipped={ch.skipped} isInfo={chInfo} />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className={`text-xs ${
|
<div className={`text-xs ${
|
||||||
ch.skipped ? 'text-gray-400 italic'
|
ch.skipped ? 'text-gray-400 italic'
|
||||||
: ch.passed ? 'text-gray-600' : 'text-red-600 font-medium'
|
: ch.passed ? 'text-gray-600'
|
||||||
|
: chInfo ? 'text-gray-400' : 'text-red-600 font-medium'
|
||||||
}`}>
|
}`}>
|
||||||
{ch.label}
|
{ch.label}
|
||||||
{ch.skipped && ' (uebersprungen)'}
|
{ch.skipped && ' (uebersprungen)'}
|
||||||
@@ -207,17 +248,19 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!ch.passed && !ch.skipped && ch.hint && (
|
{!ch.passed && !ch.skipped && ch.hint && (
|
||||||
<div className="text-xs text-red-500/80 mt-0.5">
|
<div className={`text-xs mt-0.5 ${chInfo ? 'text-gray-400' : 'text-red-500/80'}`}>
|
||||||
{ch.hint}
|
{ch.hint}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
{r.word_count > 0 && (
|
{r.word_count > 0 && (
|
||||||
<div className="text-xs text-gray-400 mt-2 pt-2 border-t border-gray-200">
|
<div className="text-xs text-gray-400 mt-2 pt-2 border-t border-gray-200">
|
||||||
{r.word_count} Woerter analysiert
|
{r.word_count} Woerter analysiert
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface SiteResult {
|
||||||
|
url: string
|
||||||
|
domain: string
|
||||||
|
risk_level: string
|
||||||
|
risk_score: number
|
||||||
|
findings_count: number
|
||||||
|
services_count: number
|
||||||
|
has_impressum: boolean
|
||||||
|
has_datenschutz: boolean
|
||||||
|
has_cookie_banner: boolean
|
||||||
|
has_google_fonts: boolean
|
||||||
|
scan_status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const RISK_COLOR: Record<string, string> = {
|
||||||
|
MINIMAL: 'text-green-700 bg-green-50',
|
||||||
|
LOW: 'text-yellow-700 bg-yellow-50',
|
||||||
|
LIMITED: 'text-orange-700 bg-orange-50',
|
||||||
|
HIGH: 'text-red-700 bg-red-50',
|
||||||
|
UNACCEPTABLE: 'text-red-900 bg-red-100',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompareResult({ sites }: { sites: SiteResult[] }) {
|
||||||
|
if (!sites.length) return null
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
{ key: 'has_datenschutz', label: 'Datenschutzerklaerung' },
|
||||||
|
{ key: 'has_impressum', label: 'Impressum' },
|
||||||
|
{ key: 'has_cookie_banner', label: 'Cookie-Banner' },
|
||||||
|
{ key: 'has_google_fonts', label: 'Google Fonts (lokal?)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50">
|
||||||
|
<th className="text-left px-3 py-2 text-xs font-medium text-gray-500 w-44">Pruefung</th>
|
||||||
|
{sites.map((s, i) => (
|
||||||
|
<th key={i} className="text-center px-3 py-2 text-xs font-medium text-gray-700">
|
||||||
|
{s.domain}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-2 text-gray-600">Risiko-Score</td>
|
||||||
|
{sites.map((s, i) => (
|
||||||
|
<td key={i} className="px-3 py-2 text-center">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${RISK_COLOR[s.risk_level] || 'text-gray-600 bg-gray-50'}`}>
|
||||||
|
{s.risk_level || '?'} ({s.risk_score}/100)
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-2 text-gray-600">Findings</td>
|
||||||
|
{sites.map((s, i) => (
|
||||||
|
<td key={i} className={`px-3 py-2 text-center font-medium ${s.findings_count > 0 ? 'text-red-700' : 'text-green-700'}`}>
|
||||||
|
{s.findings_count}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-2 text-gray-600">Dienste erkannt</td>
|
||||||
|
{sites.map((s, i) => (
|
||||||
|
<td key={i} className="px-3 py-2 text-center text-gray-700">{s.services_count}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
{checks.map(check => (
|
||||||
|
<tr key={check.key}>
|
||||||
|
<td className="px-3 py-2 text-gray-600">{check.label}</td>
|
||||||
|
{sites.map((s, i) => {
|
||||||
|
const val = (s as any)[check.key]
|
||||||
|
const isInverted = check.key === 'has_google_fonts'
|
||||||
|
const good = isInverted ? !val : val
|
||||||
|
return (
|
||||||
|
<td key={i} className={`px-3 py-2 text-center font-medium ${good ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{good ? '✓' : '✗'}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from 'react'
|
||||||
|
import { ChecklistView } from './ChecklistView'
|
||||||
|
import { DocumentRow } from './DocumentRow'
|
||||||
|
|
||||||
|
const DOCUMENT_TYPES = [
|
||||||
|
{ id: 'dse', label: 'DSI (Datenschutzinformation)', required: true },
|
||||||
|
{ id: 'impressum', label: 'Impressum', required: true },
|
||||||
|
{ id: 'social_media', label: 'Social Media DSE', required: false },
|
||||||
|
{ id: 'cookie', label: 'Cookie-Richtlinie', required: false },
|
||||||
|
{ id: 'agb', label: 'AGB', required: false },
|
||||||
|
{ id: 'nutzungsbedingungen', label: 'Nutzungsbedingungen', required: false },
|
||||||
|
{ id: 'widerruf', label: 'Widerrufsbelehrung', required: false },
|
||||||
|
{ id: 'dsb', label: 'DSB-Kontakt', required: false },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type DocTypeId = typeof DOCUMENT_TYPES[number]['id']
|
||||||
|
|
||||||
|
interface DocState {
|
||||||
|
url: string
|
||||||
|
text: string
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocsState = Record<DocTypeId, DocState>
|
||||||
|
|
||||||
|
const STORAGE_KEY_STATE = 'compliance-check-state'
|
||||||
|
const STORAGE_KEY_RESULTS = 'compliance-check-results'
|
||||||
|
const STORAGE_KEY_HISTORY = 'compliance-check-history'
|
||||||
|
const STORAGE_KEY_CHECK_ID = 'compliance-check-active-id'
|
||||||
|
|
||||||
|
function emptyDocState(): DocState {
|
||||||
|
return { url: '', text: '', loading: false, error: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function initState(): DocsState {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return Object.fromEntries(DOCUMENT_TYPES.map(d => [d.id, emptyDocState()])) as DocsState
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY_STATE)
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved) as Record<string, { url?: string; text?: string }>
|
||||||
|
return Object.fromEntries(
|
||||||
|
DOCUMENT_TYPES.map(d => [d.id, {
|
||||||
|
url: parsed[d.id]?.url || '',
|
||||||
|
text: parsed[d.id]?.text || '',
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}])
|
||||||
|
) as DocsState
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return Object.fromEntries(DOCUMENT_TYPES.map(d => [d.id, emptyDocState()])) as DocsState
|
||||||
|
}
|
||||||
|
|
||||||
|
function countWords(text: string): number {
|
||||||
|
if (!text.trim()) return 0
|
||||||
|
return text.trim().split(/\s+/).length
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HistoryEntry {
|
||||||
|
date: string
|
||||||
|
docCount: number
|
||||||
|
findings: number
|
||||||
|
resultKey: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComplianceCheckTab() {
|
||||||
|
const [docs, setDocs] = useState<DocsState>(initState)
|
||||||
|
const [useAgent, setUseAgent] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [progress, setProgress] = useState('')
|
||||||
|
const [results, setResults] = useState<any>(() => {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
try { const s = localStorage.getItem(STORAGE_KEY_RESULTS); return s ? JSON.parse(s) : null } catch { return null }
|
||||||
|
})
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [activeCheckId, setActiveCheckId] = useState<string>(() =>
|
||||||
|
typeof window !== 'undefined' ? localStorage.getItem(STORAGE_KEY_CHECK_ID) || '' : ''
|
||||||
|
)
|
||||||
|
const [history, setHistory] = useState<HistoryEntry[]>(() => {
|
||||||
|
if (typeof window === 'undefined') return []
|
||||||
|
try { return JSON.parse(localStorage.getItem(STORAGE_KEY_HISTORY) || '[]') } catch { return [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Persist URLs and texts (not loading/error state)
|
||||||
|
React.useEffect(() => {
|
||||||
|
const toSave: Record<string, { url: string; text: string }> = {}
|
||||||
|
for (const [key, val] of Object.entries(docs)) {
|
||||||
|
toSave[key] = { url: val.url, text: val.text }
|
||||||
|
}
|
||||||
|
try { localStorage.setItem(STORAGE_KEY_STATE, JSON.stringify(toSave)) } catch { /* quota */ }
|
||||||
|
}, [docs])
|
||||||
|
|
||||||
|
// Resume polling if check was in progress when navigating away
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!activeCheckId || results) return
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
setProgress('Pruefung laeuft noch...')
|
||||||
|
const poll = async () => {
|
||||||
|
while (!cancelled) {
|
||||||
|
await new Promise(r => setTimeout(r, 3000))
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/agent/compliance-check?check_id=${activeCheckId}`)
|
||||||
|
if (!res.ok) continue
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.progress) setProgress(data.progress)
|
||||||
|
if (data.status === 'completed' && data.result) {
|
||||||
|
setResults(data.result); setProgress(''); setLoading(false)
|
||||||
|
localStorage.setItem(STORAGE_KEY_RESULTS, JSON.stringify(data.result))
|
||||||
|
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.status === 'failed' || data.status === 'not_found') {
|
||||||
|
if (data.status === 'failed') setError(data.error || 'Pruefung fehlgeschlagen')
|
||||||
|
setProgress(''); setLoading(false)
|
||||||
|
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch { /* retry */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
poll()
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const updateDoc = useCallback((docType: DocTypeId, patch: Partial<DocState>) => {
|
||||||
|
setDocs(prev => ({ ...prev, [docType]: { ...prev[docType], ...patch } }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleFetchText = useCallback(async (docType: DocTypeId) => {
|
||||||
|
const url = docs[docType].url.trim()
|
||||||
|
if (!url) return
|
||||||
|
|
||||||
|
updateDoc(docType, { loading: true, error: null })
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sdk/v1/agent/extract-text', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = res.status === 404
|
||||||
|
? 'Seite nicht erreichbar'
|
||||||
|
: `Fehler beim Laden (${res.status})`
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
updateDoc(docType, { text: data.text || '', loading: false })
|
||||||
|
} catch (e) {
|
||||||
|
updateDoc(docType, {
|
||||||
|
loading: false,
|
||||||
|
error: e instanceof Error ? e.message : 'Text konnte nicht geladen werden',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [docs, updateDoc])
|
||||||
|
|
||||||
|
const handleFileUpload = useCallback(async (docType: DocTypeId, file: File) => {
|
||||||
|
// For now, read as text. PDF/DOCX parsing can be added server-side later.
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
updateDoc(docType, { text: reader.result as string })
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
}, [updateDoc])
|
||||||
|
|
||||||
|
const filledCount = Object.values(docs).filter(d => d.url.trim() || d.text.trim()).length
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (filledCount === 0) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setResults(null)
|
||||||
|
setProgress('Compliance-Check wird gestartet...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = DOCUMENT_TYPES
|
||||||
|
.filter(dt => docs[dt.id].url.trim() || docs[dt.id].text.trim())
|
||||||
|
.map(dt => ({
|
||||||
|
doc_type: dt.id,
|
||||||
|
label: dt.label,
|
||||||
|
url: docs[dt.id].url.trim(),
|
||||||
|
text: docs[dt.id].text.trim() || undefined,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const startRes = await fetch('/api/sdk/v1/agent/compliance-check', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
documents: entries,
|
||||||
|
use_agent: useAgent,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!startRes.ok) throw new Error(`Pruefung konnte nicht gestartet werden: ${startRes.status}`)
|
||||||
|
const { check_id } = await startRes.json()
|
||||||
|
if (!check_id) throw new Error('Keine Check-ID erhalten')
|
||||||
|
setActiveCheckId(check_id)
|
||||||
|
localStorage.setItem(STORAGE_KEY_CHECK_ID, check_id)
|
||||||
|
|
||||||
|
// Poll for results (max 25 min = 500 polls x 3s)
|
||||||
|
let attempts = 0
|
||||||
|
while (attempts < 500) {
|
||||||
|
await new Promise(r => setTimeout(r, 3000))
|
||||||
|
const pollRes = await fetch(`/api/sdk/v1/agent/compliance-check?check_id=${check_id}`)
|
||||||
|
if (!pollRes.ok) { attempts++; continue }
|
||||||
|
const pollData = await pollRes.json()
|
||||||
|
if (pollData.progress) setProgress(pollData.progress)
|
||||||
|
if (pollData.status === 'completed' && pollData.result) {
|
||||||
|
setResults(pollData.result)
|
||||||
|
setProgress('')
|
||||||
|
localStorage.setItem(STORAGE_KEY_RESULTS, JSON.stringify(pollData.result))
|
||||||
|
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||||
|
|
||||||
|
const resultKey = `compliance-check-result-${Date.now()}`
|
||||||
|
try { localStorage.setItem(resultKey, JSON.stringify(pollData.result)) } catch { /* quota */ }
|
||||||
|
const entry: HistoryEntry = {
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
docCount: entries.length,
|
||||||
|
findings: pollData.result.total_findings || 0,
|
||||||
|
resultKey,
|
||||||
|
}
|
||||||
|
const updated = [entry, ...history].slice(0, 30)
|
||||||
|
setHistory(updated)
|
||||||
|
localStorage.setItem(STORAGE_KEY_HISTORY, JSON.stringify(updated))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (pollData.status === 'failed') {
|
||||||
|
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||||
|
throw new Error(pollData.error || 'Pruefung fehlgeschlagen')
|
||||||
|
}
|
||||||
|
attempts++
|
||||||
|
}
|
||||||
|
if (attempts >= 500) {
|
||||||
|
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
|
||||||
|
throw new Error('Zeitlimit ueberschritten (15 Min)')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||||
|
setProgress('')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFromHistory = (entry: HistoryEntry) => {
|
||||||
|
if (entry.resultKey) {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(entry.resultKey)
|
||||||
|
if (saved) { setResults(JSON.parse(saved)); return }
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const last = localStorage.getItem(STORAGE_KEY_RESULTS)
|
||||||
|
if (last) setResults(JSON.parse(last))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Info box */}
|
||||||
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-purple-900">Compliance-Check (Alle Dokumente)</h3>
|
||||||
|
<p className="text-xs text-purple-700 mt-1">
|
||||||
|
Geben Sie die URLs Ihrer Rechtstexte ein oder laden Sie die Dokumente hoch.
|
||||||
|
Das System prueft alle Pflichtangaben nach DSGVO, TDDDG, TMG und UWG.
|
||||||
|
Pflichtdokumente sind mit * markiert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Document rows */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{DOCUMENT_TYPES.map(dt => (
|
||||||
|
<DocumentRow
|
||||||
|
key={dt.id}
|
||||||
|
label={dt.label}
|
||||||
|
docType={dt.id}
|
||||||
|
required={dt.required}
|
||||||
|
url={docs[dt.id].url}
|
||||||
|
text={docs[dt.id].text}
|
||||||
|
loading={docs[dt.id].loading}
|
||||||
|
error={docs[dt.id].error}
|
||||||
|
wordCount={countWords(docs[dt.id].text)}
|
||||||
|
onUrlChange={url => updateDoc(dt.id, { url })}
|
||||||
|
onFetchText={() => handleFetchText(dt.id)}
|
||||||
|
onTextChange={text => updateDoc(dt.id, { text })}
|
||||||
|
onFileUpload={file => handleFileUpload(dt.id, file)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent toggle + submit */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUseAgent(!useAgent)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
|
||||||
|
useAgent
|
||||||
|
? 'bg-emerald-100 border-emerald-300 text-emerald-800'
|
||||||
|
: 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`w-2 h-2 rounded-full ${useAgent ? 'bg-emerald-500' : 'bg-gray-300'}`} />
|
||||||
|
{useAgent ? 'KI-Agent aktiv (alle MCs)' : 'KI-Agent aus'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{filledCount} von {DOCUMENT_TYPES.length} Dokumenten ausgefuellt
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit button */}
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading || filledCount === 0}
|
||||||
|
className="w-full px-4 py-3 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50 transition-colors text-sm flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
Pruefe...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
`Compliance-Check starten (${filledCount} Dokument${filledCount !== 1 ? 'e' : ''})`
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
{progress && (
|
||||||
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm text-purple-700 flex items-center gap-3">
|
||||||
|
<svg className="animate-spin w-4 h-4 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
{progress}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{results && results.results && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||||
|
{/* Business Profile */}
|
||||||
|
{results.business_profile && (
|
||||||
|
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-xs">
|
||||||
|
<div className="font-semibold text-blue-900 mb-1">Erkanntes Geschaeftsmodell</div>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-blue-700">
|
||||||
|
<span>Typ: <strong>{results.business_profile.business_type?.toUpperCase()}</strong></span>
|
||||||
|
<span>Branche: {results.business_profile.industry}</span>
|
||||||
|
{results.business_profile.has_online_shop && <span className="text-amber-700">Online-Shop</span>}
|
||||||
|
{results.business_profile.is_regulated_profession && <span className="text-amber-700">Regulierter Beruf ({results.business_profile.regulated_profession_type})</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Extracted Profile — pre-fill suggestion */}
|
||||||
|
{results.extracted_profile?.company_profile && Object.keys(results.extracted_profile.company_profile).length > 0 && (
|
||||||
|
<div className="mb-4 p-3 bg-emerald-50 border border-emerald-200 rounded-lg text-xs">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="font-semibold text-emerald-900">Aus Dokumenten extrahiert</span>
|
||||||
|
<button className="text-emerald-700 hover:text-emerald-900 text-xs font-medium underline"
|
||||||
|
onClick={() => { /* TODO: navigate to company profile with pre-fill */ }}>
|
||||||
|
In Company Profile uebernehmen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-emerald-700">
|
||||||
|
{results.extracted_profile.company_profile.companyName && (
|
||||||
|
<span>Firma: <strong>{results.extracted_profile.company_profile.companyName}</strong></span>
|
||||||
|
)}
|
||||||
|
{results.extracted_profile.company_profile.legalForm && (
|
||||||
|
<span>Rechtsform: {results.extracted_profile.company_profile.legalForm.toUpperCase()}</span>
|
||||||
|
)}
|
||||||
|
{results.extracted_profile.company_profile.headquartersCity && (
|
||||||
|
<span>Sitz: {results.extracted_profile.company_profile.headquartersZip} {results.extracted_profile.company_profile.headquartersCity}</span>
|
||||||
|
)}
|
||||||
|
{results.extracted_profile.company_profile.dpoEmail && (
|
||||||
|
<span>DSB: {results.extracted_profile.company_profile.dpoEmail}</span>
|
||||||
|
)}
|
||||||
|
{results.extracted_profile.company_profile.ustIdNr && (
|
||||||
|
<span>USt-IdNr: {results.extracted_profile.company_profile.ustIdNr}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{results.extracted_profile.compliance_scope_hints?.length > 0 && (
|
||||||
|
<div className="mt-2 pt-2 border-t border-emerald-200 text-emerald-600">
|
||||||
|
<span className="font-medium">Scope-Hinweise: </span>
|
||||||
|
{results.extracted_profile.compliance_scope_hints.map((h: any, i: number) => (
|
||||||
|
<span key={i} className="inline-block bg-emerald-100 rounded px-1.5 py-0.5 mr-1 mb-1">
|
||||||
|
{h.source}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Banner Check Result */}
|
||||||
|
{results.banner_result && (
|
||||||
|
<div className={`mb-4 p-3 rounded-lg border text-xs ${
|
||||||
|
results.banner_result.violations > 0
|
||||||
|
? 'bg-amber-50 border-amber-200'
|
||||||
|
: results.banner_result.detected
|
||||||
|
? 'bg-green-50 border-green-200'
|
||||||
|
: 'bg-gray-50 border-gray-200'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${
|
||||||
|
results.banner_result.violations > 0 ? 'bg-amber-500'
|
||||||
|
: results.banner_result.detected ? 'bg-green-500' : 'bg-gray-400'
|
||||||
|
}`} />
|
||||||
|
<span className="font-semibold text-gray-900">
|
||||||
|
Cookie-Banner-Check (automatisch)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-gray-600 ml-4">
|
||||||
|
{results.banner_result.detected ? (
|
||||||
|
<>
|
||||||
|
Banner erkannt{results.banner_result.provider ? ` (${results.banner_result.provider})` : ''}.
|
||||||
|
{results.banner_result.violations > 0
|
||||||
|
? ` ${results.banner_result.violations} Auffaelligkeit${results.banner_result.violations !== 1 ? 'en' : ''} gefunden.`
|
||||||
|
: ' Keine Auffaelligkeiten.'}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Kein Cookie-Banner erkannt oder Banner-Check nicht moeglich.'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ChecklistView results={results.results} />
|
||||||
|
|
||||||
|
{/* Email status */}
|
||||||
|
{results.email_status && (
|
||||||
|
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${results.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
||||||
|
E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* History */}
|
||||||
|
{history.length > 0 && (
|
||||||
|
<div className="border border-gray-200 rounded-xl p-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">Letzte Compliance-Checks</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{history.map((h, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => loadFromHistory(h)}
|
||||||
|
className="w-full flex items-center justify-between text-sm py-2 px-2 rounded-lg border border-gray-50 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left"
|
||||||
|
>
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{new Date(h.date).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-gray-500">{h.docCount} Dok.</span>
|
||||||
|
<span className={`text-xs font-medium ${h.findings > 0 ? 'text-amber-600' : 'text-green-600'}`}>
|
||||||
|
{h.findings} Findings
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -83,6 +83,23 @@ Typische Praxis-Bussgelder in Deutschland: 5.000-50.000 EUR fuer KMU, 100.000-1
|
|||||||
|
|
||||||
**IACE Normen-Bibliothek:** Die aktuelle Liste unter /sdk/iace/library enthaelt 751 harmonisierte Normen (1 A-Norm, 19 B1, 126 B2, 605 C-Normen). Diese muessen regelmaessig gegen das EU-Amtsblatt abgeglichen werden, da einige Normen zurueckgezogen oder ersetzt wurden.`,
|
**IACE Normen-Bibliothek:** Die aktuelle Liste unter /sdk/iace/library enthaelt 751 harmonisierte Normen (1 A-Norm, 19 B1, 126 B2, 605 C-Normen). Diese muessen regelmaessig gegen das EU-Amtsblatt abgeglichen werden, da einige Normen zurueckgezogen oder ersetzt wurden.`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
q: "Warum muss ich harmonisierte Normen kaufen obwohl sie EU-Recht sind?",
|
||||||
|
a: `Harmonisierte Normen werden von privaten Organisationen (CEN/CENELEC) erstellt und ueber nationale Normungsinstitute wie DIN/Beuth (Deutschland), ASI (Oesterreich) oder SNV (Schweiz) verkauft — typisch 50-300 EUR pro Norm.
|
||||||
|
|
||||||
|
**Das Problem:** Die EU-Kommission beauftragt die Normung, Industrieexperten schreiben die Normen ehrenamtlich in Technischen Komitees, aber ein privater Verlag verkauft das Ergebnis. Unternehmen muessen Normen kaufen die ihre eigenen Mitarbeiter geschrieben haben.
|
||||||
|
|
||||||
|
**EuGH-Urteil C-588/21 P (5. Maerz 2024):**
|
||||||
|
Der Europaeische Gerichtshof hat entschieden, dass harmonisierte Normen **Teil des EU-Rechts** sind, weil sie eine Konformitaetsvermutung erzeugen. Das Rechtsstaatsprinzip verlangt, dass Buerger die Regeln kennen koennen die fuer sie gelten. Daher muessen harmonisierte Normen grundsaetzlich **frei zugaenglich** sein.
|
||||||
|
|
||||||
|
**Aktueller Stand (2026):** Das Urteil ist noch nicht vollstaendig umgesetzt. CEN/CENELEC und die nationalen Normungsinstitute wehren sich, weil ihr Geschaeftsmodell auf dem Verkauf basiert. Die EU-Kommission arbeitet an einer Loesung.
|
||||||
|
|
||||||
|
**Was das fuer Unternehmen bedeutet:**
|
||||||
|
- Aktuell muessen Normen weiterhin gekauft werden
|
||||||
|
- Normnummern und Titel sind frei nutzbar (bibliographische Daten)
|
||||||
|
- BSI-Grundschutz und NIST-Standards sind kostenlose Alternativen die inhaltlich aehnliche Anforderungen abdecken
|
||||||
|
- Die IACE-Bibliothek in BreakPilot listet alle harmonisierten Normen mit Status (aktiv/zurueckgezogen) ohne kostenpflichtigen Normtext`,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function ComplianceFAQ() {
|
export function ComplianceFAQ() {
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface Violation {
|
||||||
|
service: string
|
||||||
|
severity: string
|
||||||
|
text: string
|
||||||
|
legal_ref: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PhaseData {
|
||||||
|
scripts: string[]
|
||||||
|
cookies: string[]
|
||||||
|
tracking_services?: string[]
|
||||||
|
new_tracking?: string[]
|
||||||
|
violations?: Violation[]
|
||||||
|
undocumented?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConsentData {
|
||||||
|
banner_detected: boolean
|
||||||
|
banner_provider: string
|
||||||
|
phases: {
|
||||||
|
before_consent: PhaseData
|
||||||
|
after_reject: PhaseData
|
||||||
|
after_accept: PhaseData
|
||||||
|
}
|
||||||
|
summary: {
|
||||||
|
critical: number
|
||||||
|
high: number
|
||||||
|
undocumented: number
|
||||||
|
total_violations: number
|
||||||
|
category_violations?: number
|
||||||
|
categories_tested?: number
|
||||||
|
}
|
||||||
|
banner_checks?: {
|
||||||
|
has_impressum_link: boolean
|
||||||
|
has_dse_link: boolean
|
||||||
|
violations: { service: string; severity: string; text: string; legal_ref: string }[]
|
||||||
|
}
|
||||||
|
category_tests?: {
|
||||||
|
category: string
|
||||||
|
category_label: string
|
||||||
|
tracking_services: string[]
|
||||||
|
violations: { service: string; severity: string; text: string }[]
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEV = {
|
||||||
|
CRITICAL: { bg: 'bg-red-100 border-red-300', text: 'text-red-800', badge: 'bg-red-600' },
|
||||||
|
HIGH: { bg: 'bg-orange-100 border-orange-300', text: 'text-orange-800', badge: 'bg-orange-500' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function PhaseCard({ title, icon, data, type }: {
|
||||||
|
title: string; icon: string; data: PhaseData; type: 'before' | 'reject' | 'accept'
|
||||||
|
}) {
|
||||||
|
const violations = data.violations || []
|
||||||
|
const tracking = data.tracking_services || data.new_tracking || []
|
||||||
|
const undocumented = data.undocumented || []
|
||||||
|
const hasProblem = violations.length > 0 || undocumented.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`border rounded-lg p-4 ${hasProblem ? 'border-red-200 bg-red-50' : 'border-green-200 bg-green-50'}`}>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
||||||
|
<span>{icon}</span> {title}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Violations */}
|
||||||
|
{violations.map((v, i) => (
|
||||||
|
<div key={i} className={`mb-2 p-2 rounded border ${SEV[v.severity as keyof typeof SEV]?.bg || SEV.HIGH.bg}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded text-white ${SEV[v.severity as keyof typeof SEV]?.badge || SEV.HIGH.badge}`}>
|
||||||
|
{v.severity}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs font-medium ${SEV[v.severity as keyof typeof SEV]?.text || SEV.HIGH.text}`}>
|
||||||
|
{v.service}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-700 mt-1">{v.text}</p>
|
||||||
|
<p className="text-[10px] text-gray-500 mt-0.5">{v.legal_ref}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Undocumented (Phase C only) */}
|
||||||
|
{undocumented.map((s, i) => (
|
||||||
|
<div key={i} className="mb-2 p-2 rounded border border-yellow-300 bg-yellow-50">
|
||||||
|
<span className="text-xs text-yellow-800">✗ {s} — nicht in Cookie-Policy dokumentiert</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Tracking services (no violations) */}
|
||||||
|
{violations.length === 0 && undocumented.length === 0 && tracking.length > 0 && (
|
||||||
|
<div className="text-xs text-green-700">
|
||||||
|
{tracking.map((t, i) => <div key={i}>✓ {t} — {type === 'accept' ? 'mit Consent OK' : 'erkannt'}</div>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{violations.length === 0 && undocumented.length === 0 && tracking.length === 0 && (
|
||||||
|
<p className="text-xs text-green-700">✓ Keine Tracking-Dienste erkannt</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cookie/Script count */}
|
||||||
|
<div className="flex gap-3 mt-2 text-[10px] text-gray-400">
|
||||||
|
<span>{data.scripts?.length || 0} Scripts</span>
|
||||||
|
<span>{data.cookies?.length || 0} Cookies</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConsentTestResult({ data }: { data: ConsentData }) {
|
||||||
|
const s = data.summary
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`w-3 h-3 rounded-full ${data.banner_detected ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||||
|
<span className="text-sm font-medium text-gray-900">
|
||||||
|
Cookie-Banner: {data.banner_detected ? data.banner_provider : 'Nicht erkannt'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{s.critical > 0 && (
|
||||||
|
<span className="text-xs px-2 py-1 rounded bg-red-600 text-white font-medium">
|
||||||
|
{s.critical} Kritisch
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.high > 0 && (
|
||||||
|
<span className="text-xs px-2 py-1 rounded bg-orange-500 text-white font-medium">
|
||||||
|
{s.high} Hoch
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.total_violations === 0 && (
|
||||||
|
<span className="text-xs px-2 py-1 rounded bg-green-500 text-white font-medium">
|
||||||
|
Keine Verstoesse
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Three Phases */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<PhaseCard
|
||||||
|
title="Phase A: Vor Einwilligung"
|
||||||
|
icon="🔍"
|
||||||
|
data={data.phases.before_consent}
|
||||||
|
type="before"
|
||||||
|
/>
|
||||||
|
{data.banner_detected && (
|
||||||
|
<>
|
||||||
|
<PhaseCard
|
||||||
|
title="Phase B: Nach Ablehnung"
|
||||||
|
icon="🚫"
|
||||||
|
data={data.phases.after_reject}
|
||||||
|
type="reject"
|
||||||
|
/>
|
||||||
|
<PhaseCard
|
||||||
|
title="Phase C: Nach Zustimmung"
|
||||||
|
icon="✅"
|
||||||
|
data={data.phases.after_accept}
|
||||||
|
type="accept"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Banner Text Checks */}
|
||||||
|
{data.banner_checks && (data.banner_checks.violations?.length > 0 || data.banner_checks.has_impressum_link !== undefined) && (
|
||||||
|
<div className="border rounded-lg p-4 border-gray-200 bg-gray-50">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||||
|
<span>📝</span> Banner-Text Pruefung
|
||||||
|
</h4>
|
||||||
|
<div className="flex gap-3 mb-3 text-xs">
|
||||||
|
<span className={data.banner_checks.has_impressum_link ? 'text-green-600' : 'text-red-600'}>
|
||||||
|
{data.banner_checks.has_impressum_link ? '✓' : '✗'} Impressum-Link
|
||||||
|
</span>
|
||||||
|
<span className={data.banner_checks.has_dse_link ? 'text-green-600' : 'text-red-600'}>
|
||||||
|
{data.banner_checks.has_dse_link ? '✓' : '✗'} DSE-Link
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{data.banner_checks.violations?.map((v: any, i: number) => {
|
||||||
|
const isHigh = v.severity === 'HIGH' || v.severity === 'CRITICAL'
|
||||||
|
return (
|
||||||
|
<div key={i} className={`mb-2 p-2 rounded border ${isHigh ? 'border-red-300 bg-red-50' : 'border-yellow-300 bg-yellow-50'}`}>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded text-white ${isHigh ? 'bg-red-600' : 'bg-yellow-600'}`}>
|
||||||
|
{v.severity}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-800">{v.text}</p>
|
||||||
|
<p className="text-[10px] text-gray-500 mt-0.5">{v.legal_ref}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{(!data.banner_checks.violations || data.banner_checks.violations.length === 0) && (
|
||||||
|
<p className="text-xs text-green-700">✓ Keine Banner-Text-Verstoesse erkannt</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Category Tests (Phase D-F) */}
|
||||||
|
{data.category_tests && data.category_tests.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 mt-2">Kategorie-Tests ({data.category_tests.length})</h4>
|
||||||
|
{data.category_tests.map((ct, i) => {
|
||||||
|
const hasViolations = ct.violations.length > 0
|
||||||
|
return (
|
||||||
|
<div key={i} className={`border rounded-lg p-4 ${hasViolations ? 'border-red-200 bg-red-50' : 'border-green-200 bg-green-50'}`}>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 mb-2 flex items-center gap-2">
|
||||||
|
<span>🔀</span> Nur "{ct.category_label}"
|
||||||
|
</h4>
|
||||||
|
{ct.violations.length > 0 ? (
|
||||||
|
ct.violations.map((v, vi) => (
|
||||||
|
<div key={vi} className="mb-2 p-2 rounded border border-red-300 bg-red-100">
|
||||||
|
<span className="text-xs font-bold text-red-800 px-1.5 py-0.5 rounded bg-red-200">FALSCH</span>
|
||||||
|
<span className="text-xs text-red-700 ml-2">{v.text}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-green-700">
|
||||||
|
{ct.tracking_services.length > 0 ? (
|
||||||
|
ct.tracking_services.map((s, si) => <div key={si}>✓ {s} — korrekte Kategorie</div>)
|
||||||
|
) : (
|
||||||
|
<div>✓ Keine Tracking-Dienste geladen — korrekt</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No banner warning */}
|
||||||
|
{!data.banner_detected && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-xs text-red-700">
|
||||||
|
<strong>Kein Cookie-Banner erkannt.</strong> Alle erkannten Tracking-Dienste laden ohne
|
||||||
|
Einwilligung — dies ist ein Verstoss gegen §25 TDDDG.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ export function DocCheckTab() {
|
|||||||
try { const s = localStorage.getItem('doc-check-entries'); return s ? JSON.parse(s) : [newEntry()] } catch { return [newEntry()] }
|
try { const s = localStorage.getItem('doc-check-entries'); return s ? JSON.parse(s) : [newEntry()] } catch { return [newEntry()] }
|
||||||
})
|
})
|
||||||
const [checkCookieBanner, setCheckCookieBanner] = useState(false)
|
const [checkCookieBanner, setCheckCookieBanner] = useState(false)
|
||||||
|
const [useAgent, setUseAgent] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [progress, setProgress] = useState('')
|
const [progress, setProgress] = useState('')
|
||||||
const [results, setResults] = useState<any>(() => {
|
const [results, setResults] = useState<any>(() => {
|
||||||
@@ -38,7 +39,7 @@ export function DocCheckTab() {
|
|||||||
try { const s = localStorage.getItem('doc-check-results'); return s ? JSON.parse(s) : null } catch { return null }
|
try { const s = localStorage.getItem('doc-check-results'); return s ? JSON.parse(s) : null } catch { return null }
|
||||||
})
|
})
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [history, setHistory] = useState<{ date: string; urls: number; findings: number }[]>(() => {
|
const [history, setHistory] = useState<{ date: string; urls: number; findings: number; resultKey: string }[]>(() => {
|
||||||
if (typeof window === 'undefined') return []
|
if (typeof window === 'undefined') return []
|
||||||
try { return JSON.parse(localStorage.getItem('doc-check-history') || '[]') } catch { return [] }
|
try { return JSON.parse(localStorage.getItem('doc-check-history') || '[]') } catch { return [] }
|
||||||
})
|
})
|
||||||
@@ -92,6 +93,7 @@ export function DocCheckTab() {
|
|||||||
url: e.url.trim(),
|
url: e.url.trim(),
|
||||||
})),
|
})),
|
||||||
check_cookie_banner: checkCookieBanner,
|
check_cookie_banner: checkCookieBanner,
|
||||||
|
use_agent: useAgent,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!startRes.ok) throw new Error(`Pruefung konnte nicht gestartet werden: ${startRes.status}`)
|
if (!startRes.ok) throw new Error(`Pruefung konnte nicht gestartet werden: ${startRes.status}`)
|
||||||
@@ -110,7 +112,9 @@ export function DocCheckTab() {
|
|||||||
setResults(pollData.result)
|
setResults(pollData.result)
|
||||||
setProgress('')
|
setProgress('')
|
||||||
localStorage.setItem('doc-check-results', JSON.stringify(pollData.result))
|
localStorage.setItem('doc-check-results', JSON.stringify(pollData.result))
|
||||||
const entry = { date: new Date().toISOString(), urls: validEntries.length, findings: pollData.result.total_findings || 0 }
|
const resultKey = `doc-check-result-${Date.now()}`
|
||||||
|
try { localStorage.setItem(resultKey, JSON.stringify(pollData.result)) } catch { /* quota */ }
|
||||||
|
const entry = { date: new Date().toISOString(), urls: validEntries.length, findings: pollData.result.total_findings || 0, resultKey }
|
||||||
const updated = [entry, ...history].slice(0, 30)
|
const updated = [entry, ...history].slice(0, 30)
|
||||||
setHistory(updated)
|
setHistory(updated)
|
||||||
localStorage.setItem('doc-check-history', JSON.stringify(updated))
|
localStorage.setItem('doc-check-history', JSON.stringify(updated))
|
||||||
@@ -190,6 +194,19 @@ export function DocCheckTab() {
|
|||||||
/>
|
/>
|
||||||
Cookie-Banner pruefen
|
Cookie-Banner pruefen
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUseAgent(!useAgent)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
|
||||||
|
useAgent
|
||||||
|
? 'bg-emerald-100 border-emerald-300 text-emerald-800'
|
||||||
|
: 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`w-2 h-2 rounded-full ${useAgent ? 'bg-emerald-500' : 'bg-gray-300'}`} />
|
||||||
|
{useAgent ? 'KI-Agent aktiv (1.874 MCs)' : 'KI-Agent aus'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submit */}
|
{/* Submit */}
|
||||||
@@ -270,7 +287,20 @@ export function DocCheckTab() {
|
|||||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Letzte Pruefungen</h4>
|
<h4 className="text-sm font-medium text-gray-700 mb-2">Letzte Pruefungen</h4>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{history.map((h, i) => (
|
{history.map((h, i) => (
|
||||||
<div key={i} className="flex items-center justify-between text-sm py-1.5 border-b border-gray-50 last:border-0">
|
<button key={i} onClick={() => {
|
||||||
|
if (h.resultKey) {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(h.resultKey)
|
||||||
|
if (saved) { setResults(JSON.parse(saved)); return }
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
// Fallback: load last result
|
||||||
|
try {
|
||||||
|
const last = localStorage.getItem('doc-check-results')
|
||||||
|
if (last) setResults(JSON.parse(last))
|
||||||
|
} catch {}
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center justify-between text-sm py-2 px-2 rounded-lg border border-gray-50 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left">
|
||||||
<span className="text-gray-600">
|
<span className="text-gray-600">
|
||||||
{new Date(h.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
{new Date(h.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||||
</span>
|
</span>
|
||||||
@@ -280,7 +310,7 @@ export function DocCheckTab() {
|
|||||||
{h.findings} Findings
|
{h.findings} Findings
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react'
|
||||||
|
|
||||||
|
interface DocumentRowProps {
|
||||||
|
label: string
|
||||||
|
docType: string
|
||||||
|
required?: boolean
|
||||||
|
url: string
|
||||||
|
text: string
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
wordCount: number
|
||||||
|
onUrlChange: (url: string) => void
|
||||||
|
onFetchText: () => void
|
||||||
|
onTextChange: (text: string) => void
|
||||||
|
onFileUpload: (file: File) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentRow({
|
||||||
|
label,
|
||||||
|
docType,
|
||||||
|
required,
|
||||||
|
url,
|
||||||
|
text,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
wordCount,
|
||||||
|
onUrlChange,
|
||||||
|
onFetchText,
|
||||||
|
onTextChange,
|
||||||
|
onFileUpload,
|
||||||
|
}: DocumentRowProps) {
|
||||||
|
const [showText, setShowText] = useState(false)
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const textVisible = showText || text.length > 0
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
// Read text-based files directly
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
const content = reader.result as string
|
||||||
|
onTextChange(content)
|
||||||
|
}
|
||||||
|
reader.onerror = () => {
|
||||||
|
// Let parent handle via onFileUpload for binary formats
|
||||||
|
onFileUpload(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.name.endsWith('.txt') || file.type === 'text/plain') {
|
||||||
|
reader.readAsText(file)
|
||||||
|
} else {
|
||||||
|
// PDF, DOCX — pass to parent for server-side parsing
|
||||||
|
onFileUpload(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset input so the same file can be re-selected
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-gray-200 rounded-lg p-3 space-y-2">
|
||||||
|
{/* Header row: label + inputs */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-52 shrink-0">
|
||||||
|
<span className="text-sm font-medium text-gray-700">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-500 ml-0.5">*</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={e => onUrlChange(e.target.value)}
|
||||||
|
placeholder="https://example.com/datenschutz"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Fetch text button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onFetchText}
|
||||||
|
disabled={loading || !url.trim()}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<svg className="animate-spin w-4 h-4 text-purple-500" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
'Text laden'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* File upload button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
|
title="PDF, DOCX oder TXT hochladen"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" 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-8l-4-4m0 0L8 8m4-4v12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.docx,.doc,.txt"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Toggle text area */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowText(!showText)}
|
||||||
|
className={`px-3 py-2 border rounded-lg text-sm transition-colors ${
|
||||||
|
textVisible
|
||||||
|
? 'border-purple-300 bg-purple-50 text-purple-700'
|
||||||
|
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
title={textVisible ? 'Text ausblenden' : 'Text anzeigen'}
|
||||||
|
>
|
||||||
|
<svg className={`w-4 h-4 transition-transform ${textVisible ? '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>
|
||||||
|
|
||||||
|
{/* Word count badge */}
|
||||||
|
{wordCount > 0 && (
|
||||||
|
<span className="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700 font-medium shrink-0">
|
||||||
|
{wordCount.toLocaleString('de-DE')} W.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="text-xs text-red-600 px-1">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collapsible textarea */}
|
||||||
|
{textVisible && (
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={e => onTextChange(e.target.value)}
|
||||||
|
placeholder="Dokumenttext hier einfuegen oder per URL / Upload laden..."
|
||||||
|
rows={6}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono resize-y focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { ChecklistView } from './ChecklistView'
|
||||||
|
|
||||||
|
interface CheckItem {
|
||||||
|
id: string; label: string; passed: boolean; severity: string
|
||||||
|
matched_text: string; level?: number; parent?: string | null
|
||||||
|
skipped?: boolean; hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImpressumCheckTab() {
|
||||||
|
const [url, setUrl] = useState(() =>
|
||||||
|
typeof window !== 'undefined' ? localStorage.getItem('impressum-check-url') || '' : ''
|
||||||
|
)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [progress, setProgress] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [results, setResults] = useState<any>(() => {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
try { const s = localStorage.getItem('impressum-check-results'); return s ? JSON.parse(s) : null } catch { return null }
|
||||||
|
})
|
||||||
|
const [history, setHistory] = useState<{ url: string; date: string; findings: number; pct: number; resultKey: string }[]>(() => {
|
||||||
|
if (typeof window === 'undefined') return []
|
||||||
|
try { return JSON.parse(localStorage.getItem('impressum-check-history') || '[]') } catch { return [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const [useAgent, setUseAgent] = useState(false)
|
||||||
|
|
||||||
|
React.useEffect(() => { localStorage.setItem('impressum-check-url', url) }, [url])
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!url.trim()) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setResults(null)
|
||||||
|
setProgress('Impressum wird geprueft...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startRes = await fetch('/api/sdk/v1/agent/doc-check', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
entries: [{ doc_type: 'impressum', label: 'Impressum', url: url.trim() }],
|
||||||
|
recipient: 'dsb@breakpilot.local',
|
||||||
|
use_agent: useAgent,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!startRes.ok) throw new Error(`Fehler: ${startRes.status}`)
|
||||||
|
const { check_id } = await startRes.json()
|
||||||
|
if (!check_id) throw new Error('Keine Check-ID erhalten')
|
||||||
|
|
||||||
|
let attempts = 0
|
||||||
|
while (attempts < 120) {
|
||||||
|
await new Promise(r => setTimeout(r, 3000))
|
||||||
|
const pollRes = await fetch(`/api/sdk/v1/agent/doc-check?check_id=${check_id}`)
|
||||||
|
if (!pollRes.ok) { attempts++; continue }
|
||||||
|
const pollData = await pollRes.json()
|
||||||
|
if (pollData.progress) setProgress(pollData.progress)
|
||||||
|
if (pollData.status === 'completed' && pollData.result) {
|
||||||
|
setResults(pollData.result)
|
||||||
|
setProgress('')
|
||||||
|
localStorage.setItem('impressum-check-results', JSON.stringify(pollData.result))
|
||||||
|
const resultKey = `impressum-result-${Date.now()}`
|
||||||
|
try { localStorage.setItem(resultKey, JSON.stringify(pollData.result)) } catch {}
|
||||||
|
const total = pollData.result.total_findings || 0
|
||||||
|
const pct = pollData.result.results?.[0]?.completeness_pct || 0
|
||||||
|
const entry = { url: url.trim(), date: new Date().toISOString(), findings: total, pct, resultKey }
|
||||||
|
const updated = [entry, ...history].slice(0, 30)
|
||||||
|
setHistory(updated)
|
||||||
|
localStorage.setItem('impressum-check-history', JSON.stringify(updated))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (pollData.status === 'failed') throw new Error(pollData.error || 'Pruefung fehlgeschlagen')
|
||||||
|
attempts++
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||||
|
setProgress('')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-amber-900">Impressum-Check (§5 TMG / §18 MStV)</h3>
|
||||||
|
<p className="text-xs text-amber-700 mt-1">
|
||||||
|
Prueft 16 Pflichtangaben: Anbietername, Anschrift, Kontaktdaten, Handelsregister,
|
||||||
|
USt-IdNr., Vertretungsberechtigte, V.i.S.d.P., Streitbeilegung.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button type="button" onClick={() => setUseAgent(!useAgent)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
|
||||||
|
useAgent ? 'bg-emerald-100 border-emerald-300 text-emerald-800' : 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-gray-100'
|
||||||
|
}`}>
|
||||||
|
<span className={`w-2 h-2 rounded-full ${useAgent ? 'bg-emerald-500' : 'bg-gray-300'}`} />
|
||||||
|
{useAgent ? 'KI-Agent aktiv (75 MCs)' : 'KI-Agent aus'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex gap-3">
|
||||||
|
<input type="url" value={url} onChange={e => setUrl(e.target.value)}
|
||||||
|
placeholder="https://www.example.com/impressum"
|
||||||
|
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm"
|
||||||
|
disabled={loading} required />
|
||||||
|
<button type="submit" disabled={loading || !url.trim()}
|
||||||
|
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center gap-2 text-sm font-medium whitespace-nowrap">
|
||||||
|
{loading ? (
|
||||||
|
<><svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>Pruefe...</>
|
||||||
|
) : 'Impressum pruefen'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{progress && (
|
||||||
|
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 text-sm text-purple-700 flex items-center gap-3">
|
||||||
|
<svg className="animate-spin w-5 h-5 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
{progress}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{error}</div>}
|
||||||
|
|
||||||
|
{results?.results && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||||
|
<ChecklistView results={results.results} />
|
||||||
|
{results.email_status && (
|
||||||
|
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${results.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
||||||
|
E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{history.length > 0 && (
|
||||||
|
<div className="border border-gray-200 rounded-xl p-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2">Letzte Impressum-Checks</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{history.map((h, i) => (
|
||||||
|
<button key={i} onClick={() => {
|
||||||
|
setUrl(h.url)
|
||||||
|
if (h.resultKey) {
|
||||||
|
try { const s = localStorage.getItem(h.resultKey); if (s) { setResults(JSON.parse(s)); return } } catch {}
|
||||||
|
}
|
||||||
|
try { const l = localStorage.getItem('impressum-check-results'); if (l) setResults(JSON.parse(l)) } catch {}
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center justify-between p-2.5 rounded-lg border border-gray-100 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium text-gray-900 truncate">{h.url}</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{new Date(h.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 shrink-0 ml-3">
|
||||||
|
<span className={`text-xs font-medium ${h.findings > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||||
|
{h.findings} Findings
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs font-medium ${h.pct === 100 ? 'text-green-700' : h.pct >= 50 ? 'text-yellow-700' : 'text-red-700'}`}>
|
||||||
|
{h.pct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
import { TextReference } from './TextReference'
|
||||||
|
|
||||||
interface ServiceInfo {
|
interface ServiceInfo {
|
||||||
name: string
|
name: string
|
||||||
@@ -14,22 +15,27 @@ interface ServiceInfo {
|
|||||||
status: string
|
status: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TextRef {
|
||||||
|
found: boolean
|
||||||
|
source_url: string
|
||||||
|
document_type: string
|
||||||
|
section_heading: string
|
||||||
|
section_number: string
|
||||||
|
parent_section: string
|
||||||
|
paragraph_index: number
|
||||||
|
original_text: string
|
||||||
|
issue: string
|
||||||
|
correction_type: string
|
||||||
|
correction_text: string
|
||||||
|
insert_after: string
|
||||||
|
}
|
||||||
|
|
||||||
interface ScanFinding {
|
interface ScanFinding {
|
||||||
code: string
|
code: string
|
||||||
severity: string
|
severity: string
|
||||||
text: string
|
text: string
|
||||||
correction: string
|
correction: string
|
||||||
doc_title: string
|
text_reference: TextRef | null
|
||||||
}
|
|
||||||
|
|
||||||
interface DiscoveredDocument {
|
|
||||||
title: string
|
|
||||||
url: string
|
|
||||||
doc_type: string
|
|
||||||
language: string
|
|
||||||
word_count: number
|
|
||||||
completeness_pct: number
|
|
||||||
findings_count: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScanData {
|
interface ScanData {
|
||||||
@@ -249,7 +255,12 @@ export function ScanResult({ data }: { data: ScanData }) {
|
|||||||
</span>
|
</span>
|
||||||
<p className="text-sm text-gray-800 flex-1">{f.text}</p>
|
<p className="text-sm text-gray-800 flex-1">{f.text}</p>
|
||||||
</div>
|
</div>
|
||||||
{f.correction && (
|
{/* Text Reference (original text + position + correction) */}
|
||||||
|
{f.text_reference && (
|
||||||
|
<TextReference ref={f.text_reference} correction={f.correction} />
|
||||||
|
)}
|
||||||
|
{/* Fallback: correction without text reference */}
|
||||||
|
{!f.text_reference && f.correction && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<button onClick={() => setExpandedCorrection(isExp ? null : corrKey)}
|
<button onClick={() => setExpandedCorrection(isExp ? null : corrKey)}
|
||||||
className="text-xs text-purple-600 hover:text-purple-800 font-medium">
|
className="text-xs text-purple-600 hover:text-purple-800 font-medium">
|
||||||
@@ -272,14 +283,35 @@ export function ScanResult({ data }: { data: ScanData }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* PDF Export Button */}
|
||||||
{/* Email Status */}
|
<div className="pt-4 border-t flex gap-3">
|
||||||
{data.email_status && (
|
<button
|
||||||
<div className="text-xs text-gray-500 flex items-center gap-2">
|
onClick={async () => {
|
||||||
<span className={`w-2 h-2 rounded-full ${data.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
try {
|
||||||
E-Mail: {data.email_status === 'sent' ? 'Gesendet' : data.email_status}
|
const res = await fetch('/api/sdk/v1/agent/scans/pdf', {
|
||||||
</div>
|
method: 'POST',
|
||||||
)}
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: '', scan_type: 'scan', analysis_mode: 'post_launch', result: data }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const blob = await res.blob()
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'compliance-report.pdf'
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
} catch (e) { console.error('PDF export failed:', e) }
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-purple-700 bg-purple-50 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
PDF herunterladen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
interface TextRef {
|
||||||
|
found: boolean
|
||||||
|
source_url: string
|
||||||
|
document_type: string
|
||||||
|
section_heading: string
|
||||||
|
section_number: string
|
||||||
|
parent_section: string
|
||||||
|
paragraph_index: number
|
||||||
|
original_text: string
|
||||||
|
issue: string
|
||||||
|
correction_type: string
|
||||||
|
correction_text: string
|
||||||
|
insert_after: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ISSUE_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
|
missing: { label: 'Fehlt in der DSE', color: 'text-red-700 bg-red-50' },
|
||||||
|
incomplete: { label: 'Unvollstaendig', color: 'text-yellow-700 bg-yellow-50' },
|
||||||
|
incorrect: { label: 'Fehlerhaft', color: 'text-orange-700 bg-orange-50' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const CORRECTION_LABELS: Record<string, string> = {
|
||||||
|
insert: 'Neuen Abschnitt einfuegen',
|
||||||
|
append: 'Am Ende des Absatzes ergaenzen',
|
||||||
|
replace: 'Absatz ersetzen',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextReference({ ref, correction }: { ref: TextRef; correction?: string }) {
|
||||||
|
const [showCorrection, setShowCorrection] = useState(false)
|
||||||
|
const issue = ISSUE_LABELS[ref.issue] || null
|
||||||
|
const correctionText = correction || ref.correction_text
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 space-y-2 text-sm">
|
||||||
|
{/* Original Text Block */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500 mb-1 flex items-center gap-1">
|
||||||
|
<span>📄</span> Originaltextblock:
|
||||||
|
</p>
|
||||||
|
<div className={`rounded-lg p-3 border ${ref.found ? 'bg-gray-50 border-gray-200' : 'bg-red-50 border-red-200'}`}>
|
||||||
|
{ref.found ? (
|
||||||
|
<p className="text-gray-700 text-xs whitespace-pre-wrap">{ref.original_text || '(Textinhalt konnte nicht extrahiert werden)'}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-red-600 text-xs italic">Nicht vorhanden — Eintrag fehlt in der {ref.document_type}.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Position */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-gray-500 mb-1 flex items-center gap-1">
|
||||||
|
<span>📍</span> Position:
|
||||||
|
</p>
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-2 text-xs text-blue-800">
|
||||||
|
{ref.found ? (
|
||||||
|
<>
|
||||||
|
<span className="font-semibold">{ref.section_heading || 'Abschnitt unbekannt'}</span>
|
||||||
|
{ref.section_number && <span className="text-blue-600 ml-1">(Nr. {ref.section_number})</span>}
|
||||||
|
{ref.parent_section && <span className="text-blue-500 ml-1">in: {ref.parent_section}</span>}
|
||||||
|
{ref.paragraph_index > 0 && <span className="text-blue-500 ml-1">| Absatz {ref.paragraph_index}</span>}
|
||||||
|
</>
|
||||||
|
) : ref.insert_after ? (
|
||||||
|
<span><strong>{CORRECTION_LABELS[ref.correction_type] || 'Einfuegen'}</strong> nach Abschnitt "{ref.insert_after}"</span>
|
||||||
|
) : (
|
||||||
|
<span>Neuen Abschnitt in der {ref.document_type} anlegen</span>
|
||||||
|
)}
|
||||||
|
{ref.source_url && (
|
||||||
|
<div className="text-blue-400 mt-1 truncate">in: {ref.source_url}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Correction */}
|
||||||
|
{correctionText && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCorrection(!showCorrection)}
|
||||||
|
className="text-xs text-purple-600 hover:text-purple-800 font-medium flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span>{showCorrection ? '▼' : '▶'}</span>
|
||||||
|
<span>✏️</span> Korrekturvorschlag {showCorrection ? 'ausblenden' : 'anzeigen'}
|
||||||
|
</button>
|
||||||
|
{showCorrection && (
|
||||||
|
<div className="mt-2 bg-white border border-purple-200 rounded-lg p-3 relative">
|
||||||
|
{issue && (
|
||||||
|
<span className={`text-[10px] px-2 py-0.5 rounded-full font-medium mb-2 inline-block ${issue.color}`}>
|
||||||
|
{CORRECTION_LABELS[ref.correction_type] || issue.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<pre className="text-xs text-gray-700 whitespace-pre-wrap font-sans mt-1">{correctionText}</pre>
|
||||||
|
<button
|
||||||
|
onClick={() => navigator.clipboard.writeText(correctionText)}
|
||||||
|
className="absolute top-2 right-2 text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded transition-colors"
|
||||||
|
title="In Zwischenablage kopieren"
|
||||||
|
>
|
||||||
|
📋 Kopieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,35 +1,22 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useAgentAnalysis } from './_hooks/useAgentAnalysis'
|
|
||||||
import { AnalysisResult } from './_components/AnalysisResult'
|
|
||||||
import { AnalysisHistory } from './_components/AnalysisHistory'
|
|
||||||
import { FollowUpQuestions } from './_components/FollowUpQuestions'
|
|
||||||
import { ScanResult } from './_components/ScanResult'
|
import { ScanResult } from './_components/ScanResult'
|
||||||
import { DocCheckTab } from './_components/DocCheckTab'
|
import { ComplianceCheckTab } from './_components/ComplianceCheckTab'
|
||||||
import { BannerCheckTab } from './_components/BannerCheckTab'
|
import { BannerCheckTab } from './_components/BannerCheckTab'
|
||||||
import { ComplianceFAQ } from './_components/ComplianceFAQ'
|
import { ComplianceFAQ } from './_components/ComplianceFAQ'
|
||||||
|
|
||||||
type AnalysisMode = 'pre_launch' | 'post_launch'
|
type AnalysisTab = 'scan' | 'compliance-check' | 'banner-check'
|
||||||
type AnalysisTab = 'quick' | 'scan' | 'doc-check' | 'banner-check'
|
|
||||||
|
|
||||||
const MODES: { id: AnalysisMode; label: string; desc: string; icon: string }[] = [
|
|
||||||
{ id: 'pre_launch', label: 'Internes Dokument', desc: 'Vor Veroeffentlichung pruefen', icon: '📋' },
|
|
||||||
{ id: 'post_launch', label: 'Live-Website', desc: 'Bereits online analysieren', icon: '🌐' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const TABS: { id: AnalysisTab; label: string; desc: string }[] = [
|
const TABS: { id: AnalysisTab; label: string; desc: string }[] = [
|
||||||
{ id: 'quick', label: 'Schnellanalyse', desc: 'Einzelne Seite klassifizieren + bewerten' },
|
{ id: 'scan', label: 'Website-Scan', desc: 'Rechtliche Dokumente finden + Dienstleister erkennen' },
|
||||||
{ id: 'scan', label: 'Website-Scan', desc: 'Mehrere Seiten scannen + Dienstleister abgleichen' },
|
{ id: 'compliance-check', label: 'Compliance-Check', desc: 'Alle rechtlichen Dokumente zusammen pruefen' },
|
||||||
{ id: 'doc-check', label: 'Dokumenten-Pruefung', desc: 'Einzelne Dokumente gezielt pruefen' },
|
|
||||||
{ id: 'banner-check', label: 'Banner-Check', desc: 'Cookie-Banner auf DSGVO-Konformitaet testen' },
|
{ id: 'banner-check', label: 'Banner-Check', desc: 'Cookie-Banner auf DSGVO-Konformitaet testen' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function AgentPage() {
|
export default function AgentPage() {
|
||||||
// Restore state from localStorage on mount
|
|
||||||
const [url, setUrl] = useState(() => typeof window !== 'undefined' ? localStorage.getItem('agent-scan-url') || '' : '')
|
const [url, setUrl] = useState(() => typeof window !== 'undefined' ? localStorage.getItem('agent-scan-url') || '' : '')
|
||||||
const [mode, setMode] = useState<AnalysisMode>(() => (typeof window !== 'undefined' ? localStorage.getItem('agent-scan-mode') as AnalysisMode : null) || 'post_launch')
|
const [tab, setTab] = useState<AnalysisTab>(() => (typeof window !== 'undefined' ? localStorage.getItem('agent-scan-tab') as AnalysisTab : null) || 'compliance-check')
|
||||||
const [tab, setTab] = useState<AnalysisTab>(() => (typeof window !== 'undefined' ? localStorage.getItem('agent-scan-tab') as AnalysisTab : null) || 'quick')
|
|
||||||
const [scanLoading, setScanLoading] = useState(false)
|
const [scanLoading, setScanLoading] = useState(false)
|
||||||
const [scanError, setScanError] = useState<string | null>(null)
|
const [scanError, setScanError] = useState<string | null>(null)
|
||||||
const [scanData, setScanData] = useState<any>(() => {
|
const [scanData, setScanData] = useState<any>(() => {
|
||||||
@@ -38,19 +25,15 @@ export default function AgentPage() {
|
|||||||
})
|
})
|
||||||
const [scanProgress, setScanProgress] = useState<string>('')
|
const [scanProgress, setScanProgress] = useState<string>('')
|
||||||
const [activeScanId, setActiveScanId] = useState<string>(() => typeof window !== 'undefined' ? localStorage.getItem('agent-scan-id') || '' : '')
|
const [activeScanId, setActiveScanId] = useState<string>(() => typeof window !== 'undefined' ? localStorage.getItem('agent-scan-id') || '' : '')
|
||||||
const [scanHistory, setScanHistory] = useState<{ url: string; date: string; findings: number; docs: number }[]>(() => {
|
const [scanHistory, setScanHistory] = useState<{ url: string; date: string; findings: number; docs: number; resultKey: string }[]>(() => {
|
||||||
if (typeof window === 'undefined') return []
|
if (typeof window === 'undefined') return []
|
||||||
try { return JSON.parse(localStorage.getItem('agent-scan-history') || '[]') } catch { return [] }
|
try { return JSON.parse(localStorage.getItem('agent-scan-history') || '[]') } catch { return [] }
|
||||||
})
|
})
|
||||||
const { analyze, answerFollowUp, loading, error, result, history } = useAgentAnalysis()
|
|
||||||
|
|
||||||
// Persist state to localStorage
|
|
||||||
React.useEffect(() => { localStorage.setItem('agent-scan-url', url) }, [url])
|
React.useEffect(() => { localStorage.setItem('agent-scan-url', url) }, [url])
|
||||||
React.useEffect(() => { localStorage.setItem('agent-scan-mode', mode) }, [mode])
|
|
||||||
React.useEffect(() => { localStorage.setItem('agent-scan-tab', tab) }, [tab])
|
React.useEffect(() => { localStorage.setItem('agent-scan-tab', tab) }, [tab])
|
||||||
React.useEffect(() => { if (scanData?.services) localStorage.setItem('agent-scan-result', JSON.stringify(scanData)) }, [scanData])
|
|
||||||
|
|
||||||
// Resume polling if scan was in progress when page was left
|
// Resume polling if scan was in progress
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!activeScanId || scanData?.services) return
|
if (!activeScanId || scanData?.services) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -65,31 +48,17 @@ export default function AgentPage() {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.progress) setScanProgress(data.progress)
|
if (data.progress) setScanProgress(data.progress)
|
||||||
if (data.status === 'completed' && data.result) {
|
if (data.status === 'completed' && data.result) {
|
||||||
setScanData(data.result)
|
setScanData(data.result); setScanProgress(''); setScanLoading(false)
|
||||||
setScanProgress('')
|
|
||||||
setScanLoading(false)
|
|
||||||
localStorage.setItem('agent-scan-result', JSON.stringify(data.result))
|
localStorage.setItem('agent-scan-result', JSON.stringify(data.result))
|
||||||
localStorage.removeItem('agent-scan-id')
|
localStorage.removeItem('agent-scan-id'); setActiveScanId('')
|
||||||
setActiveScanId('')
|
_addToHistory(data.result); return
|
||||||
_addToHistory(data.result)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (data.status === 'failed') {
|
if (data.status === 'failed' || data.status === 'not_found') {
|
||||||
setScanError(data.error || 'Scan fehlgeschlagen')
|
if (data.status === 'failed') setScanError(data.error || 'Scan fehlgeschlagen')
|
||||||
setScanProgress('')
|
setScanProgress(''); setScanLoading(false)
|
||||||
setScanLoading(false)
|
localStorage.removeItem('agent-scan-id'); setActiveScanId(''); return
|
||||||
localStorage.removeItem('agent-scan-id')
|
|
||||||
setActiveScanId('')
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (data.status === 'not_found') {
|
} catch {}
|
||||||
setScanProgress('')
|
|
||||||
setScanLoading(false)
|
|
||||||
localStorage.removeItem('agent-scan-id')
|
|
||||||
setActiveScanId('')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch { /* retry */ }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
poll()
|
poll()
|
||||||
@@ -97,222 +66,127 @@ export default function AgentPage() {
|
|||||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const _addToHistory = (result: any) => {
|
const _addToHistory = (result: any) => {
|
||||||
const entry = {
|
const resultKey = `scan-result-${Date.now()}`
|
||||||
url: url || result.url || '',
|
try { localStorage.setItem(resultKey, JSON.stringify(result)) } catch {}
|
||||||
date: new Date().toISOString(),
|
const entry = { url: url || result.url || '', date: new Date().toISOString(), findings: result.findings?.length || 0, docs: result.discovered_documents?.length || 0, resultKey }
|
||||||
findings: result.findings?.length || 0,
|
const updated = [entry, ...scanHistory].slice(0, 30)
|
||||||
docs: result.discovered_documents?.length || 0,
|
setScanHistory(updated); localStorage.setItem('agent-scan-history', JSON.stringify(updated))
|
||||||
}
|
|
||||||
const updated = [entry, ...scanHistory].slice(0, 50)
|
|
||||||
setScanHistory(updated)
|
|
||||||
localStorage.setItem('agent-scan-history', JSON.stringify(updated))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const _loadFromHistory = (entry: { url: string }) => {
|
const handleScan = async (e: React.FormEvent) => {
|
||||||
setUrl(entry.url)
|
|
||||||
setTab('scan')
|
|
||||||
// Load saved result if same URL
|
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem('agent-scan-result')
|
|
||||||
if (saved) {
|
|
||||||
const parsed = JSON.parse(saved)
|
|
||||||
if (parsed.url === entry.url) {
|
|
||||||
setScanData(parsed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!url.trim()) return
|
if (!url.trim()) return
|
||||||
|
setScanLoading(true); setScanError(null); setScanData(null); setScanProgress('Scan wird gestartet...')
|
||||||
if (tab === 'quick') {
|
try {
|
||||||
analyze(url.trim(), mode)
|
const startRes = await fetch('/api/sdk/v1/agent/scan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: url.trim(), mode: 'post_launch' }) })
|
||||||
} else {
|
if (!startRes.ok) throw new Error(`Scan konnte nicht gestartet werden: ${startRes.status}`)
|
||||||
setScanLoading(true)
|
const { scan_id } = await startRes.json()
|
||||||
setScanError(null)
|
if (!scan_id) throw new Error('Keine Scan-ID erhalten')
|
||||||
setScanData(null)
|
setActiveScanId(scan_id); localStorage.setItem('agent-scan-id', scan_id)
|
||||||
setScanProgress('Scan wird gestartet...')
|
let attempts = 0
|
||||||
try {
|
while (attempts < 120) {
|
||||||
// Step 1: Start async scan
|
await new Promise(r => setTimeout(r, 5000))
|
||||||
const startRes = await fetch('/api/sdk/v1/agent/scan', {
|
const pollRes = await fetch(`/api/sdk/v1/agent/scan?scan_id=${scan_id}`)
|
||||||
method: 'POST',
|
if (!pollRes.ok) { attempts++; continue }
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const pollData = await pollRes.json()
|
||||||
body: JSON.stringify({ url: url.trim(), mode }),
|
if (pollData.progress) setScanProgress(pollData.progress)
|
||||||
})
|
if (pollData.status === 'completed' && pollData.result) {
|
||||||
if (!startRes.ok) throw new Error(`Scan konnte nicht gestartet werden: ${startRes.status}`)
|
setScanData(pollData.result); setScanProgress('')
|
||||||
const { scan_id } = await startRes.json()
|
localStorage.setItem('agent-scan-result', JSON.stringify(pollData.result))
|
||||||
if (!scan_id) throw new Error('Keine Scan-ID erhalten')
|
localStorage.removeItem('agent-scan-id'); setActiveScanId(''); _addToHistory(pollData.result); break
|
||||||
setActiveScanId(scan_id)
|
|
||||||
localStorage.setItem('agent-scan-id', scan_id)
|
|
||||||
|
|
||||||
// Step 2: Poll for results
|
|
||||||
let attempts = 0
|
|
||||||
const maxAttempts = 120 // 10 min at 5s intervals
|
|
||||||
while (attempts < maxAttempts) {
|
|
||||||
await new Promise(r => setTimeout(r, 5000))
|
|
||||||
const pollRes = await fetch(`/api/sdk/v1/agent/scan?scan_id=${scan_id}`)
|
|
||||||
if (!pollRes.ok) { attempts++; continue }
|
|
||||||
const pollData = await pollRes.json()
|
|
||||||
|
|
||||||
if (pollData.progress) {
|
|
||||||
setScanProgress(pollData.progress)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pollData.status === 'completed' && pollData.result) {
|
|
||||||
setScanData(pollData.result)
|
|
||||||
setScanProgress('')
|
|
||||||
localStorage.setItem('agent-scan-result', JSON.stringify(pollData.result))
|
|
||||||
localStorage.removeItem('agent-scan-id')
|
|
||||||
setActiveScanId('')
|
|
||||||
_addToHistory(pollData.result)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (pollData.status === 'failed') {
|
|
||||||
throw new Error(pollData.error || 'Scan fehlgeschlagen')
|
|
||||||
}
|
|
||||||
attempts++
|
|
||||||
}
|
}
|
||||||
if (attempts >= maxAttempts) throw new Error('Scan-Timeout (10 Minuten)')
|
if (pollData.status === 'failed') throw new Error(pollData.error || 'Scan fehlgeschlagen')
|
||||||
} catch (e) {
|
attempts++
|
||||||
setScanError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
|
||||||
setScanProgress('')
|
|
||||||
} finally {
|
|
||||||
setScanLoading(false)
|
|
||||||
}
|
}
|
||||||
}
|
if (attempts >= 120) throw new Error('Scan-Timeout (10 Minuten)')
|
||||||
|
} catch (e) { setScanError(e instanceof Error ? e.message : 'Unbekannter Fehler'); setScanProgress('') }
|
||||||
|
finally { setScanLoading(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLoading = tab === 'quick' ? loading : scanLoading
|
const navigateToCheck = (targetTab: AnalysisTab, checkUrl: string) => {
|
||||||
const currentError = tab === 'quick' ? error : scanError
|
const keyMap: Record<string, string> = { 'doc-check': 'doc-check-prefill-url', 'banner-check': 'banner-check-url', 'impressum-check': 'impressum-check-url' }
|
||||||
|
if (keyMap[targetTab]) localStorage.setItem(keyMap[targetTab], checkUrl)
|
||||||
|
setTab(targetTab)
|
||||||
|
}
|
||||||
|
|
||||||
|
const discoveredDocs = scanData?.discovered_documents || []
|
||||||
|
const scannedUrl = scanData?.url || url
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-4xl">
|
<div className="space-y-6 max-w-4xl">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Compliance Agent</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Compliance Agent</h1>
|
||||||
<p className="text-gray-500 mt-1">Analysiere Dokumente und Webseiten auf DSGVO-Konformitaet.</p>
|
<p className="text-gray-500 mt-1">Analysiere Webseiten und Dokumente auf DSGVO-Konformitaet.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mode Selection */}
|
<div className="flex border-b border-gray-200 overflow-x-auto">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{MODES.map(m => (
|
|
||||||
<button key={m.id} onClick={() => setMode(m.id)}
|
|
||||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
|
||||||
mode === m.id ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-gray-300'}`}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-xl">{m.icon}</span>
|
|
||||||
<div>
|
|
||||||
<p className={`text-sm font-semibold ${mode === m.id ? 'text-purple-900' : 'text-gray-900'}`}>{m.label}</p>
|
|
||||||
<p className="text-xs text-gray-500">{m.desc}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Selection */}
|
|
||||||
<div className="flex border-b border-gray-200">
|
|
||||||
{TABS.map(t => (
|
{TABS.map(t => (
|
||||||
<button key={t.id} onClick={() => setTab(t.id)}
|
<button key={t.id} onClick={() => setTab(t.id)}
|
||||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||||
tab === t.id
|
tab === t.id ? 'border-purple-500 text-purple-700' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
||||||
? 'border-purple-500 text-purple-700'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
|
||||||
{t.label}
|
{t.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Doc Check Tab — own component */}
|
{tab === 'scan' && (
|
||||||
{tab === 'doc-check' && <DocCheckTab />}
|
<div className="space-y-4">
|
||||||
|
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
|
||||||
{/* Banner Check Tab — own component */}
|
<h3 className="text-sm font-semibold text-indigo-900">Website-Scan (Discovery)</h3>
|
||||||
{tab === 'banner-check' && <BannerCheckTab />}
|
<p className="text-xs text-indigo-700 mt-1">Findet alle rechtlichen Dokumente (DSI, AGB, Impressum, Cookie, Widerruf), erkennt eingesetzte Drittdienste und prueft ob sie in der DSE dokumentiert sind.</p>
|
||||||
|
</div>
|
||||||
{/* URL Input (quick + scan only) */}
|
<form onSubmit={handleScan} className="flex gap-3">
|
||||||
{(tab === 'quick' || tab === 'scan') && <form onSubmit={handleSubmit} className="flex gap-3">
|
<input type="url" value={url} onChange={e => setUrl(e.target.value)} placeholder="https://www.example.com/"
|
||||||
<input type="url" value={url} onChange={e => setUrl(e.target.value)}
|
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm" disabled={scanLoading} required />
|
||||||
placeholder={tab === 'scan' ? 'https://www.example.com/' : 'https://example.com/datenschutz'}
|
<button type="submit" disabled={scanLoading || !url.trim()}
|
||||||
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm"
|
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center gap-2 text-sm font-medium whitespace-nowrap">
|
||||||
disabled={isLoading} required />
|
{scanLoading ? (<><svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>Scanne...</>) : 'Website scannen'}
|
||||||
<button type="submit" disabled={isLoading || !url.trim()}
|
</button>
|
||||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center gap-2 text-sm font-medium">
|
</form>
|
||||||
{isLoading ? (
|
{scanProgress && <div className="bg-purple-50 border border-purple-200 rounded-lg p-4 text-sm text-purple-700 flex items-center gap-3"><svg className="animate-spin w-5 h-5 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>{scanProgress}</div>}
|
||||||
<><svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
{scanError && <div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{scanError}</div>}
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
{scanData && (
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
<div className="bg-white border border-gray-200 rounded-xl p-4 shadow-sm">
|
||||||
</svg>{tab === 'scan' ? 'Scanne...' : 'Analysiere...'}</>
|
<h4 className="text-sm font-semibold text-gray-800 mb-3">Jetzt pruefen</h4>
|
||||||
) : tab === 'scan' ? 'Website scannen' : 'Analysieren'}
|
<div className="grid grid-cols-2 gap-2">
|
||||||
</button>
|
<button onClick={() => navigateToCheck('banner-check', scannedUrl)} className="p-3 rounded-lg border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-all text-left">
|
||||||
</form>}
|
<div className="text-sm font-medium text-gray-900">Cookie-Banner pruefen</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">3-Phasen Dark-Pattern-Analyse</div>
|
||||||
{/* Scan Progress */}
|
</button>
|
||||||
{scanProgress && tab === 'scan' && (
|
<button onClick={() => navigateToCheck('impressum-check', scannedUrl + '/impressum')} className="p-3 rounded-lg border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-all text-left">
|
||||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 text-sm text-purple-700 flex items-center gap-3">
|
<div className="text-sm font-medium text-gray-900">Impressum pruefen</div>
|
||||||
<svg className="animate-spin w-5 h-5 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24">
|
<div className="text-xs text-gray-500 mt-0.5">§5 TMG Pflichtangaben</div>
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
</button>
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
{discoveredDocs.map((doc: any, i: number) => (
|
||||||
</svg>
|
<button key={i} onClick={() => navigateToCheck('doc-check', doc.url)} className="p-3 rounded-lg border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-all text-left">
|
||||||
{scanProgress}
|
<div className="text-sm font-medium text-gray-900 truncate">{doc.title || doc.url}</div>
|
||||||
</div>
|
<div className="text-xs text-gray-500 mt-0.5">{doc.doc_type?.toUpperCase()} · {doc.word_count || '?'} Woerter{doc.completeness_pct != null && ` · ${doc.completeness_pct}%`}</div>
|
||||||
)}
|
</button>
|
||||||
|
))}
|
||||||
{/* Error */}
|
</div>
|
||||||
{currentError && (
|
</div>
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{currentError}</div>
|
)}
|
||||||
)}
|
{scanData?.services && <div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm"><ScanResult data={scanData} /></div>}
|
||||||
|
{scanHistory.length > 0 && (
|
||||||
{/* Quick Analysis Result */}
|
<div className="border border-gray-200 rounded-xl p-4">
|
||||||
{tab === 'quick' && result && (
|
<h4 className="text-sm font-medium text-gray-700 mb-3">Letzte Scans</h4>
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm space-y-6">
|
<div className="space-y-2">
|
||||||
<AnalysisResult result={result} />
|
{scanHistory.map((h, i) => (
|
||||||
{result.follow_up_questions.length > 0 && (
|
<button key={i} onClick={() => { setUrl(h.url); if (h.resultKey) { try { const s = localStorage.getItem(h.resultKey); if (s) { setScanData(JSON.parse(s)); return } } catch {} } }}
|
||||||
<div className="border-t pt-4">
|
className="w-full flex items-center justify-between p-3 rounded-lg border border-gray-100 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left">
|
||||||
<FollowUpQuestions questions={result.follow_up_questions} answers={result.follow_up_answers} onAnswer={answerFollowUp} />
|
<div className="min-w-0 flex-1"><div className="text-sm font-medium text-gray-900 truncate">{h.url}</div><div className="text-xs text-gray-500">{new Date(h.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}</div></div>
|
||||||
|
<div className="flex items-center gap-3 shrink-0 ml-3">{h.docs > 0 && <span className="text-xs text-purple-600">{h.docs} Dok.</span>}<span className={`text-xs font-medium ${h.findings > 0 ? 'text-red-600' : 'text-green-600'}`}>{h.findings} Findings</span></div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Scan Result — only render when we have a complete response with services */}
|
{tab === 'compliance-check' && <ComplianceCheckTab />}
|
||||||
{tab === 'scan' && scanData && scanData.services && (
|
{tab === 'banner-check' && <BannerCheckTab />}
|
||||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
|
||||||
<ScanResult data={scanData} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* History (quick only) */}
|
|
||||||
{tab === 'quick' && (
|
|
||||||
<AnalysisHistory history={history} onSelect={r => { setUrl(r.url); analyze(r.url, mode) }} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Scan History */}
|
|
||||||
{tab === 'scan' && scanHistory.length > 0 && (
|
|
||||||
<div className="border border-gray-200 rounded-xl p-4">
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Letzte Scans</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{scanHistory.map((h, i) => (
|
|
||||||
<button key={i} onClick={() => _loadFromHistory(h)}
|
|
||||||
className="w-full flex items-center justify-between p-3 rounded-lg border border-gray-100 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-medium text-gray-900 truncate">{h.url}</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{new Date(h.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 shrink-0 ml-3">
|
|
||||||
{h.docs > 0 && <span className="text-xs text-purple-600">{h.docs} Dok.</span>}
|
|
||||||
<span className={`text-xs font-medium ${h.findings > 0 ? 'text-red-600' : 'text-green-600'}`}>
|
|
||||||
{h.findings} Findings
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* FAQ */}
|
|
||||||
<ComplianceFAQ />
|
<ComplianceFAQ />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
id: string
|
||||||
|
entity_type: string
|
||||||
|
entity_id: string
|
||||||
|
entity_name: string
|
||||||
|
action: string
|
||||||
|
field_changed: string | null
|
||||||
|
old_value: string | null
|
||||||
|
new_value: string | null
|
||||||
|
change_summary: string | null
|
||||||
|
performed_by: string
|
||||||
|
performed_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuditTimeline() {
|
||||||
|
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [filter, setFilter] = useState<string>('all')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadEntries()
|
||||||
|
}, [filter]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
async function loadEntries() {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ limit: '100' })
|
||||||
|
if (filter !== 'all') params.set('entity_type', filter)
|
||||||
|
const res = await fetch(`/api/sdk/v1/compliance/audit-trail?${params}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json()
|
||||||
|
setEntries(json.entries || json.audit_trail || json || [])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load audit trail:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { entries, loading, filter, setFilter }
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useAuditTimeline, type AuditEntry } from './_hooks/useAuditTimeline'
|
||||||
|
|
||||||
|
const ENTITY_LABELS: Record<string, string> = {
|
||||||
|
evidence: 'Nachweis', control: 'Control', document: 'Dokument',
|
||||||
|
dsfa: 'DSFA', vvt: 'VVT', tom: 'TOM', policy: 'Richtlinie',
|
||||||
|
dsms_archive: 'DSMS-Archiv', risk: 'Risiko',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_COLORS: Record<string, string> = {
|
||||||
|
create: 'bg-green-500', update: 'bg-blue-500', delete: 'bg-red-500',
|
||||||
|
approve: 'bg-purple-500', archive: 'bg-emerald-500', review: 'bg-yellow-500',
|
||||||
|
sign: 'bg-indigo-500', reject: 'bg-red-400',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTER_OPTIONS = ['all', 'evidence', 'dsms_archive', 'control', 'document', 'dsfa', 'vvt', 'tom']
|
||||||
|
|
||||||
|
export default function AuditTimelinePage() {
|
||||||
|
const { entries, loading, filter, setFilter } = useAuditTimeline()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Audit Timeline</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">Chronologische Compliance-Historie mit DSMS-Nachweisen</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{FILTER_OPTIONS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => setFilter(f)}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||||
|
filter === f
|
||||||
|
? 'bg-purple-600 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f === 'all' ? 'Alle' : ENTITY_LABELS[f] || f}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600" />
|
||||||
|
</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<div className="text-center py-16 text-gray-500">
|
||||||
|
Keine Eintraege gefunden. Compliance-Aktionen werden automatisch protokolliert.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Timeline line */}
|
||||||
|
<div className="absolute left-6 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700" />
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<TimelineEntry key={entry.id} entry={entry} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineEntry({ entry }: { entry: AuditEntry }) {
|
||||||
|
const dotColor = ACTION_COLORS[entry.action] || 'bg-gray-400'
|
||||||
|
const isCID = entry.field_changed === 'dsms_cid' || entry.action === 'archive'
|
||||||
|
const date = new Date(entry.performed_at)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex gap-4 pl-3">
|
||||||
|
{/* Dot */}
|
||||||
|
<div className={`relative z-10 w-3 h-3 rounded-full mt-1.5 flex-shrink-0 ring-4 ring-white dark:ring-gray-900 ${dotColor}`} />
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 min-w-0">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-white">{entry.entity_name}</span>
|
||||||
|
<span className="px-2 py-0.5 rounded text-[10px] font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
{ENTITY_LABELS[entry.entity_type] || entry.entity_type}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-0.5 rounded text-[10px] font-medium text-white ${dotColor}`}>
|
||||||
|
{entry.action}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{entry.change_summary && (
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">{entry.change_summary}</p>
|
||||||
|
)}
|
||||||
|
{isCID && entry.new_value && (
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<svg className="w-3.5 h-3.5 text-emerald-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
<code className="text-[10px] bg-emerald-50 text-emerald-700 px-2 py-0.5 rounded font-mono dark:bg-emerald-900/30 dark:text-emerald-300">
|
||||||
|
{entry.new_value.length > 20 ? entry.new_value.slice(0, 8) + '...' + entry.new_value.slice(-6) : entry.new_value}
|
||||||
|
</code>
|
||||||
|
<span className="text-[10px] text-emerald-500">DSMS/IPFS</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right flex-shrink-0">
|
||||||
|
<div className="text-xs text-gray-400">{date.toLocaleDateString('de-DE')}</div>
|
||||||
|
<div className="text-[10px] text-gray-300">{date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</div>
|
||||||
|
<div className="text-[10px] text-gray-300 mt-0.5">{entry.performed_by}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -174,6 +174,44 @@ export default function CMPDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Banner-Bedarf Hinweis (TTDSG § 25) */}
|
||||||
|
{bannerStats && Object.keys(bannerStats.category_acceptance).length === 0 && sites.length === 0 && (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-xl p-5 flex items-start gap-4">
|
||||||
|
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-green-800">Kein Cookie-Banner erforderlich</h3>
|
||||||
|
<p className="text-sm text-green-700 mt-1">
|
||||||
|
Es wurden keine Cookies, Tracker oder Analytics-Dienste erkannt. Gemaess TTDSG § 25 ist kein
|
||||||
|
Cookie-Banner erforderlich, da keine Informationen auf dem Endgeraet gespeichert werden.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-green-600 mt-2">
|
||||||
|
<strong>Weiterhin Pflicht:</strong> Impressum (DDG § 5) und Datenschutzerklaerung (DSGVO Art. 13)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Banner-Warnung wenn Tracker ohne Banner */}
|
||||||
|
{bannerStats && Object.keys(bannerStats.category_acceptance).length > 0 && sites.length === 0 && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-xl p-5 flex items-start gap-4">
|
||||||
|
<div className="w-10 h-10 bg-red-100 rounded-lg 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>
|
||||||
|
<h3 className="font-semibold text-red-800">Cookie-Banner fehlt!</h3>
|
||||||
|
<p className="text-sm text-red-700 mt-1">
|
||||||
|
Es wurden Tracking-Dienste erkannt, aber kein Cookie-Banner ist konfiguriert.
|
||||||
|
Gemaess TTDSG § 25 ist eine Einwilligung erforderlich.
|
||||||
|
</p>
|
||||||
|
<Link href="/sdk/cookie-banner" className="inline-block mt-2 text-sm text-red-700 font-medium underline">
|
||||||
|
Jetzt Cookie-Banner einrichten
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Compliance Status */}
|
{/* Compliance Status */}
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-1">Compliance-Status</h3>
|
<h3 className="font-semibold text-gray-900 mb-1">Compliance-Status</h3>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { COMPANY_PROFILE_PRESETS, type CompanyProfilePreset } from '@/lib/sdk/company-profile-presets'
|
||||||
|
|
||||||
|
interface PresetSelectorProps {
|
||||||
|
onSelect: (preset: CompanyProfilePreset) => void
|
||||||
|
onSkip: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PresetSelector({ onSelect, onSkip }: PresetSelectorProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">Welcher Unternehmenstyp passt zu Ihnen?</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
|
Waehlen Sie eine Vorlage fuer Ihre Branche — alle Felder werden vorbefuellt
|
||||||
|
und Sie koennen anschliessend anpassen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||||
|
{COMPANY_PROFILE_PRESETS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.id}
|
||||||
|
onClick={() => onSelect(preset)}
|
||||||
|
className="flex flex-col items-center gap-2 p-4 bg-white border border-gray-200 rounded-xl hover:border-purple-400 hover:shadow-md transition-all text-center group"
|
||||||
|
>
|
||||||
|
<span className="text-3xl">{preset.icon}</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900 group-hover:text-purple-700">
|
||||||
|
{preset.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500 leading-tight">
|
||||||
|
{preset.description}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
onClick={onSkip}
|
||||||
|
className="text-sm text-gray-400 hover:text-gray-600 underline"
|
||||||
|
>
|
||||||
|
Manuell ausfuellen (ohne Vorlage)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -78,6 +78,14 @@ export default function ComplianceScopePage() {
|
|||||||
const [supervisoryAuthorities, setSupervisoryAuthorities] = useState<SupervisoryAuthorityInfo[]>([])
|
const [supervisoryAuthorities, setSupervisoryAuthorities] = useState<SupervisoryAuthorityInfo[]>([])
|
||||||
const [regulationAssessmentLoading, setRegulationAssessmentLoading] = useState(false)
|
const [regulationAssessmentLoading, setRegulationAssessmentLoading] = useState(false)
|
||||||
|
|
||||||
|
// Enabled compliance modules (derived from applicable regulations)
|
||||||
|
const [enabledModules, setEnabledModules] = useState<string[]>([])
|
||||||
|
|
||||||
|
// Auto-enable all applicable regulations when they load
|
||||||
|
const handleToggleModule = (moduleId: string, enabled: boolean) => {
|
||||||
|
setEnabledModules(prev => enabled ? [...prev, moduleId] : prev.filter(id => id !== moduleId))
|
||||||
|
}
|
||||||
|
|
||||||
// Sync from SDK context when it becomes available (handles async loading).
|
// Sync from SDK context when it becomes available (handles async loading).
|
||||||
// The SDK context loads state from server/localStorage asynchronously, so
|
// The SDK context loads state from server/localStorage asynchronously, so
|
||||||
// sdkState.complianceScope may arrive AFTER this page has already mounted.
|
// sdkState.complianceScope may arrive AFTER this page has already mounted.
|
||||||
@@ -159,6 +167,10 @@ export default function ComplianceScopePage() {
|
|||||||
// Set applicable regulations from response
|
// Set applicable regulations from response
|
||||||
const regs: ApplicableRegulation[] = data.overview?.applicable_regulations || data.applicable_regulations || []
|
const regs: ApplicableRegulation[] = data.overview?.applicable_regulations || data.applicable_regulations || []
|
||||||
setApplicableRegulations(regs)
|
setApplicableRegulations(regs)
|
||||||
|
// Auto-enable all applicable regulations as modules
|
||||||
|
if (enabledModules.length === 0) {
|
||||||
|
setEnabledModules(regs.map(r => r.id))
|
||||||
|
}
|
||||||
|
|
||||||
// Derive supervisory authorities
|
// Derive supervisory authorities
|
||||||
const regIds = regs.map(r => r.id)
|
const regIds = regs.map(r => r.id)
|
||||||
@@ -375,6 +387,8 @@ export default function ComplianceScopePage() {
|
|||||||
supervisoryAuthorities={supervisoryAuthorities}
|
supervisoryAuthorities={supervisoryAuthorities}
|
||||||
regulationAssessmentLoading={regulationAssessmentLoading}
|
regulationAssessmentLoading={regulationAssessmentLoading}
|
||||||
onGoToObligations={() => { window.location.href = '/sdk/obligations' }}
|
onGoToObligations={() => { window.location.href = '/sdk/obligations' }}
|
||||||
|
enabledModules={enabledModules}
|
||||||
|
onToggleModule={handleToggleModule}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -141,16 +141,24 @@ export default function ConsentManagementPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'emails' && (
|
{activeTab === 'emails' && (
|
||||||
<EmailsTab
|
<div className="bg-purple-50 border border-purple-200 rounded-xl p-8 text-center">
|
||||||
apiEmailTemplates={apiEmailTemplates}
|
<div className="w-14 h-14 mx-auto mb-4 bg-purple-100 rounded-xl flex items-center justify-center">
|
||||||
templatesLoading={templatesLoading}
|
<svg className="w-7 h-7 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
savingTemplateId={savingTemplateId}
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
savedTemplates={savedTemplates}
|
</svg>
|
||||||
setShowCreateTemplateModal={setShowCreateTemplateModal}
|
</div>
|
||||||
saveApiEmailTemplate={saveApiEmailTemplate}
|
<h3 className="font-semibold text-gray-900 mb-2">E-Mail-Templates wurden zentralisiert</h3>
|
||||||
setPreviewTemplate={setPreviewTemplate}
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
setEditingTemplate={setEditingTemplate}
|
Alle E-Mail-Vorlagen (DSR, Consent, Breach, Training, etc.) werden jetzt zentral
|
||||||
/>
|
im E-Mail-Template-Modul verwaltet — mit Versionierung, Freigabe-Workflow und Audit-Log.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/sdk/email-templates')}
|
||||||
|
className="px-6 py-2.5 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 transition-colors"
|
||||||
|
>
|
||||||
|
Zu E-Mail-Templates
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'gdpr' && (
|
{activeTab === 'gdpr' && (
|
||||||
|
|||||||
@@ -212,14 +212,14 @@ export function ControlDetail({
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{ctrl.requirements.length > 0 && (
|
{Array.isArray(ctrl.requirements) && ctrl.requirements.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
||||||
<ol className="list-decimal list-inside space-y-1">{ctrl.requirements.map((r, i) => <li key={i} className="text-sm text-gray-700">{r}</li>)}</ol>
|
<ol className="list-decimal list-inside space-y-1">{ctrl.requirements.map((r, i) => <li key={i} className="text-sm text-gray-700">{r}</li>)}</ol>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ctrl.test_procedure.length > 0 && (
|
{Array.isArray(ctrl.test_procedure) && ctrl.test_procedure.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
||||||
<ol className="list-decimal list-inside space-y-1">{ctrl.test_procedure.map((s, i) => <li key={i} className="text-sm text-gray-700">{s}</li>)}</ol>
|
<ol className="list-decimal list-inside space-y-1">{ctrl.test_procedure.map((s, i) => <li key={i} className="text-sm text-gray-700">{s}</li>)}</ol>
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ export interface ControlsMeta {
|
|||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
export function useControlLibraryState() {
|
export function useControlLibraryState(backendUrlOverride?: string) {
|
||||||
|
const backendUrl = backendUrlOverride || BACKEND_URL
|
||||||
const [frameworks, setFrameworks] = useState<Framework[]>([])
|
const [frameworks, setFrameworks] = useState<Framework[]>([])
|
||||||
const [controls, setControls] = useState<CanonicalControl[]>([])
|
const [controls, setControls] = useState<CanonicalControl[]>([])
|
||||||
const [totalCount, setTotalCount] = useState(0)
|
const [totalCount, setTotalCount] = useState(0)
|
||||||
@@ -100,7 +101,7 @@ export function useControlLibraryState() {
|
|||||||
|
|
||||||
const loadFrameworks = useCallback(async () => {
|
const loadFrameworks = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BACKEND_URL}?endpoint=frameworks`)
|
const res = await fetch(`${backendUrl}?endpoint=frameworks`)
|
||||||
if (res.ok) setFrameworks(await res.json())
|
if (res.ok) setFrameworks(await res.json())
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}, [])
|
}, [])
|
||||||
@@ -111,7 +112,7 @@ export function useControlLibraryState() {
|
|||||||
metaAbortRef.current = controller
|
metaAbortRef.current = controller
|
||||||
try {
|
try {
|
||||||
const qs = buildParams()
|
const qs = buildParams()
|
||||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls-meta${qs ? `&${qs}` : ''}`, { signal: controller.signal })
|
const res = await fetch(`${backendUrl}?endpoint=controls-meta${qs ? `&${qs}` : ''}`, { signal: controller.signal })
|
||||||
if (res.ok && !controller.signal.aborted) setMeta(await res.json())
|
if (res.ok && !controller.signal.aborted) setMeta(await res.json())
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof DOMException && e.name === 'AbortError') return
|
if (e instanceof DOMException && e.name === 'AbortError') return
|
||||||
@@ -130,8 +131,8 @@ export function useControlLibraryState() {
|
|||||||
const qs = buildParams({ sort: sortField, order: sortOrder, limit: String(PAGE_SIZE), offset: String(offset) })
|
const qs = buildParams({ sort: sortField, order: sortOrder, limit: String(PAGE_SIZE), offset: String(offset) })
|
||||||
const countQs = buildParams()
|
const countQs = buildParams()
|
||||||
const [ctrlRes, countRes] = await Promise.all([
|
const [ctrlRes, countRes] = await Promise.all([
|
||||||
fetch(`${BACKEND_URL}?endpoint=controls&${qs}`, { signal: controller.signal }),
|
fetch(`${backendUrl}?endpoint=controls&${qs}`, { signal: controller.signal }),
|
||||||
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`, { signal: controller.signal }),
|
fetch(`${backendUrl}?endpoint=controls-count&${countQs}`, { signal: controller.signal }),
|
||||||
])
|
])
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
||||||
@@ -147,7 +148,7 @@ export function useControlLibraryState() {
|
|||||||
|
|
||||||
const loadReviewCount = useCallback(async () => {
|
const loadReviewCount = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls-count&release_state=needs_review`)
|
const res = await fetch(`${backendUrl}?endpoint=controls-count&release_state=needs_review`)
|
||||||
if (res.ok) { const data = await res.json(); setReviewCount(data.total || 0) }
|
if (res.ok) { const data = await res.json(); setReviewCount(data.total || 0) }
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}, [])
|
}, [])
|
||||||
@@ -165,14 +166,14 @@ export function useControlLibraryState() {
|
|||||||
|
|
||||||
const loadProcessedStats = async () => {
|
const loadProcessedStats = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BACKEND_URL}?endpoint=processed-stats`)
|
const res = await fetch(`${backendUrl}?endpoint=processed-stats`)
|
||||||
if (res.ok) { const data = await res.json(); setProcessedStats(data.stats || []) }
|
if (res.ok) { const data = await res.json(); setProcessedStats(data.stats || []) }
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const enterReviewMode = async () => {
|
const enterReviewMode = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BACKEND_URL}?endpoint=controls&release_state=needs_review&limit=1000`)
|
const res = await fetch(`${backendUrl}?endpoint=controls&release_state=needs_review&limit=1000`)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const items: CanonicalControl[] = await res.json()
|
const items: CanonicalControl[] = await res.json()
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
|
|||||||
@@ -62,6 +62,14 @@ export default function ControlLibraryPage() {
|
|||||||
initial={{
|
initial={{
|
||||||
...EMPTY_CONTROL,
|
...EMPTY_CONTROL,
|
||||||
...state.selectedControl,
|
...state.selectedControl,
|
||||||
|
scope: {
|
||||||
|
platforms: state.selectedControl.scope?.platforms ?? [],
|
||||||
|
components: state.selectedControl.scope?.components ?? [],
|
||||||
|
data_classes: state.selectedControl.scope?.data_classes ?? [],
|
||||||
|
},
|
||||||
|
target_audience: Array.isArray(state.selectedControl.target_audience)
|
||||||
|
? state.selectedControl.target_audience.join(', ')
|
||||||
|
: state.selectedControl.target_audience,
|
||||||
risk_score: state.selectedControl.risk_score,
|
risk_score: state.selectedControl.risk_score,
|
||||||
implementation_effort: state.selectedControl.implementation_effort,
|
implementation_effort: state.selectedControl.implementation_effort,
|
||||||
open_anchors: state.selectedControl.open_anchors.length > 0
|
open_anchors: state.selectedControl.open_anchors.length > 0
|
||||||
@@ -69,7 +77,9 @@ export default function ControlLibraryPage() {
|
|||||||
: [{ framework: '', ref: '', url: '' }],
|
: [{ framework: '', ref: '', url: '' }],
|
||||||
requirements: state.selectedControl.requirements.length > 0 ? state.selectedControl.requirements : [''],
|
requirements: state.selectedControl.requirements.length > 0 ? state.selectedControl.requirements : [''],
|
||||||
test_procedure: state.selectedControl.test_procedure.length > 0 ? state.selectedControl.test_procedure : [''],
|
test_procedure: state.selectedControl.test_procedure.length > 0 ? state.selectedControl.test_procedure : [''],
|
||||||
evidence: state.selectedControl.evidence.length > 0 ? state.selectedControl.evidence : [{ type: '', description: '' }],
|
evidence: state.selectedControl.evidence.length > 0
|
||||||
|
? state.selectedControl.evidence.map(e => typeof e === 'string' ? { type: '', description: e } : e)
|
||||||
|
: [{ type: '', description: '' }],
|
||||||
}}
|
}}
|
||||||
onSave={handleUpdate}
|
onSave={handleUpdate}
|
||||||
onCancel={() => state.setMode('detail')}
|
onCancel={() => state.setMode('detail')}
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
interface Variant {
|
||||||
|
id: string
|
||||||
|
variant_name: string
|
||||||
|
variant_key: string
|
||||||
|
traffic_percent: number
|
||||||
|
is_control: boolean
|
||||||
|
banner_title: string | null
|
||||||
|
banner_description: string | null
|
||||||
|
position: string | null
|
||||||
|
primary_color: string | null
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VariantStat {
|
||||||
|
variant_id: string
|
||||||
|
variant_key: string
|
||||||
|
variant_name: string
|
||||||
|
traffic_percent: number
|
||||||
|
is_control: boolean
|
||||||
|
total: number
|
||||||
|
accepted: number
|
||||||
|
opt_in_rate: number
|
||||||
|
is_winner?: boolean
|
||||||
|
significance?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const API = '/api/sdk/v1/compliance/banner/ab'
|
||||||
|
|
||||||
|
export function ABTestPanel({ siteConfigId }: { siteConfigId?: string }) {
|
||||||
|
const [variants, setVariants] = useState<Variant[]>([])
|
||||||
|
const [stats, setStats] = useState<VariantStat[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [newVariant, setNewVariant] = useState({ variant_name: '', variant_key: 'B', traffic_percent: 50, banner_title: '', primary_color: '' })
|
||||||
|
|
||||||
|
const scid = siteConfigId || ''
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!scid) { setLoading(false); return }
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [v, s] = await Promise.all([
|
||||||
|
fetch(`${API}/${scid}/variants`).then(r => r.ok ? r.json() : []),
|
||||||
|
fetch(`${API}/${scid}/stats`).then(r => r.ok ? r.json() : []),
|
||||||
|
])
|
||||||
|
setVariants(v)
|
||||||
|
setStats(s)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setLoading(false)
|
||||||
|
}, [scid])
|
||||||
|
|
||||||
|
useEffect(() => { loadData() }, [loadData])
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!scid || !newVariant.variant_name) return
|
||||||
|
await fetch(`${API}/${scid}/variants`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(newVariant),
|
||||||
|
})
|
||||||
|
setShowCreate(false)
|
||||||
|
setNewVariant({ variant_name: '', variant_key: 'B', traffic_percent: 50, banner_title: '', primary_color: '' })
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
await fetch(`${API}/variants/${id}`, { method: 'DELETE' })
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTrafficChange = async (id: string, pct: number) => {
|
||||||
|
await fetch(`${API}/variants/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ traffic_percent: pct }),
|
||||||
|
})
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scid) {
|
||||||
|
return <div className="text-center py-8 text-gray-400">Bitte waehlen Sie zuerst eine Site aus.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="text-center py-8 text-gray-400">Lade A/B-Test...</div>
|
||||||
|
|
||||||
|
const maxRate = Math.max(...stats.map(s => s.opt_in_rate), 1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">A/B-Test Varianten</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Testen Sie verschiedene Banner-Konfigurationen um die Opt-In-Rate zu optimieren.</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowCreate(!showCreate)}
|
||||||
|
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||||
|
+ Variante erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Form */}
|
||||||
|
{showCreate && (
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-xl p-4 space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<input value={newVariant.variant_name} onChange={e => setNewVariant({ ...newVariant, variant_name: e.target.value })}
|
||||||
|
placeholder="Name (z.B. Variante B)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
|
||||||
|
<input value={newVariant.variant_key} onChange={e => setNewVariant({ ...newVariant, variant_key: e.target.value })}
|
||||||
|
placeholder="Key (z.B. B)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
|
||||||
|
<input value={newVariant.banner_title} onChange={e => setNewVariant({ ...newVariant, banner_title: e.target.value })}
|
||||||
|
placeholder="Banner-Titel (Override)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
|
||||||
|
<input value={newVariant.primary_color} onChange={e => setNewVariant({ ...newVariant, primary_color: e.target.value })}
|
||||||
|
placeholder="Farbe (z.B. #22c55e)" type="color" className="px-3 py-2 h-10 text-sm border border-gray-200 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="text-sm text-gray-600">Traffic:</label>
|
||||||
|
<input type="range" min={5} max={95} value={newVariant.traffic_percent}
|
||||||
|
onChange={e => setNewVariant({ ...newVariant, traffic_percent: parseInt(e.target.value) })}
|
||||||
|
className="flex-1" />
|
||||||
|
<span className="text-sm font-medium w-12 text-right">{newVariant.traffic_percent}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={handleCreate} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">Erstellen</button>
|
||||||
|
<button onClick={() => setShowCreate(false)} className="px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 rounded-lg">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Variants + Stats */}
|
||||||
|
{variants.length === 0 ? (
|
||||||
|
<div className="text-center py-12 bg-white border border-gray-200 rounded-xl">
|
||||||
|
<p className="text-gray-400">Kein A/B-Test aktiv.</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">Erstellen Sie mindestens 2 Varianten um einen Test zu starten.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Comparison Chart */}
|
||||||
|
{stats.length > 0 && (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||||
|
<h4 className="font-medium text-gray-900 mb-4">Opt-In-Rate Vergleich</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{stats.map(s => (
|
||||||
|
<div key={s.variant_key} className="flex items-center gap-4">
|
||||||
|
<div className="w-24 text-sm text-gray-700 truncate">
|
||||||
|
{s.variant_name}
|
||||||
|
{s.is_control && <span className="ml-1 text-[10px] text-gray-400">(Kontrolle)</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 h-8 bg-gray-100 rounded-lg overflow-hidden relative">
|
||||||
|
<div className={`h-full rounded-lg transition-all ${s.is_winner ? 'bg-green-500' : s.is_control ? 'bg-gray-400' : 'bg-purple-500'}`}
|
||||||
|
style={{ width: `${(s.opt_in_rate / maxRate) * 100}%` }} />
|
||||||
|
<span className="absolute inset-0 flex items-center px-3 text-xs font-medium text-gray-900">
|
||||||
|
{s.opt_in_rate}% ({s.accepted}/{s.total})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{s.is_winner && (
|
||||||
|
<span className="px-2 py-0.5 text-[10px] font-medium bg-green-100 text-green-700 rounded-full">
|
||||||
|
Gewinner ({s.significance}%)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Variant Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{variants.map(v => (
|
||||||
|
<div key={v.id} className={`bg-white border rounded-xl p-4 ${v.is_control ? 'border-gray-300' : 'border-purple-200'}`}>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm text-gray-900">{v.variant_name}</span>
|
||||||
|
<span className="px-1.5 py-0.5 text-[10px] bg-gray-100 text-gray-600 rounded">{v.variant_key}</span>
|
||||||
|
{v.is_control && <span className="px-1.5 py-0.5 text-[10px] bg-blue-50 text-blue-600 rounded">Kontrolle</span>}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => handleDelete(v.id)} className="text-xs text-red-500 hover:text-red-700">Loeschen</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<label className="text-xs text-gray-500">Traffic:</label>
|
||||||
|
<input type="range" min={5} max={95} value={v.traffic_percent}
|
||||||
|
onChange={e => handleTrafficChange(v.id, parseInt(e.target.value))}
|
||||||
|
className="flex-1 h-1" />
|
||||||
|
<span className="text-xs font-medium w-8 text-right">{v.traffic_percent}%</span>
|
||||||
|
</div>
|
||||||
|
{v.banner_title && <div className="text-xs text-gray-500">Titel: {v.banner_title}</div>}
|
||||||
|
{v.primary_color && (
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
<div className="w-3 h-3 rounded-full border" style={{ backgroundColor: v.primary_color }} />
|
||||||
|
<span className="text-xs text-gray-500">{v.primary_color}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface TimeSeriesPoint {
|
||||||
|
period: string
|
||||||
|
given: number
|
||||||
|
updated: number
|
||||||
|
withdrawn: number
|
||||||
|
total: number
|
||||||
|
opt_in_rate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryStats {
|
||||||
|
[key: string]: { count: number; total: number; rate: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceStats {
|
||||||
|
desktop: number
|
||||||
|
mobile: number
|
||||||
|
tablet: number
|
||||||
|
unknown: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverviewStats {
|
||||||
|
period_days: number
|
||||||
|
total_interactions: number
|
||||||
|
consents_given: number
|
||||||
|
consents_updated: number
|
||||||
|
consents_withdrawn: number
|
||||||
|
opt_in_rate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERIODS = [
|
||||||
|
{ value: 7, label: '7 Tage' },
|
||||||
|
{ value: 30, label: '30 Tage' },
|
||||||
|
{ value: 90, label: '90 Tage' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CAT_COLORS: Record<string, string> = {
|
||||||
|
necessary: '#22c55e',
|
||||||
|
statistics: '#eab308',
|
||||||
|
marketing: '#ef4444',
|
||||||
|
functional: '#3b82f6',
|
||||||
|
preferences: '#8b5cf6',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalyticsDashboard({ siteId }: { siteId?: string }) {
|
||||||
|
const [days, setDays] = useState(30)
|
||||||
|
const [overview, setOverview] = useState<OverviewStats | null>(null)
|
||||||
|
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([])
|
||||||
|
const [categories, setCategories] = useState<CategoryStats>({})
|
||||||
|
const [devices, setDevices] = useState<DeviceStats>({ desktop: 0, mobile: 0, tablet: 0, unknown: 0 })
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const sid = siteId || 'preview-test-site'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
const base = `/api/sdk/v1/compliance/banner/analytics/${sid}`
|
||||||
|
Promise.all([
|
||||||
|
fetch(`${base}/overview?days=${days}`).then(r => r.ok ? r.json() : null),
|
||||||
|
fetch(`${base}/time-series?days=${days}&period=daily`).then(r => r.ok ? r.json() : []),
|
||||||
|
fetch(`${base}/categories?days=${days}`).then(r => r.ok ? r.json() : {}),
|
||||||
|
fetch(`${base}/devices?days=${days}`).then(r => r.ok ? r.json() : {}),
|
||||||
|
]).then(([o, ts, cats, devs]) => {
|
||||||
|
setOverview(o)
|
||||||
|
setTimeSeries(ts || [])
|
||||||
|
setCategories(cats || {})
|
||||||
|
setDevices(devs || { desktop: 0, mobile: 0, tablet: 0, unknown: 0 })
|
||||||
|
}).catch(() => {}).finally(() => setLoading(false))
|
||||||
|
}, [sid, days])
|
||||||
|
|
||||||
|
const deviceTotal = devices.desktop + devices.mobile + devices.tablet + devices.unknown
|
||||||
|
|
||||||
|
if (loading) return <div className="text-center py-12 text-gray-400">Lade Analytik...</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Period Selector */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{PERIODS.map(p => (
|
||||||
|
<button key={p.value} onClick={() => setDays(p.value)}
|
||||||
|
className={`px-3 py-1.5 text-xs rounded-full border transition-colors ${
|
||||||
|
days === p.value ? 'bg-purple-100 border-purple-300 text-purple-700' : 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'
|
||||||
|
}`}>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Overview KPIs */}
|
||||||
|
{overview && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div className="text-xs text-gray-500">Opt-In-Rate</div>
|
||||||
|
<div className="text-2xl font-bold text-green-600">{overview.opt_in_rate}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div className="text-xs text-gray-500">Einwilligungen</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900">{overview.consents_given}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div className="text-xs text-gray-500">Aktualisiert</div>
|
||||||
|
<div className="text-2xl font-bold text-blue-600">{overview.consents_updated}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div className="text-xs text-gray-500">Widerrufen</div>
|
||||||
|
<div className="text-2xl font-bold text-red-600">{overview.consents_withdrawn}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Time Series (simple bar visualization) */}
|
||||||
|
{timeSeries.length > 0 && (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Opt-In-Rate im Zeitverlauf</h3>
|
||||||
|
<div className="flex items-end gap-1 h-32">
|
||||||
|
{timeSeries.map((pt, i) => {
|
||||||
|
const height = Math.max(pt.opt_in_rate, 2)
|
||||||
|
const date = new Date(pt.period)
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex-1 flex flex-col items-center gap-1 group relative">
|
||||||
|
<div className="w-full bg-purple-500 rounded-t transition-all hover:bg-purple-600"
|
||||||
|
style={{ height: `${height}%` }}
|
||||||
|
title={`${date.toLocaleDateString('de-DE')}: ${pt.opt_in_rate}% (${pt.total} Interaktionen)`}
|
||||||
|
/>
|
||||||
|
{i % Math.max(1, Math.floor(timeSeries.length / 6)) === 0 && (
|
||||||
|
<span className="text-[8px] text-gray-400">{date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Category Acceptance */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Akzeptanz nach Kategorie</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(categories).map(([cat, stats]) => (
|
||||||
|
<div key={cat}>
|
||||||
|
<div className="flex items-center justify-between text-sm mb-1">
|
||||||
|
<span className="text-gray-700 capitalize">{cat}</span>
|
||||||
|
<span className="font-medium text-gray-900">{stats.rate}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div className="h-full rounded-full transition-all" style={{ width: `${stats.rate}%`, backgroundColor: CAT_COLORS[cat] || '#9ca3af' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{Object.keys(categories).length === 0 && (
|
||||||
|
<p className="text-xs text-gray-400">Noch keine Daten vorhanden</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device Breakdown */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Geraete-Verteilung</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[
|
||||||
|
{ key: 'desktop', label: 'Desktop', color: 'bg-blue-500' },
|
||||||
|
{ key: 'mobile', label: 'Mobile', color: 'bg-green-500' },
|
||||||
|
{ key: 'tablet', label: 'Tablet', color: 'bg-purple-500' },
|
||||||
|
].map(d => {
|
||||||
|
const count = devices[d.key as keyof DeviceStats]
|
||||||
|
const pct = deviceTotal > 0 ? Math.round(count / deviceTotal * 100) : 0
|
||||||
|
return (
|
||||||
|
<div key={d.key}>
|
||||||
|
<div className="flex items-center justify-between text-sm mb-1">
|
||||||
|
<span className="text-gray-700">{d.label}</span>
|
||||||
|
<span className="font-medium text-gray-900">{pct}% ({count})</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full rounded-full ${d.color}`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{deviceTotal === 0 && (
|
||||||
|
<p className="text-xs text-gray-400">Noch keine Geraetedaten vorhanden</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface Vendor {
|
||||||
|
vendor_name: string
|
||||||
|
vendor_url: string | null
|
||||||
|
category_key: string
|
||||||
|
description_de: string | null
|
||||||
|
cookie_names: string[]
|
||||||
|
retention_days: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const CAT_LABELS: Record<string, string> = {
|
||||||
|
necessary: 'Notwendig',
|
||||||
|
functional: 'Funktional',
|
||||||
|
statistics: 'Statistik',
|
||||||
|
marketing: 'Marketing',
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateHTML(vendors: Vendor[]): string {
|
||||||
|
const grouped = vendors.reduce<Record<string, Vendor[]>>((acc, v) => {
|
||||||
|
const key = v.category_key || 'other'
|
||||||
|
if (!acc[key]) acc[key] = []
|
||||||
|
acc[key].push(v)
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
let html = `<div style="font-family:system-ui,sans-serif;font-size:14px;color:#1f2937;">\n`
|
||||||
|
html += `<h3 style="margin:0 0 12px;font-size:16px;">Eingesetzte Dienste und Cookies</h3>\n`
|
||||||
|
|
||||||
|
for (const [catKey, catVendors] of Object.entries(grouped)) {
|
||||||
|
const label = CAT_LABELS[catKey] || catKey
|
||||||
|
html += `<h4 style="margin:16px 0 8px;font-size:14px;color:#6b21a8;">${label}</h4>\n`
|
||||||
|
html += `<table style="width:100%;border-collapse:collapse;margin-bottom:12px;font-size:13px;">\n`
|
||||||
|
html += `<tr style="background:#f9fafb;"><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Anbieter</th><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Zweck</th><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Cookies</th><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Speicherdauer</th></tr>\n`
|
||||||
|
|
||||||
|
for (const v of catVendors) {
|
||||||
|
const name = v.vendor_url
|
||||||
|
? `<a href="${v.vendor_url}" target="_blank" rel="noopener">${v.vendor_name}</a>`
|
||||||
|
: v.vendor_name
|
||||||
|
const cookies = v.cookie_names?.join(', ') || '-'
|
||||||
|
const retention = v.retention_days ? `${v.retention_days} Tage` : '-'
|
||||||
|
html += `<tr><td style="padding:6px 8px;border:1px solid #e5e7eb;">${name}</td><td style="padding:6px 8px;border:1px solid #e5e7eb;">${v.description_de || '-'}</td><td style="padding:6px 8px;border:1px solid #e5e7eb;font-family:monospace;font-size:11px;">${cookies}</td><td style="padding:6px 8px;border:1px solid #e5e7eb;">${retention}</td></tr>\n`
|
||||||
|
}
|
||||||
|
html += `</table>\n`
|
||||||
|
}
|
||||||
|
html += `</div>`
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmbeddableVendorHTML({ siteId }: { siteId?: string }) {
|
||||||
|
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sid = siteId || 'preview-test-site'
|
||||||
|
fetch(`/api/sdk/v1/banner/admin/sites/${sid}/vendors`)
|
||||||
|
.then(r => r.ok ? r.json() : [])
|
||||||
|
.then(data => setVendors(Array.isArray(data) ? data : []))
|
||||||
|
.catch(() => {})
|
||||||
|
}, [siteId])
|
||||||
|
|
||||||
|
const html = generateHTML(vendors)
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
navigator.clipboard.writeText(html)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">Einbettbarer HTML-Code</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Kopieren Sie diesen Code in Ihre Datenschutzerklaerung oder Cookie-Richtlinie.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleCopy}
|
||||||
|
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||||
|
{copied ? 'Kopiert!' : 'HTML kopieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="border border-gray-200 rounded-lg p-4 bg-white">
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Raw HTML */}
|
||||||
|
<details className="group">
|
||||||
|
<summary className="text-xs text-gray-500 cursor-pointer hover:text-gray-700">
|
||||||
|
Quellcode anzeigen
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 p-3 bg-gray-50 border border-gray-200 rounded-lg text-xs text-gray-700 overflow-x-auto max-h-[300px] overflow-y-auto">
|
||||||
|
{html}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface Site {
|
||||||
|
id: string
|
||||||
|
site_id: string
|
||||||
|
site_name: string
|
||||||
|
site_url: string
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SiteSelectorProps {
|
||||||
|
sites: Site[]
|
||||||
|
activeSiteId: string | null
|
||||||
|
onSiteChange: (siteId: string) => void
|
||||||
|
onCreateSite: (data: { site_id: string; site_name: string; site_url: string }) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SiteSelector({ sites, activeSiteId, onSiteChange, onCreateSite }: SiteSelectorProps) {
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [newSite, setNewSite] = useState({ site_id: '', site_name: '', site_url: '' })
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!newSite.site_id || !newSite.site_name) return
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
await onCreateSite(newSite)
|
||||||
|
setNewSite({ site_id: '', site_name: '', site_url: '' })
|
||||||
|
setShowCreate(false)
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-xs font-medium text-gray-500 mb-1">Website / Domain</label>
|
||||||
|
<select value={activeSiteId || ''} onChange={e => onSiteChange(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-1 focus:ring-purple-500 bg-white">
|
||||||
|
{sites.length === 0 && <option value="">Keine Sites konfiguriert</option>}
|
||||||
|
{sites.map(s => (
|
||||||
|
<option key={s.site_id} value={s.site_id}>
|
||||||
|
{s.site_name} ({s.site_url || s.site_id})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowCreate(!showCreate)}
|
||||||
|
className="mt-5 px-3 py-2 text-sm bg-purple-50 text-purple-600 border border-purple-200 rounded-lg hover:bg-purple-100">
|
||||||
|
+ Neue Seite
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-100 grid grid-cols-3 gap-3">
|
||||||
|
<input value={newSite.site_id} onChange={e => setNewSite({ ...newSite, site_id: e.target.value })}
|
||||||
|
placeholder="Site-ID (z.B. main-website)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
|
||||||
|
<input value={newSite.site_name} onChange={e => setNewSite({ ...newSite, site_name: e.target.value })}
|
||||||
|
placeholder="Name (z.B. Hauptwebsite)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input value={newSite.site_url} onChange={e => setNewSite({ ...newSite, site_url: e.target.value })}
|
||||||
|
placeholder="URL (z.B. https://example.com)" className="flex-1 px-3 py-2 text-sm border border-gray-200 rounded-lg" />
|
||||||
|
<button onClick={handleCreate} disabled={creating || !newSite.site_id}
|
||||||
|
className="px-3 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||||
|
{creating ? '...' : 'Anlegen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface IABPurpose {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
name_de: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const API = '/api/sdk/v1/compliance/tcf'
|
||||||
|
|
||||||
|
export function TCFSettings({ siteId, tcfEnabled, onToggle }: {
|
||||||
|
siteId?: string
|
||||||
|
tcfEnabled: boolean
|
||||||
|
onToggle: (enabled: boolean) => void
|
||||||
|
}) {
|
||||||
|
const [purposes, setPurposes] = useState<IABPurpose[]>([])
|
||||||
|
const [categoryMap, setCategoryMap] = useState<Record<string, number[]>>({})
|
||||||
|
const [testResult, setTestResult] = useState<string | null>(null)
|
||||||
|
const [testing, setTesting] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
fetch(`${API}/purposes`).then(r => r.ok ? r.json() : []),
|
||||||
|
fetch(`${API}/category-mapping`).then(r => r.ok ? r.json() : {}),
|
||||||
|
]).then(([p, m]) => {
|
||||||
|
setPurposes(p)
|
||||||
|
setCategoryMap(m)
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleTestEncode = async () => {
|
||||||
|
setTesting(true)
|
||||||
|
setTestResult(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/encode-categories`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ categories: ['necessary', 'statistics', 'marketing'] }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setTestResult(`TC String: ${data.tc_string}\nPurposes: ${data.purposes_consented.join(', ')}`)
|
||||||
|
}
|
||||||
|
} catch { setTestResult('Fehler beim Generieren') }
|
||||||
|
setTesting(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Enable/Disable */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">IAB TCF 2.2</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Transparency & Consent Framework — Standardisierte Einwilligungssignale fuer programmatische Werbung
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" checked={tcfEnabled} onChange={e => onToggle(e.target.checked)}
|
||||||
|
className="w-5 h-5 text-purple-600 rounded" />
|
||||||
|
<span className="text-sm font-medium">{tcfEnabled ? 'Aktiv' : 'Inaktiv'}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{!tcfEnabled && (
|
||||||
|
<p className="mt-3 text-xs text-amber-600 bg-amber-50 p-3 rounded-lg">
|
||||||
|
TCF ist nur erforderlich wenn Sie programmatische Werbung (AdTech) einsetzen.
|
||||||
|
Fuer die meisten Websites reicht das Standard-Cookie-Banner.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tcfEnabled && (
|
||||||
|
<>
|
||||||
|
{/* IAB Purposes */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-3">12 IAB-Zwecke (Purposes)</h4>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
|
Diese Zwecke werden automatisch aus Ihren Cookie-Kategorien abgeleitet.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
|
{purposes.map(p => {
|
||||||
|
const activeCats = Object.entries(categoryMap)
|
||||||
|
.filter(([, pids]) => pids.includes(p.id))
|
||||||
|
.map(([cat]) => cat)
|
||||||
|
return (
|
||||||
|
<div key={p.id} className={`flex items-start gap-2 p-2 rounded-lg text-xs ${activeCats.length > 0 ? 'bg-green-50' : 'bg-gray-50'}`}>
|
||||||
|
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold flex-shrink-0 ${activeCats.length > 0 ? 'bg-green-500 text-white' : 'bg-gray-300 text-white'}`}>
|
||||||
|
{p.id}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-700">{p.name_de}</div>
|
||||||
|
{activeCats.length > 0 && (
|
||||||
|
<div className="text-gray-400 mt-0.5">via: {activeCats.join(', ')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Mapping */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-3">Kategorie → Purpose Zuordnung</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(categoryMap).map(([cat, pids]) => (
|
||||||
|
<div key={cat} className="flex items-center gap-3">
|
||||||
|
<span className="text-sm font-medium text-gray-700 w-24 capitalize">{cat}</span>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{pids.length === 0 ? (
|
||||||
|
<span className="text-xs text-gray-400">Keine Einwilligung noetig</span>
|
||||||
|
) : (
|
||||||
|
pids.map(pid => (
|
||||||
|
<span key={pid} className="px-2 py-0.5 text-[10px] bg-purple-100 text-purple-700 rounded-full">
|
||||||
|
Purpose {pid}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TC String Test */}
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h4 className="font-semibold text-gray-900 mb-3">TC String testen</h4>
|
||||||
|
<button onClick={handleTestEncode} disabled={testing}
|
||||||
|
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||||
|
{testing ? 'Generiere...' : 'Test TC String generieren'}
|
||||||
|
</button>
|
||||||
|
{testResult && (
|
||||||
|
<pre className="mt-3 p-3 bg-gray-50 border border-gray-200 rounded-lg text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap">
|
||||||
|
{testResult}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-400 mt-2">
|
||||||
|
Simuliert: necessary + statistics + marketing → generiert base64url-codierten TC String
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CMP Registration Info */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||||
|
<h4 className="font-semibold text-blue-800 text-sm">CMP-Registrierung</h4>
|
||||||
|
<p className="text-xs text-blue-700 mt-1">
|
||||||
|
Fuer den produktiven Einsatz muss Ihr CMP bei der IAB Europe registriert werden.
|
||||||
|
Sie erhalten eine eindeutige CMP-ID die im TC String codiert wird.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-blue-600 mt-2">
|
||||||
|
Registrierung: <a href="https://iabeurope.eu/tcf-for-cmps/" target="_blank" rel="noopener"
|
||||||
|
className="underline">iabeurope.eu/tcf-for-cmps</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useSDK } from '@/lib/sdk'
|
||||||
|
|
||||||
|
interface Vendor {
|
||||||
|
id: string
|
||||||
|
vendor_name: string
|
||||||
|
vendor_url: string | null
|
||||||
|
category_key: string
|
||||||
|
description_de: string | null
|
||||||
|
description_en: string | null
|
||||||
|
cookie_names: string[]
|
||||||
|
retention_days: number | null
|
||||||
|
is_active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
|
necessary: { label: 'Notwendig', color: 'bg-green-100 text-green-700' },
|
||||||
|
functional: { label: 'Funktional', color: 'bg-blue-100 text-blue-700' },
|
||||||
|
statistics: { label: 'Statistik', color: 'bg-yellow-100 text-yellow-700' },
|
||||||
|
marketing: { label: 'Marketing', color: 'bg-red-100 text-red-700' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VendorTable({ siteId }: { siteId?: string }) {
|
||||||
|
const { projectId } = useSDK()
|
||||||
|
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sid = siteId || 'preview-test-site'
|
||||||
|
fetch(`/api/sdk/v1/banner/admin/sites/${sid}/vendors`)
|
||||||
|
.then(r => r.ok ? r.json() : [])
|
||||||
|
.then(data => setVendors(Array.isArray(data) ? data : []))
|
||||||
|
.catch(() => setVendors([]))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [siteId])
|
||||||
|
|
||||||
|
// Group by category
|
||||||
|
const grouped = vendors.reduce<Record<string, Vendor[]>>((acc, v) => {
|
||||||
|
const key = v.category_key || 'other'
|
||||||
|
if (!acc[key]) acc[key] = []
|
||||||
|
acc[key].push(v)
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-center py-12 text-gray-400">Lade Verarbeiter...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vendors.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-400 mb-3">Keine Verarbeiter konfiguriert.</p>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Nutzen Sie den Website-Scanner oder fuegen Sie Verarbeiter manuell hinzu.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">Verarbeiter-Uebersicht</h3>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{vendors.length} Dienste in {Object.keys(grouped).length} Kategorien</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.entries(grouped).map(([catKey, catVendors]) => {
|
||||||
|
const catInfo = CATEGORY_LABELS[catKey] || { label: catKey, color: 'bg-gray-100 text-gray-700' }
|
||||||
|
return (
|
||||||
|
<div key={catKey} className="border border-gray-200 rounded-xl overflow-hidden">
|
||||||
|
<div className="bg-gray-50 px-4 py-3 flex items-center gap-3">
|
||||||
|
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${catInfo.color}`}>
|
||||||
|
{catInfo.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">{catVendors.length} Dienste</span>
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-100 text-left text-xs text-gray-500">
|
||||||
|
<th className="px-4 py-2 font-medium">Anbieter</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Zweck</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Cookies</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Aufbewahrung</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Datenschutz</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-50">
|
||||||
|
{catVendors.map(v => (
|
||||||
|
<tr key={v.id} className="hover:bg-gray-50/50">
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<button onClick={() => setExpandedId(expandedId === v.id ? null : v.id)}
|
||||||
|
className="font-medium text-gray-900 hover:text-purple-600 text-left">
|
||||||
|
{v.vendor_name}
|
||||||
|
</button>
|
||||||
|
{expandedId === v.id && v.cookie_names?.length > 0 && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{v.cookie_names.map(c => (
|
||||||
|
<span key={c} className="px-1.5 py-0.5 text-[10px] bg-gray-100 text-gray-600 rounded font-mono">
|
||||||
|
{c}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-xs text-gray-600 max-w-[200px] truncate">
|
||||||
|
{v.description_de || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-xs text-gray-500">
|
||||||
|
{v.cookie_names?.length || 0}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-xs text-gray-500">
|
||||||
|
{v.retention_days ? `${v.retention_days} Tage` : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
{v.vendor_url ? (
|
||||||
|
<a href={v.vendor_url} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="text-xs text-purple-600 hover:underline">
|
||||||
|
Link
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -96,13 +96,39 @@ const defaultBannerTexts: BannerTexts = {
|
|||||||
privacyLink: '/datenschutz',
|
privacyLink: '/datenschutz',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BannerSite {
|
||||||
|
id: string
|
||||||
|
site_id: string
|
||||||
|
site_name: string
|
||||||
|
site_url: string
|
||||||
|
is_active: boolean
|
||||||
|
tcf_enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export function useCookieBanner() {
|
export function useCookieBanner() {
|
||||||
const [categories, setCategories] = useState<CookieCategory[]>([])
|
const [categories, setCategories] = useState<CookieCategory[]>([])
|
||||||
const [config, setConfig] = useState<BannerConfig>(defaultConfig)
|
const [config, setConfig] = useState<BannerConfig>(defaultConfig)
|
||||||
const [bannerTexts, setBannerTexts] = useState<BannerTexts>(defaultBannerTexts)
|
const [bannerTexts, setBannerTexts] = useState<BannerTexts>(defaultBannerTexts)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [exportToast, setExportToast] = useState<string | null>(null)
|
const [exportToast, setExportToast] = useState<string | null>(null)
|
||||||
|
const [sites, setSites] = useState<BannerSite[]>([])
|
||||||
|
const [activeSiteId, setActiveSiteId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Load sites list
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetch('/api/sdk/v1/banner/admin/sites')
|
||||||
|
.then(r => r.ok ? r.json() : [])
|
||||||
|
.then(data => {
|
||||||
|
const siteList = Array.isArray(data) ? data : []
|
||||||
|
setSites(siteList)
|
||||||
|
if (siteList.length > 0 && !activeSiteId) {
|
||||||
|
setActiveSiteId(siteList[0].site_id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load config for active site
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -125,7 +151,20 @@ export function useCookieBanner() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadConfig()
|
loadConfig()
|
||||||
}, [])
|
}, [activeSiteId])
|
||||||
|
|
||||||
|
const createSite = async (data: { site_id: string; site_name: string; site_url: string }) => {
|
||||||
|
const res = await fetch('/api/sdk/v1/banner/admin/sites', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const newSite = await res.json()
|
||||||
|
setSites(prev => [...prev, newSite])
|
||||||
|
setActiveSiteId(newSite.site_id || data.site_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleCategoryToggle = async (categoryId: string, enabled: boolean) => {
|
const handleCategoryToggle = async (categoryId: string, enabled: boolean) => {
|
||||||
setCategories(prev =>
|
setCategories(prev =>
|
||||||
@@ -180,5 +219,6 @@ export function useCookieBanner() {
|
|||||||
categories, config, bannerTexts, isSaving, exportToast,
|
categories, config, bannerTexts, isSaving, exportToast,
|
||||||
setConfig, setBannerTexts,
|
setConfig, setBannerTexts,
|
||||||
handleCategoryToggle, handleExportCode, handleSaveConfig,
|
handleCategoryToggle, handleExportCode, handleSaveConfig,
|
||||||
|
sites, activeSiteId, setActiveSiteId, createSite,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useSDK } from '@/lib/sdk'
|
import { useSDK } from '@/lib/sdk'
|
||||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||||
import { useCookieBanner } from './_hooks/useCookieBanner'
|
import { useCookieBanner } from './_hooks/useCookieBanner'
|
||||||
import { BannerPreview } from './_components/BannerPreview'
|
import { BannerPreview } from './_components/BannerPreview'
|
||||||
import { CategoryCard } from './_components/CategoryCard'
|
import { CategoryCard } from './_components/CategoryCard'
|
||||||
|
import { VendorTable } from './_components/VendorTable'
|
||||||
|
import { EmbeddableVendorHTML } from './_components/EmbeddableVendorHTML'
|
||||||
|
import { SiteSelector } from './_components/SiteSelector'
|
||||||
|
import { AnalyticsDashboard } from './_components/AnalyticsDashboard'
|
||||||
|
import { ABTestPanel } from './_components/ABTestPanel'
|
||||||
|
import { TCFSettings } from './_components/TCFSettings'
|
||||||
|
|
||||||
|
type BannerTab = 'config' | 'vendors' | 'embed' | 'analytics' | 'abtest' | 'tcf'
|
||||||
|
|
||||||
export default function CookieBannerPage() {
|
export default function CookieBannerPage() {
|
||||||
const { state } = useSDK()
|
const { state } = useSDK()
|
||||||
|
const [activeTab, setActiveTab] = useState<BannerTab>('config')
|
||||||
const {
|
const {
|
||||||
categories, config, bannerTexts, isSaving, exportToast,
|
categories, config, bannerTexts, isSaving, exportToast,
|
||||||
setConfig, setBannerTexts,
|
setConfig, setBannerTexts,
|
||||||
handleCategoryToggle, handleExportCode, handleSaveConfig,
|
handleCategoryToggle, handleExportCode, handleSaveConfig,
|
||||||
|
sites, activeSiteId, setActiveSiteId, createSite,
|
||||||
} = useCookieBanner()
|
} = useCookieBanner()
|
||||||
|
|
||||||
const totalCookies = categories.reduce((sum, cat) => sum + cat.cookies.length, 0)
|
const totalCookies = categories.reduce((sum, cat) => sum + cat.cookies.length, 0)
|
||||||
@@ -57,6 +67,58 @@ export default function CookieBannerPage() {
|
|||||||
</div>
|
</div>
|
||||||
</StepHeader>
|
</StepHeader>
|
||||||
|
|
||||||
|
{/* Site Selector */}
|
||||||
|
{sites.length > 0 && (
|
||||||
|
<SiteSelector sites={sites} activeSiteId={activeSiteId} onSiteChange={setActiveSiteId} onCreateSite={createSite} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b border-gray-200">
|
||||||
|
{([
|
||||||
|
{ id: 'config' as const, label: 'Konfiguration' },
|
||||||
|
{ id: 'vendors' as const, label: 'Verarbeiter' },
|
||||||
|
{ id: 'embed' as const, label: 'Einbettung' },
|
||||||
|
{ id: 'analytics' as const, label: 'Analytik' },
|
||||||
|
{ id: 'abtest' as const, label: 'A/B-Test' },
|
||||||
|
{ id: 'tcf' as const, label: 'TCF/IAB' },
|
||||||
|
]).map(tab => (
|
||||||
|
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||||||
|
activeTab === tab.id ? 'text-purple-600 border-purple-600' : 'text-gray-500 border-transparent hover:text-gray-700'
|
||||||
|
}`}>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab: Verarbeiter */}
|
||||||
|
{activeTab === 'vendors' && <VendorTable siteId={activeSiteId || undefined} />}
|
||||||
|
|
||||||
|
{/* Tab: Einbettung */}
|
||||||
|
{activeTab === 'embed' && <EmbeddableVendorHTML siteId={activeSiteId || undefined} />}
|
||||||
|
|
||||||
|
{/* Tab: Analytik */}
|
||||||
|
{activeTab === 'analytics' && <AnalyticsDashboard siteId={activeSiteId || undefined} />}
|
||||||
|
|
||||||
|
{/* Tab: A/B-Test */}
|
||||||
|
{activeTab === 'abtest' && <ABTestPanel siteConfigId={activeSiteId || undefined} />}
|
||||||
|
|
||||||
|
{/* Tab: TCF/IAB */}
|
||||||
|
{activeTab === 'tcf' && (
|
||||||
|
<TCFSettings siteId={activeSiteId || undefined} tcfEnabled={sites.find(s => s.site_id === activeSiteId)?.tcf_enabled ?? false}
|
||||||
|
onToggle={(enabled) => {
|
||||||
|
if (activeSiteId) {
|
||||||
|
fetch(`/api/sdk/v1/banner/admin/sites/${activeSiteId}`, {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tcf_enabled: enabled }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab: Konfiguration */}
|
||||||
|
{activeTab !== 'config' ? null : (<>
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
@@ -207,6 +269,7 @@ export default function CookieBannerPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import { LegalTemplateResult } from '@/lib/sdk/types'
|
import { LegalTemplateResult } from '@/lib/sdk/types'
|
||||||
import { RuleEngineResult } from '../ruleEngine'
|
import { RuleEngineResult } from '../ruleEngine'
|
||||||
|
import ReviewAssignmentPanel from './ReviewAssignmentPanel'
|
||||||
|
|
||||||
interface GeneratorPreviewTabProps {
|
interface GeneratorPreviewTabProps {
|
||||||
template: LegalTemplateResult
|
template: LegalTemplateResult
|
||||||
@@ -10,8 +12,76 @@ interface GeneratorPreviewTabProps {
|
|||||||
missing: string[]
|
missing: string[]
|
||||||
onCopy: () => void
|
onCopy: () => void
|
||||||
onExportMarkdown: () => void
|
onExportMarkdown: () => void
|
||||||
|
onSaveToWorkflow?: () => void
|
||||||
|
saveStatus?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Lightweight Markdown → HTML (no dependency needed)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function markdownToHtml(md: string): string {
|
||||||
|
let html = md
|
||||||
|
// Escape HTML entities first
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
|
||||||
|
// Headings
|
||||||
|
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>')
|
||||||
|
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||||
|
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||||
|
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||||
|
|
||||||
|
// Horizontal rules
|
||||||
|
html = html.replace(/^---$/gm, '<hr/>')
|
||||||
|
|
||||||
|
// Bold + Italic
|
||||||
|
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
|
||||||
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||||
|
|
||||||
|
// Links
|
||||||
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-purple-600 underline">$1</a>')
|
||||||
|
|
||||||
|
// Tables (simple)
|
||||||
|
html = html.replace(/^\|(.+)\|$/gm, (match) => {
|
||||||
|
const cells = match.split('|').filter(c => c.trim())
|
||||||
|
const isHeader = cells.every(c => /^[\s-:]+$/.test(c))
|
||||||
|
if (isHeader) return '<!-- separator -->'
|
||||||
|
const tag = 'td'
|
||||||
|
return '<tr>' + cells.map(c => `<${tag}>${c.trim()}</${tag}>`).join('') + '</tr>'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wrap consecutive table rows
|
||||||
|
html = html.replace(/((?:<tr>.*<\/tr>\n?<!-- separator -->\n?)?(?:<tr>.*<\/tr>\n?)+)/g, (block) => {
|
||||||
|
const rows = block.split('\n').filter(r => r.startsWith('<tr>'))
|
||||||
|
if (rows.length === 0) return block
|
||||||
|
const headerRow = rows[0].replace(/<td>/g, '<th>').replace(/<\/td>/g, '</th>')
|
||||||
|
const bodyRows = rows.slice(1).join('\n')
|
||||||
|
return `<table><thead>${headerRow}</thead><tbody>${bodyRows}</tbody></table>`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove separator comments
|
||||||
|
html = html.replace(/<!-- separator -->\n?/g, '')
|
||||||
|
|
||||||
|
// Unordered lists
|
||||||
|
html = html.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||||
|
html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>')
|
||||||
|
|
||||||
|
// Paragraphs (lines that aren't already HTML)
|
||||||
|
html = html.replace(/^(?!<[a-z/]|$)(.+)$/gm, '<p>$1</p>')
|
||||||
|
|
||||||
|
// Clean up empty paragraphs
|
||||||
|
html = html.replace(/<p>\s*<\/p>/g, '')
|
||||||
|
|
||||||
|
return html
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
export default function GeneratorPreviewTab({
|
export default function GeneratorPreviewTab({
|
||||||
template,
|
template,
|
||||||
ruleResult,
|
ruleResult,
|
||||||
@@ -19,13 +89,20 @@ export default function GeneratorPreviewTab({
|
|||||||
missing,
|
missing,
|
||||||
onCopy,
|
onCopy,
|
||||||
onExportMarkdown,
|
onExportMarkdown,
|
||||||
|
onSaveToWorkflow,
|
||||||
|
saveStatus,
|
||||||
}: GeneratorPreviewTabProps) {
|
}: GeneratorPreviewTabProps) {
|
||||||
|
const [viewMode, setViewMode] = useState<'preview' | 'markdown'>('preview')
|
||||||
|
|
||||||
|
const htmlContent = markdownToHtml(renderedContent)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* Violations */}
|
||||||
{ruleResult && ruleResult.violations.length > 0 && (
|
{ruleResult && ruleResult.violations.length > 0 && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||||
<p className="text-sm font-semibold text-red-700 mb-2">
|
<p className="text-sm font-semibold text-red-700 mb-2">
|
||||||
🔴 {ruleResult.violations.length} Fehler
|
{ruleResult.violations.length} Fehler
|
||||||
</p>
|
</p>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{ruleResult.violations.map((v) => (
|
{ruleResult.violations.map((v) => (
|
||||||
@@ -36,6 +113,8 @@ export default function GeneratorPreviewTab({
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Warnings */}
|
||||||
{ruleResult && ruleResult.warnings.filter((w) => w.id !== 'WARN_LEGAL_REVIEW').length > 0 && (
|
{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">
|
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4">
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
@@ -43,69 +122,156 @@ export default function GeneratorPreviewTab({
|
|||||||
.filter((w) => w.id !== 'WARN_LEGAL_REVIEW')
|
.filter((w) => w.id !== 'WARN_LEGAL_REVIEW')
|
||||||
.map((w) => (
|
.map((w) => (
|
||||||
<li key={w.id} className="text-xs text-yellow-700">
|
<li key={w.id} className="text-xs text-yellow-700">
|
||||||
🟡 <span className="font-mono font-medium">[{w.id}]</span> {w.message}
|
<span className="font-mono font-medium">[{w.id}]</span> {w.message}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Legal notice */}
|
||||||
{ruleResult && (
|
{ruleResult && (
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3">
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3">
|
||||||
<p className="text-xs text-blue-700">
|
<p className="text-xs text-blue-700">
|
||||||
ℹ️ Rechtlicher Hinweis: Diese Vorlage ist MIT-lizenziert. Vor Produktionseinsatz
|
Rechtlicher Hinweis: Diese Vorlage ist MIT-lizenziert. Vor Produktionseinsatz
|
||||||
wird eine rechtliche Überprüfung dringend empfohlen.
|
wird eine rechtliche Ueberpruefung dringend empfohlen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{ruleResult && ruleResult.appliedDefaults.length > 0 && (
|
|
||||||
<p className="text-xs text-gray-400">
|
|
||||||
Defaults angewendet: {ruleResult.appliedDefaults.join(', ')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
<span className="text-sm text-gray-600">
|
<div className="flex gap-1 bg-gray-100 rounded-lg p-0.5">
|
||||||
{missing.length > 0 && (
|
|
||||||
<span className="text-orange-600">
|
|
||||||
⚠ {missing.length} Platzhalter noch nicht ausgefüllt
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
<button
|
||||||
onClick={onCopy}
|
onClick={() => setViewMode('preview')}
|
||||||
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"
|
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
viewMode === 'preview' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
Vorschau
|
||||||
<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>
|
||||||
<button
|
<button
|
||||||
onClick={onExportMarkdown}
|
onClick={() => setViewMode('markdown')}
|
||||||
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"
|
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||||
|
viewMode === 'markdown' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
Markdown
|
||||||
<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" />
|
</button>
|
||||||
</svg>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{missing.length > 0 && (
|
||||||
|
<span className="text-xs text-orange-600">
|
||||||
|
{missing.length} Platzhalter offen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button onClick={onCopy} className="px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600">
|
||||||
|
Kopieren
|
||||||
|
</button>
|
||||||
|
<button onClick={onExportMarkdown} className="px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600">
|
||||||
Markdown
|
Markdown
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.print()}
|
onClick={() => {
|
||||||
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"
|
const printWindow = window.open('', '_blank')
|
||||||
|
if (!printWindow) return
|
||||||
|
printWindow.document.write(`<!DOCTYPE html><html><head><title>${template.documentTitle || 'Dokument'}</title><style>
|
||||||
|
@page { size: A4; margin: 25mm 20mm; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 11pt; line-height: 1.6; color: #1a202c; max-width: 170mm; margin: 0 auto; }
|
||||||
|
h1 { font-size: 18pt; color: #5b21b6; margin: 24pt 0 8pt; border-bottom: 2px solid #7c3aed; padding-bottom: 4pt; }
|
||||||
|
h2 { font-size: 14pt; color: #1f2937; margin: 18pt 0 6pt; }
|
||||||
|
h3 { font-size: 12pt; color: #374151; margin: 12pt 0 4pt; }
|
||||||
|
h4 { font-size: 11pt; color: #4b5563; margin: 10pt 0 4pt; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin: 8pt 0; font-size: 10pt; }
|
||||||
|
th { background: #f5f3ff; color: #5b21b6; font-weight: 600; text-align: left; padding: 6pt 8pt; border: 1px solid #e5e7eb; }
|
||||||
|
td { padding: 5pt 8pt; border: 1px solid #e5e7eb; vertical-align: top; }
|
||||||
|
ul { padding-left: 20pt; }
|
||||||
|
li { margin: 2pt 0; }
|
||||||
|
hr { border: none; border-top: 1px solid #e5e7eb; margin: 16pt 0; }
|
||||||
|
a { color: #7c3aed; }
|
||||||
|
p { margin: 4pt 0; }
|
||||||
|
strong { font-weight: 600; }
|
||||||
|
</style></head><body>${htmlContent}</body></html>`)
|
||||||
|
printWindow.document.close()
|
||||||
|
printWindow.print()
|
||||||
|
}}
|
||||||
|
className="px-4 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||||||
>
|
>
|
||||||
<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
|
PDF drucken
|
||||||
</button>
|
</button>
|
||||||
|
{onSaveToWorkflow && (
|
||||||
|
<button
|
||||||
|
onClick={onSaveToWorkflow}
|
||||||
|
disabled={saveStatus === 'saving'}
|
||||||
|
className={`px-4 py-1.5 text-xs rounded-lg transition-colors ${
|
||||||
|
saveStatus === 'saved' ? 'bg-green-600 text-white' :
|
||||||
|
saveStatus === 'error' ? 'bg-red-600 text-white' :
|
||||||
|
'bg-indigo-600 text-white hover:bg-indigo-700'
|
||||||
|
} disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{saveStatus === 'saving' ? 'Speichern...' :
|
||||||
|
saveStatus === 'saved' ? 'Gespeichert!' :
|
||||||
|
saveStatus === 'error' ? 'Fehler' :
|
||||||
|
'Als Version speichern'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
{/* Content */}
|
||||||
{renderedContent}
|
{viewMode === 'markdown' ? (
|
||||||
</pre>
|
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6 max-h-[800px] overflow-y-auto">
|
||||||
</div>
|
<pre className="text-sm text-gray-800 whitespace-pre-wrap leading-relaxed font-mono">
|
||||||
|
{renderedContent}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-gray-100 rounded-xl p-8 flex justify-center overflow-y-auto max-h-[85vh]">
|
||||||
|
{/* A4 Page */}
|
||||||
|
<div
|
||||||
|
className="bg-white shadow-lg border border-gray-300"
|
||||||
|
style={{
|
||||||
|
width: '210mm',
|
||||||
|
minHeight: '297mm',
|
||||||
|
padding: '25mm 20mm',
|
||||||
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
|
fontSize: '11pt',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
color: '#1a202c',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
.a4-content h1 { font-size: 18pt; color: #5b21b6; margin: 24pt 0 8pt; border-bottom: 2px solid #7c3aed; padding-bottom: 4pt; }
|
||||||
|
.a4-content h2 { font-size: 14pt; color: #1f2937; margin: 18pt 0 6pt; }
|
||||||
|
.a4-content h3 { font-size: 12pt; color: #374151; margin: 12pt 0 4pt; }
|
||||||
|
.a4-content h4 { font-size: 11pt; color: #4b5563; margin: 10pt 0 4pt; }
|
||||||
|
.a4-content table { width: 100%; border-collapse: collapse; margin: 8pt 0; font-size: 10pt; }
|
||||||
|
.a4-content th { background: #f5f3ff; color: #5b21b6; font-weight: 600; text-align: left; padding: 6pt 8pt; border: 1px solid #e5e7eb; }
|
||||||
|
.a4-content td { padding: 5pt 8pt; border: 1px solid #e5e7eb; vertical-align: top; }
|
||||||
|
.a4-content ul { padding-left: 20pt; margin: 4pt 0; }
|
||||||
|
.a4-content li { margin: 2pt 0; }
|
||||||
|
.a4-content hr { border: none; border-top: 1px solid #e5e7eb; margin: 16pt 0; }
|
||||||
|
.a4-content a { color: #7c3aed; text-decoration: underline; }
|
||||||
|
.a4-content p { margin: 4pt 0; }
|
||||||
|
.a4-content strong { font-weight: 600; }
|
||||||
|
`}</style>
|
||||||
|
<div
|
||||||
|
className="a4-content"
|
||||||
|
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Review Assignment */}
|
||||||
|
<ReviewAssignmentPanel
|
||||||
|
documentType={template.templateType || ''}
|
||||||
|
documentTitle={template.documentTitle || 'Dokument'}
|
||||||
|
documentContent={renderedContent}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Attribution */}
|
||||||
{template.attributionRequired && template.attributionText && (
|
{template.attributionRequired && template.attributionText && (
|
||||||
<div className="text-xs text-orange-600 bg-orange-50 p-3 rounded-lg border border-orange-200">
|
<div className="text-xs text-orange-600 bg-orange-50 p-3 rounded-lg border border-orange-200">
|
||||||
<strong>Attribution erforderlich:</strong> {template.attributionText}
|
<strong>Attribution erforderlich:</strong> {template.attributionText}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function GeneratorSection({
|
|||||||
const [activeTab, setActiveTab] = useState<'placeholders' | 'preview'>('placeholders')
|
const [activeTab, setActiveTab] = useState<'placeholders' | 'preview'>('placeholders')
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['PROVIDER', 'LEGAL']))
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['PROVIDER', 'LEGAL']))
|
||||||
|
|
||||||
const placeholders = template.placeholders || []
|
const placeholders = Array.isArray(template.placeholders) ? template.placeholders : []
|
||||||
const relevantSections = useMemo(() => getRelevantSections(placeholders), [placeholders])
|
const relevantSections = useMemo(() => getRelevantSections(placeholders), [placeholders])
|
||||||
const uncovered = useMemo(() => getUncoveredPlaceholders(placeholders, context), [placeholders, context])
|
const uncovered = useMemo(() => getUncoveredPlaceholders(placeholders, context), [placeholders, context])
|
||||||
const missing = useMemo(() => getMissingRequired(placeholders, context), [placeholders, context])
|
const missing = useMemo(() => getMissingRequired(placeholders, context), [placeholders, context])
|
||||||
@@ -101,6 +101,45 @@ export default function GeneratorSection({
|
|||||||
|
|
||||||
const handleCopy = () => navigator.clipboard.writeText(renderedContent)
|
const handleCopy = () => navigator.clipboard.writeText(renderedContent)
|
||||||
|
|
||||||
|
const [saveStatus, setSaveStatus] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleSaveToWorkflow = async () => {
|
||||||
|
setSaveStatus('saving')
|
||||||
|
try {
|
||||||
|
// 1. Create or find document
|
||||||
|
const docRes = await fetch('/api/sdk/v1/compliance/legal-documents/documents', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
type: template.templateType || 'custom',
|
||||||
|
name: template.documentTitle || 'Dokument',
|
||||||
|
description: `Generiert aus Template: ${template.templateType}`,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!docRes.ok) throw new Error('Dokument konnte nicht erstellt werden')
|
||||||
|
const doc = await docRes.json()
|
||||||
|
|
||||||
|
// 2. Create version
|
||||||
|
const verRes = await fetch('/api/sdk/v1/compliance/legal-documents/versions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
document_id: doc.id,
|
||||||
|
title: template.documentTitle || 'Dokument',
|
||||||
|
content: renderedContent,
|
||||||
|
language: template.language || 'de',
|
||||||
|
version: '1.0',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!verRes.ok) throw new Error('Version konnte nicht erstellt werden')
|
||||||
|
setSaveStatus('saved')
|
||||||
|
setTimeout(() => setSaveStatus(null), 3000)
|
||||||
|
} catch (e) {
|
||||||
|
setSaveStatus('error')
|
||||||
|
setTimeout(() => setSaveStatus(null), 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleExportMarkdown = () => {
|
const handleExportMarkdown = () => {
|
||||||
const blob = new Blob([renderedContent], { type: 'text/markdown' })
|
const blob = new Blob([renderedContent], { type: 'text/markdown' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
@@ -160,6 +199,33 @@ export default function GeneratorSection({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
// Load example data for current template type
|
||||||
|
const templateType = template.templateType || ''
|
||||||
|
const lang = template.language || 'de'
|
||||||
|
const exampleFile = `/sdk/document-generator/examples/${templateType}_${lang}.json`
|
||||||
|
fetch(exampleFile)
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(data => {
|
||||||
|
if (!data?.context) return
|
||||||
|
const ctx = data.context
|
||||||
|
for (const [section, fields] of Object.entries(ctx)) {
|
||||||
|
if (typeof fields === 'object' && fields) {
|
||||||
|
for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
|
||||||
|
onContextChange(section as keyof TemplateContext, key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {/* no example available */})
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 text-xs bg-blue-50 text-blue-600 border border-blue-200 rounded-lg hover:bg-blue-100 transition-colors"
|
||||||
|
>
|
||||||
|
Beispieldaten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors shrink-0" aria-label="Schließen">
|
<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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
@@ -223,6 +289,8 @@ export default function GeneratorSection({
|
|||||||
missing={missing}
|
missing={missing}
|
||||||
onCopy={handleCopy}
|
onCopy={handleCopy}
|
||||||
onExportMarkdown={handleExportMarkdown}
|
onExportMarkdown={handleExportMarkdown}
|
||||||
|
onSaveToWorkflow={handleSaveToWorkflow}
|
||||||
|
saveStatus={saveStatus}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useSDK } from '@/lib/sdk'
|
||||||
|
import { evaluateTemplateRecommendations, type TemplateRecommendation } from '../templateRecommendations'
|
||||||
|
import { getProfileLabel } from '../scopeDefaults'
|
||||||
|
import type { LegalTemplateResult } from '@/lib/sdk/types'
|
||||||
|
import type { ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types/core-levels'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
allTemplates: LegalTemplateResult[]
|
||||||
|
onUseTemplate: (t: LegalTemplateResult) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecommendedDocuments({ allTemplates, onUseTemplate }: Props) {
|
||||||
|
const { state } = useSDK()
|
||||||
|
const [showOptional, setShowOptional] = useState(false)
|
||||||
|
|
||||||
|
const level = state?.complianceScope?.determinedLevel as ComplianceDepthLevel | undefined
|
||||||
|
const scopeAnswers = state?.complianceScope?.answers || []
|
||||||
|
|
||||||
|
const recommendations = useMemo(() => {
|
||||||
|
if (!level) return null
|
||||||
|
return evaluateTemplateRecommendations(
|
||||||
|
scopeAnswers,
|
||||||
|
level,
|
||||||
|
(state?.companyProfile as Record<string, unknown>) || {},
|
||||||
|
)
|
||||||
|
}, [level, scopeAnswers, state?.companyProfile])
|
||||||
|
|
||||||
|
if (!level || !recommendations || recommendations.length === 0) return null
|
||||||
|
|
||||||
|
// Match recommendations to actual templates in the library
|
||||||
|
const templateMap = new Map<string, LegalTemplateResult>()
|
||||||
|
for (const t of allTemplates) {
|
||||||
|
if (t.templateType) templateMap.set(t.templateType, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
const required = recommendations.filter((r) => r.requirement === 'required')
|
||||||
|
const recommended = recommendations.filter((r) => r.requirement === 'recommended')
|
||||||
|
const optional = recommendations.filter((r) => r.requirement === 'optional')
|
||||||
|
|
||||||
|
const renderCard = (rec: TemplateRecommendation) => {
|
||||||
|
const template = templateMap.get(rec.templateType)
|
||||||
|
const exists = !!template
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={rec.templateType}
|
||||||
|
className={`rounded-lg border p-3 text-sm ${
|
||||||
|
exists
|
||||||
|
? 'border-gray-200 bg-white hover:border-purple-300 cursor-pointer'
|
||||||
|
: 'border-dashed border-gray-300 bg-gray-50'
|
||||||
|
}`}
|
||||||
|
onClick={() => exists && template && onUseTemplate(template)}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-gray-900 truncate">{rec.label}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
|
{exists ? (
|
||||||
|
<span className="text-purple-600">Vorlage verfuegbar</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">Noch nicht erstellt</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gradient-to-br from-purple-50 to-white rounded-xl border border-purple-200 p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
|
Empfohlene Dokumente fuer Ihr Unternehmen
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Basierend auf Ihrem Compliance-Profil ({getProfileLabel(level)})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-700">
|
||||||
|
{level}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Required */}
|
||||||
|
{required.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-sm font-medium text-red-700">Pflicht</span>
|
||||||
|
<span className="text-xs text-gray-400">({required.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
|
||||||
|
{required.map(renderCard)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recommended */}
|
||||||
|
{recommended.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-sm font-medium text-amber-700">Empfohlen</span>
|
||||||
|
<span className="text-xs text-gray-400">({recommended.length})</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
|
||||||
|
{recommended.map(renderCard)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Optional (collapsed by default) */}
|
||||||
|
{optional.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowOptional(!showOptional)}
|
||||||
|
className="text-sm text-gray-500 hover:text-purple-600 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span>{showOptional ? '▼' : '▶'}</span>
|
||||||
|
<span>Optional ({optional.length})</span>
|
||||||
|
</button>
|
||||||
|
{showOptional && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2 mt-2">
|
||||||
|
{optional.map(renderCard)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useSDK } from '@/lib/sdk'
|
||||||
|
|
||||||
|
interface ReviewerInfo {
|
||||||
|
role_key: string
|
||||||
|
role_label?: string
|
||||||
|
person_name?: string | null
|
||||||
|
person_email?: string | null
|
||||||
|
is_primary?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewRecord {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
reviewer_role_key: string
|
||||||
|
reviewer_name: string | null
|
||||||
|
email_sent: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
pending: 'bg-gray-100 text-gray-700',
|
||||||
|
in_review: 'bg-blue-100 text-blue-700',
|
||||||
|
approved: 'bg-green-100 text-green-700',
|
||||||
|
rejected: 'bg-red-100 text-red-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
pending: 'Ausstehend',
|
||||||
|
in_review: 'In Pruefung',
|
||||||
|
approved: 'Freigegeben',
|
||||||
|
rejected: 'Abgelehnt',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReviewAssignmentPanel({
|
||||||
|
documentType,
|
||||||
|
documentTitle,
|
||||||
|
documentContent,
|
||||||
|
}: {
|
||||||
|
documentType: string
|
||||||
|
documentTitle: string
|
||||||
|
documentContent: string
|
||||||
|
}) {
|
||||||
|
const { projectId } = useSDK()
|
||||||
|
const [reviewers, setReviewers] = useState<ReviewerInfo[]>([])
|
||||||
|
const [existingReviews, setExistingReviews] = useState<ReviewRecord[]>([])
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const [result, setResult] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Load reviewers for this document type
|
||||||
|
useEffect(() => {
|
||||||
|
if (!documentType) return
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (projectId) qs.set('project_id', projectId)
|
||||||
|
qs.set('document_type', documentType)
|
||||||
|
|
||||||
|
// Load mapping + existing reviews
|
||||||
|
Promise.all([
|
||||||
|
fetch(`/api/sdk/v1/compliance/org-roles/mapping`).then(r => r.ok ? r.json() : []),
|
||||||
|
fetch(`/api/sdk/v1/compliance/org-roles${projectId ? `?project_id=${projectId}` : ''}`).then(r => r.ok ? r.json() : []),
|
||||||
|
fetch(`/api/sdk/v1/compliance/document-reviews/for-document?${qs}`).then(r => r.ok ? r.json() : []),
|
||||||
|
]).then(([mappings, roles, reviews]) => {
|
||||||
|
// Filter mappings for this document type
|
||||||
|
const relevant = (mappings as Array<{ document_type: string; role_key: string; is_primary: boolean }>)
|
||||||
|
.filter(m => m.document_type === documentType)
|
||||||
|
// Enrich with role info
|
||||||
|
const enriched: ReviewerInfo[] = relevant.map(m => {
|
||||||
|
const role = (roles as Array<{ role_key: string; role_label: string; person_name: string | null; person_email: string | null }>)
|
||||||
|
.find(r => r.role_key === m.role_key)
|
||||||
|
return { ...m, role_label: role?.role_label, person_name: role?.person_name, person_email: role?.person_email }
|
||||||
|
})
|
||||||
|
setReviewers(enriched)
|
||||||
|
setExistingReviews(reviews)
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [documentType, projectId])
|
||||||
|
|
||||||
|
const handleSendForReview = async () => {
|
||||||
|
setSending(true)
|
||||||
|
setResult(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sdk/v1/compliance/document-reviews', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
document_type: documentType,
|
||||||
|
document_title: documentTitle,
|
||||||
|
document_content: documentContent,
|
||||||
|
project_id: projectId,
|
||||||
|
review_link: window.location.href,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Fehler beim Erstellen')
|
||||||
|
const reviews = await res.json()
|
||||||
|
|
||||||
|
// Send email for each review
|
||||||
|
let sentCount = 0
|
||||||
|
for (const review of reviews) {
|
||||||
|
if (review.reviewer_email) {
|
||||||
|
const sendRes = await fetch(`/api/sdk/v1/compliance/document-reviews/${review.id}/send`, { method: 'POST' })
|
||||||
|
if (sendRes.ok) sentCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setResult(`${reviews.length} Review(s) erstellt, ${sentCount} E-Mail(s) gesendet`)
|
||||||
|
// Refresh
|
||||||
|
const qs = new URLSearchParams({ document_type: documentType })
|
||||||
|
if (projectId) qs.set('project_id', projectId)
|
||||||
|
const updated = await fetch(`/api/sdk/v1/compliance/document-reviews/for-document?${qs}`).then(r => r.json())
|
||||||
|
setExistingReviews(updated)
|
||||||
|
} catch (e) {
|
||||||
|
setResult(e instanceof Error ? e.message : 'Fehler')
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reviewers.length === 0 && existingReviews.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-purple-200 rounded-lg p-4 bg-purple-50/50 space-y-3">
|
||||||
|
<h4 className="font-semibold text-sm text-gray-900 flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-purple-600" 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>
|
||||||
|
Pruefung & Freigabe
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
{/* Assigned reviewers */}
|
||||||
|
{reviewers.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{reviewers.map(r => (
|
||||||
|
<div key={r.role_key} className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="font-medium text-gray-700">{r.role_label || r.role_key}:</span>
|
||||||
|
{r.person_name ? (
|
||||||
|
<span className="text-gray-600">{r.person_name} ({r.person_email || 'keine E-Mail'})</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 italic">Nicht zugewiesen</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Existing reviews */}
|
||||||
|
{existingReviews.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{existingReviews.map(r => (
|
||||||
|
<div key={r.id} className="flex items-center gap-2">
|
||||||
|
<span className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${STATUS_COLORS[r.status] || ''}`}>
|
||||||
|
{STATUS_LABELS[r.status] || r.status}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-600">{r.reviewer_name || r.reviewer_role_key}</span>
|
||||||
|
{r.email_sent && <span className="text-[10px] text-green-600">E-Mail gesendet</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Send for review */}
|
||||||
|
<button onClick={handleSendForReview} disabled={sending || reviewers.length === 0}
|
||||||
|
className="w-full px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors">
|
||||||
|
{sending ? 'Sende...' : 'Zur Pruefung senden'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{result && (
|
||||||
|
<p className={`text-xs ${result.includes('Fehler') ? 'text-red-600' : 'text-green-600'}`}>{result}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,22 +6,64 @@ import { TemplateContext } from './contextBridge'
|
|||||||
|
|
||||||
export const CATEGORIES: { key: string; label: string; types: string[] | null }[] = [
|
export const CATEGORIES: { key: string; label: string; types: string[] | null }[] = [
|
||||||
{ key: 'all', label: 'Alle', types: 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'] },
|
// ── Nach Nutzungskontext sortiert ──────────────────────────────────────
|
||||||
{ key: 'impressum', label: 'Impressum', types: ['impressum'] },
|
|
||||||
{ key: 'dpa', label: 'AVV/DPA', types: ['dpa'] },
|
// Jede Website / App braucht:
|
||||||
{ key: 'nda', label: 'NDA', types: ['nda'] },
|
{ key: 'website', label: 'Website / App', types: ['privacy_policy', 'impressum', 'cookie_policy', 'cookie_banner', 'social_media_dsi'] },
|
||||||
{ key: 'sla', label: 'SLA', types: ['sla'] },
|
|
||||||
{ key: 'acceptable_use', label: 'AUP', types: ['acceptable_use'] },
|
// Online-Shop / E-Commerce:
|
||||||
{ key: 'widerruf', label: 'Widerruf', types: ['widerruf'] },
|
{ key: 'shop', label: 'Online-Shop', types: ['agb', 'widerruf', 'privacy_policy', 'impressum', 'cookie_policy', 'cookie_banner'] },
|
||||||
{ key: 'cookie', label: 'Cookie', types: ['cookie_policy', 'cookie_banner'] },
|
|
||||||
{ key: 'cloud', label: 'Cloud', types: ['cloud_service_agreement'] },
|
// SaaS / Cloud-Dienst:
|
||||||
{ key: 'misc', label: 'Weitere', types: ['community_guidelines', 'copyright_policy', 'data_usage_clause'] },
|
{ key: 'saas', label: 'SaaS / Cloud', types: ['agb', 'dpa', 'sla', 'cloud_service_agreement', 'privacy_policy', 'terms_of_use'] },
|
||||||
{ key: 'dsfa', label: 'DSFA', types: ['dsfa'] },
|
|
||||||
{ key: 'dsr', label: 'DSR-Prozesse', types: [
|
// App / Plattform mit Nutzern:
|
||||||
|
{ key: 'platform', label: 'App / Plattform', types: ['terms_of_use', 'community_guidelines', 'privacy_policy', 'agb', 'acceptable_use', 'media_content_policy', 'copyright_policy'] },
|
||||||
|
|
||||||
|
// Vertraege mit Geschaeftspartnern:
|
||||||
|
{ key: 'contracts', label: 'Vertraege (B2B)', types: ['dpa', 'nda', 'sla', 'cloud_service_agreement', 'data_usage_clause'] },
|
||||||
|
|
||||||
|
// Drittlandtransfer:
|
||||||
|
{ key: 'third_country', label: 'Drittlandtransfer', types: ['transfer_impact_assessment', 'scc_companion'] },
|
||||||
|
|
||||||
|
// ── Interne Compliance-Dokumente ──────────────────────────────────────
|
||||||
|
|
||||||
|
// DSGVO-Kernpflichten:
|
||||||
|
{ key: 'dsgvo_core', label: 'DSGVO-Pflichten', types: ['tom_documentation', 'vvt_register', 'loeschkonzept', 'dsfa', 'pflichtenregister'] },
|
||||||
|
|
||||||
|
// Betroffenenrechte:
|
||||||
|
{ key: 'dsr', label: 'Betroffenenrechte', types: [
|
||||||
'dsr_process_art15', 'dsr_process_art16', 'dsr_process_art17',
|
'dsr_process_art15', 'dsr_process_art16', 'dsr_process_art17',
|
||||||
'dsr_process_art18', 'dsr_process_art19', 'dsr_process_art20', 'dsr_process_art21',
|
'dsr_process_art18', 'dsr_process_art19', 'dsr_process_art20', 'dsr_process_art21',
|
||||||
]},
|
]},
|
||||||
|
|
||||||
|
// Datenschutz-Informationen (alle DSI-Typen):
|
||||||
|
{ key: 'dsi', label: 'Datenschutzinfos', types: ['privacy_policy', 'applicant_dsi', 'employee_dsi', 'social_media_dsi', 'video_conference_dsi', 'informationspflichten'] },
|
||||||
|
|
||||||
|
// Einwilligungen:
|
||||||
|
{ key: 'consent', label: 'Einwilligungen', types: ['consent_texts', 'cookie_banner', 'verpflichtungserklaerung'] },
|
||||||
|
|
||||||
|
// ── Sicherheit & IT ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{ key: 'security_concepts', label: 'Sicherheitskonzepte', types: ['it_security_concept', 'data_protection_concept', 'backup_recovery_concept', 'logging_concept', 'incident_response_plan', 'access_control_concept', 'risk_management_concept', 'isms_manual'] },
|
||||||
|
|
||||||
|
{ key: 'security_policies', label: 'Sicherheitsrichtlinien', types: [
|
||||||
|
'information_security_policy', 'access_control_policy', 'password_policy', 'encryption_policy',
|
||||||
|
'cybersecurity_policy', 'incident_response_policy', 'logging_policy', 'patch_management_policy',
|
||||||
|
'vulnerability_management_policy', 'secrets_management_policy', 'devsecops_policy',
|
||||||
|
'cloud_security_policy', 'change_management_policy', 'asset_management_policy', 'backup_policy',
|
||||||
|
]},
|
||||||
|
|
||||||
|
// ── Organisation & HR ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
{ key: 'hr', label: 'HR & Mitarbeiter', types: ['applicant_dsi', 'employee_dsi', 'employee_security_policy', 'security_awareness_policy', 'remote_work_policy', 'offboarding_policy', 'byod_policy', 'ai_usage_policy', 'whistleblower_policy', 'verpflichtungserklaerung'] },
|
||||||
|
|
||||||
|
{ key: 'data_governance', label: 'Daten-Governance', types: ['data_protection_policy', 'data_classification_policy', 'data_retention_policy', 'data_transfer_policy', 'privacy_incident_policy'] },
|
||||||
|
|
||||||
|
{ key: 'vendor', label: 'Lieferanten / Vendor', types: ['vendor_risk_management_policy', 'third_party_security_policy', 'supplier_security_policy', 'dpa'] },
|
||||||
|
|
||||||
|
{ key: 'bcm', label: 'BCM / Notfall', types: ['business_continuity_policy', 'disaster_recovery_policy', 'crisis_management_policy', 'incident_response_plan'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -41,6 +83,8 @@ export const SECTION_LABELS: Record<keyof TemplateContext, string> = {
|
|||||||
CONSENT: 'Cookie / Einwilligung',
|
CONSENT: 'Cookie / Einwilligung',
|
||||||
HOSTING: 'Hosting-Provider',
|
HOSTING: 'Hosting-Provider',
|
||||||
FEATURES: 'Dokument-Features & Textbausteine',
|
FEATURES: 'Dokument-Features & Textbausteine',
|
||||||
|
TOM: 'TOM-Dokumentation',
|
||||||
|
DPA: 'AVV / Auftragsverarbeitung',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FieldType = 'text' | 'email' | 'number' | 'select' | 'textarea' | 'boolean'
|
export type FieldType = 'text' | 'email' | 'number' | 'select' | 'textarea' | 'boolean'
|
||||||
@@ -186,6 +230,192 @@ export const SECTION_FIELDS: Record<keyof TemplateContext, FieldDef[]> = {
|
|||||||
{ key: 'EDITORIAL_RESPONSIBLE_ADDRESS', label: 'V.i.S.d.P. Adresse' },
|
{ key: 'EDITORIAL_RESPONSIBLE_ADDRESS', label: 'V.i.S.d.P. Adresse' },
|
||||||
{ key: 'HAS_DISPUTE_RESOLUTION', label: 'Streitbeilegungshinweis', type: 'boolean' },
|
{ key: 'HAS_DISPUTE_RESOLUTION', label: 'Streitbeilegungshinweis', type: 'boolean' },
|
||||||
{ key: 'DISPUTE_RESOLUTION_TEXT', label: 'Streitbeilegungstext', type: 'textarea', span: true },
|
{ key: 'DISPUTE_RESOLUTION_TEXT', label: 'Streitbeilegungstext', type: 'textarea', span: true },
|
||||||
|
// ── SaaS AGB v2 ─────────────────────────────────────────────────────────
|
||||||
|
{ key: 'B2B_ONLY', label: 'Nur B2B (keine Verbraucher)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_END_USERS', label: 'Endkunden-Weitergabe (B2B2C)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_MODULAR_PACKAGES', label: 'Modulare Leistungspakete', type: 'boolean' },
|
||||||
|
{ key: 'HAS_STORAGE', label: 'Speicherplatz als Leistung', type: 'boolean' },
|
||||||
|
{ key: 'HAS_STORAGE_LIMITS', label: 'Speicherplatz begrenzt', type: 'boolean' },
|
||||||
|
{ key: 'HAS_TRIAL', label: 'Kostenlose Testphase', type: 'boolean' },
|
||||||
|
{ key: 'TRIAL_DAYS', label: 'Testphase (Tage)', type: 'select', opts: ['7', '14', '30'] },
|
||||||
|
{ key: 'HAS_PRICE_ADJUSTMENT', label: 'Preisanpassungsklausel', type: 'boolean' },
|
||||||
|
{ key: 'PRICE_ADJUSTMENT_NOTICE_WEEKS', label: 'Ankündigung Preisanpassung (Wo.)', type: 'select', opts: ['4', '8', '12'] },
|
||||||
|
{ key: 'PRICE_INCREASE_THRESHOLD_PERCENT', label: 'Schwelle Sonderkündigung (%)', type: 'select', opts: ['5', '10', '15'] },
|
||||||
|
{ key: 'HAS_UPLOAD', label: 'Datei-Upload Funktion', type: 'boolean' },
|
||||||
|
{ key: 'NO_AUDIT_PROOF_STORAGE', label: 'Keine revisionssichere Speicherung', type: 'boolean' },
|
||||||
|
{ key: 'HAS_API_ACCESS', label: 'API-Zugang', type: 'boolean' },
|
||||||
|
{ key: 'HAS_MAINTENANCE_ACCESS', label: 'Fernwartungszugang (On-Premise)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_MAX_DOWNTIME', label: 'Max. Ausfalldauer begrenzt', type: 'boolean' },
|
||||||
|
{ key: 'MAX_DOWNTIME_DAYS', label: 'Max. Ausfalldauer (Tage)', type: 'number' },
|
||||||
|
{ key: 'HAS_IP_INDEMNIFICATION', label: 'IP-Freistellung (Schutzrechte)', type: 'boolean' },
|
||||||
|
{ key: 'LIABILITY_MULTIPLIER', label: 'Haftungsdeckel (x Jahreslizenz)', type: 'select', opts: ['1', '2', '3'] },
|
||||||
|
{ key: 'HAS_REFERENCE_MARKETING', label: 'Referenzmarketing (Logo-Nutzung)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_WHITELABEL', label: 'Whitelabel-Paket vorhanden', type: 'boolean' },
|
||||||
|
{ key: 'HAS_FORCE_MAJEURE', label: 'Force-Majeure-Klausel', type: 'boolean' },
|
||||||
|
{ key: 'HAS_COMMUNITY_GUIDELINES', label: 'Community Guidelines als Bestandteil', type: 'boolean' },
|
||||||
|
// ── Community Guidelines (modular) ──────────────────────────────────────
|
||||||
|
{ key: 'TONE_FRIENDLY', label: 'Ton: Freundlich/Einladend', type: 'boolean' },
|
||||||
|
{ key: 'TONE_EDITORIAL', label: 'Ton: Editorial/Sachlich', type: 'boolean' },
|
||||||
|
{ key: 'TONE_FORMAL', label: 'Ton: Formal/Juristisch', type: 'boolean' },
|
||||||
|
{ key: 'HAS_MEDIA_UPLOADS', label: 'Plattform: Medien-Uploads (Bilder/Videos)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_MESSAGING', label: 'Plattform: Messaging/Chat', type: 'boolean' },
|
||||||
|
{ key: 'HAS_MARKETPLACE', label: 'Plattform: Marketplace/Handel', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_ILLEGAL', label: '↳ Details: Rechtswidrige Inhalte', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_HATE_SPEECH', label: '↳ Details: Hassrede', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_FRAUD', label: '↳ Details: Betrug/Deepfakes', type: 'boolean' },
|
||||||
|
{ key: 'EXCEPTIONS_FRAUD', label: '↳ Ausnahmen: Parodie/Satire/Kunst', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_PRIVACY', label: '↳ Details: Sicherheit/Privatsphäre', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_VIOLENCE', label: '↳ Details: Gewalt (bei Medien-Uploads)', type: 'boolean' },
|
||||||
|
{ key: 'EXCEPTIONS_VIOLENCE', label: '↳ Ausnahmen: Kampfsport/Journalismus/Kunst', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_PORNOGRAPHY', label: '↳ Details: Pornografie (bei Medien-Uploads)', type: 'boolean' },
|
||||||
|
{ key: 'EXCEPTIONS_PORNOGRAPHY', label: '↳ Ausnahmen: Bodypainting/Stillen/Medizin', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_SELF_HARM', label: '↳ Details: Suizid/Selbstverletzung', type: 'boolean' },
|
||||||
|
{ key: 'EXCEPTIONS_SELF_HARM', label: '↳ Ausnahmen: Prävention/Journalismus', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_EXPLOITATION', label: '↳ Details: Ausbeutung/Missbrauch/CSAM', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_HARASSMENT', label: '↳ Details: Sexuelle Belästigung (bei Messaging)', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_DANGEROUS_PRODUCTS', label: '↳ Details: Gefährliche Produkte (bei Marketplace)', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_TERRORISM', label: '↳ Details: Terrorismus/Gefährliche Gruppen', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_DANGEROUS_ACTIVITIES', label: '↳ Details: Gefährdende Aktivitäten', type: 'boolean' },
|
||||||
|
{ key: 'GUIDELINES_URL', label: 'URL der Richtlinien' },
|
||||||
|
// ── Medien & Content Module ─────────────────────────────────────────────
|
||||||
|
{ key: 'IS_JOURNALISTIC_MEDIA', label: 'Journalistisches Medium (MStV §§ 18-22)', type: 'boolean' },
|
||||||
|
{ key: 'EDITORIAL_EMAIL', label: 'Redaktions-E-Mail (Gegendarstellung)', type: 'email' },
|
||||||
|
{ key: 'HAS_AI_GENERATED_CONTENT', label: 'KI-generierte Inhalte (AI Act Art. 50)', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_AI_LABELING', label: '↳ Detaillierte KI-Kennzeichnungstabelle', type: 'boolean' },
|
||||||
|
{ key: 'HAS_SPONSORED_CONTENT', label: 'Bezahlte/werbliche Inhalte (§ 5a UWG)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_PRESS_COUNCIL', label: 'Pressekodex-Selbstverpflichtung (Presserat)', type: 'boolean' },
|
||||||
|
// ── Nutzungsbedingungen ─────────────────────────────────────────────────
|
||||||
|
{ key: 'HAS_UGC', label: 'User Generated Content', type: 'boolean' },
|
||||||
|
{ key: 'HAS_CONTENT_LICENSING', label: 'Content Licensing (Nutzer-zu-Nutzer)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_TDM_OPTOUT', label: 'Text- und Data-Mining Opt-out', type: 'boolean' },
|
||||||
|
{ key: 'HAS_CONTENT_AUTHENTICITY', label: 'Content Authenticity (kryptogr. Herkunft)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_TIPPING', label: 'Tipping/Anerkennungsfunktion', type: 'boolean' },
|
||||||
|
{ key: 'HAS_CRYPTO_PAYMENTS', label: 'Krypto-Zahlungen', type: 'boolean' },
|
||||||
|
{ key: 'HAS_INTEGRATED_WALLET', label: 'Integriertes Wallet (Non-Custodial)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_IDENTITY_VERIFICATION', label: 'Identitätsprüfung erforderlich', type: 'boolean' },
|
||||||
|
{ key: 'HAS_COPYRIGHT_TAKEDOWN', label: 'Copyright Takedown-Verfahren', type: 'boolean' },
|
||||||
|
{ key: 'HAS_PAID_USER_ACCOUNTS', label: 'Kostenpflichtige Nutzeraccounts', type: 'boolean' },
|
||||||
|
{ key: 'HAS_EU_USERS', label: 'EU-weite Nutzer (Verbraucherschutz)', type: 'boolean' },
|
||||||
|
{ key: 'MFA_REQUIRED', label: 'MFA verpflichtend für Nutzer', type: 'boolean' },
|
||||||
|
{ key: 'DATA_EXPORT_BEFORE_DELETION', label: 'Datenexport vor Kontolöschung', type: 'boolean' },
|
||||||
|
{ key: 'EXPORT_BEFORE_DELETION_DAYS', label: 'Exportfrist (Tage)', type: 'select', opts: ['7', '14', '30'] },
|
||||||
|
{ key: 'MIN_AGE', label: 'Mindestalter', type: 'select', opts: ['13', '16', '18'] },
|
||||||
|
{ key: 'ALLOWS_MINORS', label: 'Minderjährige mit Eltern-Einwilligung', type: 'boolean' },
|
||||||
|
{ key: 'TIPPING_FEE_PERCENT', label: 'Tipping-Gebühr (%)', type: 'number' },
|
||||||
|
{ key: 'SUPPORTED_CURRENCIES', label: 'Unterstützte Währungen/Token' },
|
||||||
|
// ── Widerrufsbelehrung ──────────────────────────────────────────────────
|
||||||
|
{ key: 'HAS_PHYSICAL_GOODS', label: 'Physische Waren (Rücksendung)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_COMBO_PACKAGE', label: 'Kombi-Paket (Hardware + Software)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_DIGITAL_CONTENT', label: 'Digitale Inhalte (§ 356 Abs. 5 BGB)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_SAAS_SERVICE', label: 'SaaS-Dienstleistung (§ 356 Abs. 4 BGB)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_IOT_BUNDLE', label: 'Verbundenes Produkt (Hardware + App/Cloud)', type: 'boolean' },
|
||||||
|
{ key: 'IOT_SEPARATE_CONTRACTS', label: '↳ HW und Cloud getrennt widerrufbar', type: 'boolean' },
|
||||||
|
{ key: 'RETURN_ADDRESS', label: 'Rücksendeadresse (Servicecenter)' },
|
||||||
|
// ── Social Media DSI ────────────────────────────────────────────────────
|
||||||
|
{ key: 'HAS_FACEBOOK', label: 'Facebook & Instagram', type: 'boolean' },
|
||||||
|
{ key: 'HAS_YOUTUBE', label: 'YouTube', type: 'boolean' },
|
||||||
|
{ key: 'HAS_LINKEDIN', label: 'LinkedIn', type: 'boolean' },
|
||||||
|
{ key: 'HAS_TIKTOK', label: 'TikTok', type: 'boolean' },
|
||||||
|
{ key: 'HAS_X_TWITTER', label: 'X (Twitter)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_META_PIXEL', label: 'Meta Pixel (Konversionsmessung)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_RECRUITING_VIA_SOCIAL', label: 'Personalgewinnung über Social Media', type: 'boolean' },
|
||||||
|
{ key: 'SOCIAL_MEDIA_PLATFORMS_LIST', label: 'Plattform-Liste (Text)', type: 'textarea', span: true },
|
||||||
|
// ── DSI Erweiterungen ───────────────────────────────────────────────────
|
||||||
|
{ key: 'DSI_TITLE', label: 'Titel', type: 'select', opts: ['Datenschutzerklaerung', 'Datenschutzinformation'] },
|
||||||
|
{ key: 'SERVICE_SCOPE_DESCRIPTION', label: 'Geltungsbereich (z.B. "die App xy" / "den Online-Shop")' },
|
||||||
|
{ key: 'HAS_ONLINE_SHOP', label: 'Online-Shop Funktionen', type: 'boolean' },
|
||||||
|
{ key: 'HAS_PICKUP_STATION', label: 'Abholstationen', type: 'boolean' },
|
||||||
|
{ key: 'HAS_SUBSCRIPTION', label: 'Abonnement-Modell', type: 'boolean' },
|
||||||
|
{ key: 'HAS_PRODUCT_REVIEWS', label: 'Produktbewertungen', type: 'boolean' },
|
||||||
|
{ key: 'HAS_PARENT_COMPANY', label: 'Konzernstruktur (Mutter-/Tochtergesellschaft)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_LOCATION', label: 'Standortdaten erhoben', type: 'boolean' },
|
||||||
|
{ key: 'HAS_E2E_ENCRYPTION', label: 'Ende-zu-Ende-Verschlüsselung (Messaging)', type: 'boolean' },
|
||||||
|
{ key: 'DETAILED_RIGHTS', label: 'Ausführliche Rechte-Beschreibung', type: 'boolean' },
|
||||||
|
{ key: 'PROCESSOR_LIST_URL', label: 'URL Auftragsverarbeiter-Liste' },
|
||||||
|
// ── Whistleblower ───────────────────────────────────────────────────────
|
||||||
|
{ key: 'WHISTLEBLOWER_CONTACT_NAME', label: 'Meldestelle: Ansprechperson' },
|
||||||
|
{ key: 'WHISTLEBLOWER_CONTACT_ROLE', label: 'Meldestelle: Funktion/Rolle' },
|
||||||
|
{ key: 'WHISTLEBLOWER_EMAIL', label: 'Meldestelle: E-Mail', type: 'email' },
|
||||||
|
{ key: 'WHISTLEBLOWER_PHONE', label: 'Meldestelle: Telefon' },
|
||||||
|
{ key: 'WHISTLEBLOWER_URL', label: 'Meldestelle: Online-Formular URL' },
|
||||||
|
{ key: 'HAS_ANONYMOUS_REPORTING', label: 'Anonyme Meldungen möglich', type: 'boolean' },
|
||||||
|
{ key: 'HAS_EXTERNAL_REPORTING', label: 'Externe Meldestelle (BfJ) erwähnen', type: 'boolean' },
|
||||||
|
// ── Bewerber-DSI ────────────────────────────────────────────────────────
|
||||||
|
{ key: 'HAS_VIDEO_INTERVIEW', label: 'Video-Interviews', type: 'boolean' },
|
||||||
|
{ key: 'HAS_ASSESSMENT', label: 'Assessment-Center/Tests', type: 'boolean' },
|
||||||
|
{ key: 'HAS_TALENT_POOL', label: 'Talentpool (Einwilligung)', type: 'boolean' },
|
||||||
|
{ key: 'TALENT_POOL_MONTHS', label: 'Talentpool Aufbewahrung (Monate)', type: 'select', opts: ['6', '12', '24'] },
|
||||||
|
{ key: 'HAS_RECRUITING_AGENCY', label: 'Personalvermittler', type: 'boolean' },
|
||||||
|
{ key: 'HAS_RECRUITING_SOFTWARE', label: 'Bewerbermanagement-Software', type: 'boolean' },
|
||||||
|
{ key: 'HAS_EMPLOYEE_REFERRAL', label: 'Mitarbeiterempfehlungen', type: 'boolean' },
|
||||||
|
// ── Mitarbeiter-DSI ─────────────────────────────────────────────────────
|
||||||
|
{ key: 'HAS_IT_USAGE_MONITORING', label: 'IT-Nutzungsüberwachung', type: 'boolean' },
|
||||||
|
{ key: 'HAS_COMPANY_VEHICLE', label: 'Dienstfahrzeuge/Fuhrpark', type: 'boolean' },
|
||||||
|
{ key: 'HAS_ACCESS_CONTROL', label: 'Zutrittskontrolle (Chipkarte)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_VIDEO_SURVEILLANCE', label: 'Videoüberwachung (Arbeitsplatz)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_COMPANY_PENSION', label: 'Betriebliche Altersvorsorge', type: 'boolean' },
|
||||||
|
{ key: 'HAS_EXTERNAL_HR_SOFTWARE', label: 'Externe HR-Software', type: 'boolean' },
|
||||||
|
{ key: 'HAS_WORKS_COUNCIL', label: 'Betriebsrat vorhanden', type: 'boolean' },
|
||||||
|
{ key: 'HAS_SPECIAL_CATEGORIES_EMPLOYEES', label: 'Besondere Datenkategorien (Gesundheit, Religion)', type: 'boolean' },
|
||||||
|
],
|
||||||
|
// ── TOM ─────────────────────────────────────────────────────────────────
|
||||||
|
TOM: [
|
||||||
|
{ key: 'ISB_NAME', label: 'IT-Sicherheitsbeauftragter' },
|
||||||
|
{ key: 'GF_NAME', label: 'Geschäftsführung' },
|
||||||
|
{ key: 'DOCUMENT_VERSION', label: 'Dokumentversion' },
|
||||||
|
{ key: 'NEXT_REVIEW_DATE', label: 'Nächste Prüfung (JJJJ-MM-TT)' },
|
||||||
|
{ key: 'HAS_MFA', label: 'Multi-Faktor-Authentifizierung aktiv', type: 'boolean' },
|
||||||
|
{ key: 'HAS_USB_LOCKED', label: 'USB-Schnittstellen physisch gesperrt', type: 'boolean' },
|
||||||
|
{ key: 'HAS_MOBILE_MEDIA', label: 'Mobile Datenträger im Einsatz', type: 'boolean' },
|
||||||
|
{ key: 'HAS_FOUR_EYES_DELETE', label: 'Vier-Augen-Prinzip für Löschungen', type: 'boolean' },
|
||||||
|
{ key: 'LOG_RETENTION_MONTHS', label: 'Log-Aufbewahrung (Monate)', type: 'select', opts: ['3', '6', '12', '24'] },
|
||||||
|
{ key: 'DIN_66399_LEVEL', label: 'Vernichtungsstufe (DIN 66399)', type: 'select', opts: ['1', '2', '3', '4', '5', '6', '7'] },
|
||||||
|
{ key: 'HAS_EXTERNAL_DESTRUCTION', label: 'Externer Vernichtungsdienstleister', type: 'boolean' },
|
||||||
|
{ key: 'HAS_PHYSICAL_TRANSPORT', label: 'Physischer Datenträgertransport', type: 'boolean' },
|
||||||
|
{ key: 'HAS_THIRD_COUNTRY_TRANSFER', label: 'Datenübermittlung in Drittländer', type: 'boolean' },
|
||||||
|
{ key: 'AVAILABILITY_TARGET', label: 'Verfügbarkeitsziel', type: 'select', opts: ['99.0', '99.5', '99.9', '99.99'] },
|
||||||
|
{ key: 'HAS_USV', label: 'USV vorhanden', type: 'boolean' },
|
||||||
|
{ key: 'HAS_REDUNDANCY', label: 'Redundante Systeme / Failover', type: 'boolean' },
|
||||||
|
{ key: 'HAS_GEO_REDUNDANCY', label: 'Georedundanter Standort', type: 'boolean' },
|
||||||
|
{ key: 'HAS_OWN_SERVER_ROOM', label: 'Eigener Serverraum', type: 'boolean' },
|
||||||
|
{ key: 'HAS_CLOUD_SERVICES', label: 'Cloud-Dienste im Einsatz', type: 'boolean' },
|
||||||
|
{ key: 'HAS_MULTI_TENANT', label: 'Multi-Tenant-System', type: 'boolean' },
|
||||||
|
{ key: 'SEPARATION_TYPE', label: 'Art der Mandantentrennung', type: 'select', opts: ['logisch', 'physisch', 'eigene Infrastruktur'] },
|
||||||
|
{ key: 'HAS_TEST_DATA_ANONYMIZED', label: 'Testdaten anonymisiert/synthetisch', type: 'boolean' },
|
||||||
|
],
|
||||||
|
// ── DPA / AVV ─────────────────────────────────────────────────────────
|
||||||
|
DPA: [
|
||||||
|
{ key: 'AG_NAME', label: 'Auftraggeber (Name/Firma)' },
|
||||||
|
{ key: 'AG_STRASSE', label: 'Auftraggeber Straße' },
|
||||||
|
{ key: 'AG_PLZ_ORT', label: 'Auftraggeber PLZ Ort' },
|
||||||
|
{ key: 'AN_NAME', label: 'Auftragnehmer (Name/Firma)' },
|
||||||
|
{ key: 'AN_STRASSE', label: 'Auftragnehmer Straße' },
|
||||||
|
{ key: 'AN_PLZ_ORT', label: 'Auftragnehmer PLZ Ort' },
|
||||||
|
{ key: 'VERARBEITUNGSGEGENSTAND', label: 'Gegenstand der Verarbeitung', type: 'textarea', span: true },
|
||||||
|
{ key: 'VERARBEITUNGSZWECK', label: 'Zweck der Verarbeitung', type: 'textarea', span: true },
|
||||||
|
{ key: 'VERARBEITUNGSARTEN', label: 'Art der Verarbeitung (Erheben, Speichern, …)', type: 'textarea', span: true },
|
||||||
|
{ key: 'DATENKATEGORIEN', label: 'Datenkategorien', type: 'textarea', span: true },
|
||||||
|
{ key: 'PERSONENKATEGORIEN', label: 'Betroffene Personenkategorien', type: 'textarea', span: true },
|
||||||
|
{ key: 'BREACH_NOTIFICATION_HOURS', label: 'Meldefrist Datenschutzverletzung (h)', type: 'select', opts: ['12', '24', '48'] },
|
||||||
|
{ key: 'INSTRUCTION_RETENTION_YEARS', label: 'Aufbewahrung Weisungen (Jahre)', type: 'select', opts: ['3', '5', '10'] },
|
||||||
|
{ key: 'SUB_PROCESSOR_NOTICE_WEEKS', label: 'Ankündigung Sub-AV (Wochen)', type: 'select', opts: ['2', '4', '6'] },
|
||||||
|
{ key: 'SUB_PROCESSOR_OBJECTION_WEEKS', label: 'Widerspruchsfrist Sub-AV (Wochen)', type: 'select', opts: ['2', '4'] },
|
||||||
|
{ key: 'DATA_EXPORT_FORMAT', label: 'Datenformat bei Rückgabe', type: 'select', opts: ['CSV/JSON', 'CSV', 'JSON', 'XML', 'nach Vereinbarung'] },
|
||||||
|
{ key: 'RETURN_CHOICE_WEEKS', label: 'Frist Rückgabe-Wahl (Wochen)', type: 'select', opts: ['2', '4', '8'] },
|
||||||
|
{ key: 'DELETION_DAYS', label: 'Löschfrist nach Vertragsende (Tage)', type: 'select', opts: ['30', '60', '90'] },
|
||||||
|
{ key: 'AN_DSB_NAME', label: 'DSB Auftragnehmer Name' },
|
||||||
|
{ key: 'AN_DSB_EMAIL', label: 'DSB Auftragnehmer E-Mail', type: 'email' },
|
||||||
|
{ key: 'VERTRAGSDATUM', label: 'Vertragsdatum (JJJJ-MM-TT)' },
|
||||||
|
{ key: 'GERICHTSSTAND', label: 'Gerichtsstand' },
|
||||||
|
{ key: 'HAS_LIABILITY_PROTECTION', label: 'Haftungsschutz bei Weisung (§ 4.1a)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_SUPPORT_COST_CLAUSE', label: 'Kostenregelung Unterstützung (§ 7.4)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_SUB_PROCESSOR_SILENCE_APPROVAL', label: 'Zustimmungsfiktion bei Sub-AV (§ 8.2a)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_SUB_PROCESSOR_TERMINATION_RIGHT', label: 'Kündigungsrecht bei Sub-AV-Widerspruch (§ 8.3)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_REACTIVATION_PERIOD', label: 'Reaktivierungszeitraum (§ 10.1)', type: 'boolean' },
|
||||||
|
{ key: 'REACTIVATION_MONTHS', label: 'Reaktivierung (Monate)', type: 'select', opts: ['1', '3', '6'] },
|
||||||
|
{ key: 'HAS_RETURN_COST_CLAUSE', label: 'Kosten für Datenrückgabe (§ 10.5)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_GERICHTSSTAND_CLAUSE', label: 'Gerichtsstandklausel (§ 11.1)', type: 'boolean' },
|
||||||
|
{ key: 'HAS_UNILATERAL_CHANGE_RIGHT', label: '⚠️ Einseitiges Änderungsrecht AN (§ 11.6)', type: 'boolean' },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
TemplateContext,
|
TemplateContext,
|
||||||
ProviderCtx,
|
ProviderCtx,
|
||||||
ComputedFlags,
|
ComputedFlags,
|
||||||
|
TOMCtx,
|
||||||
|
DPACtx,
|
||||||
} from './contextBridge'
|
} from './contextBridge'
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -44,6 +46,8 @@ export function contextToPlaceholders(ctx: TemplateContext): Record<string, stri
|
|||||||
const con = ctx.CONSENT
|
const con = ctx.CONSENT
|
||||||
const h = ctx.HOSTING
|
const h = ctx.HOSTING
|
||||||
const f = ctx.FEATURES
|
const f = ctx.FEATURES
|
||||||
|
const tom = ctx.TOM
|
||||||
|
const dpa = ctx.DPA
|
||||||
|
|
||||||
const address = providerAddress(p)
|
const address = providerAddress(p)
|
||||||
|
|
||||||
@@ -180,6 +184,86 @@ export function contextToPlaceholders(ctx: TemplateContext): Record<string, stri
|
|||||||
'{{LIMITATION_CAP_TEXT}}': str(f.LIMITATION_CAP_TEXT),
|
'{{LIMITATION_CAP_TEXT}}': str(f.LIMITATION_CAP_TEXT),
|
||||||
'{{CONSUMER_WITHDRAWAL_TEXT}}': str(f.CONSUMER_WITHDRAWAL_TEXT),
|
'{{CONSUMER_WITHDRAWAL_TEXT}}': str(f.CONSUMER_WITHDRAWAL_TEXT),
|
||||||
'{{SUPPORT_CHANNELS_TEXT}}': str(f.SUPPORT_CHANNELS_TEXT),
|
'{{SUPPORT_CHANNELS_TEXT}}': str(f.SUPPORT_CHANNELS_TEXT),
|
||||||
|
|
||||||
|
// --- TOM ---
|
||||||
|
'{{ISB_NAME}}': str(tom.ISB_NAME),
|
||||||
|
'{{GF_NAME}}': str(tom.GF_NAME),
|
||||||
|
'{{DOCUMENT_VERSION}}': str(tom.DOCUMENT_VERSION),
|
||||||
|
'{{NEXT_REVIEW_DATE}}': str(tom.NEXT_REVIEW_DATE),
|
||||||
|
|
||||||
|
// --- DPA / AVV ---
|
||||||
|
'{{AG_NAME}}': str(dpa.AG_NAME) || str(c.LEGAL_NAME),
|
||||||
|
'{{AG_STRASSE}}': str(dpa.AG_STRASSE) || str(c.ADDRESS_LINE),
|
||||||
|
'{{AG_PLZ_ORT}}': str(dpa.AG_PLZ_ORT) || [c.POSTAL_CODE, c.CITY].filter(Boolean).join(' '),
|
||||||
|
'{{AN_NAME}}': str(dpa.AN_NAME) || str(p.LEGAL_NAME),
|
||||||
|
'{{AN_STRASSE}}': str(dpa.AN_STRASSE) || str(p.ADDRESS_LINE),
|
||||||
|
'{{AN_PLZ_ORT}}': str(dpa.AN_PLZ_ORT) || [p.POSTAL_CODE, p.CITY].filter(Boolean).join(' '),
|
||||||
|
'{{VERARBEITUNGSGEGENSTAND}}': str(dpa.VERARBEITUNGSGEGENSTAND),
|
||||||
|
'{{VERARBEITUNGSZWECK}}': str(dpa.VERARBEITUNGSZWECK),
|
||||||
|
'{{VERARBEITUNGSARTEN}}': str(dpa.VERARBEITUNGSARTEN),
|
||||||
|
'{{DATENKATEGORIEN}}': str(dpa.DATENKATEGORIEN),
|
||||||
|
'{{PERSONENKATEGORIEN}}': str(dpa.PERSONENKATEGORIEN),
|
||||||
|
'{{BREACH_NOTIFICATION_HOURS}}': str(dpa.BREACH_NOTIFICATION_HOURS) || str(sec.INCIDENT_NOTICE_HOURS),
|
||||||
|
'{{INSTRUCTION_RETENTION_YEARS}}': str(dpa.INSTRUCTION_RETENTION_YEARS),
|
||||||
|
'{{SUB_PROCESSOR_NOTICE_WEEKS}}': str(dpa.SUB_PROCESSOR_NOTICE_WEEKS),
|
||||||
|
'{{SUB_PROCESSOR_OBJECTION_WEEKS}}': str(dpa.SUB_PROCESSOR_OBJECTION_WEEKS),
|
||||||
|
'{{DATA_EXPORT_FORMAT}}': str(dpa.DATA_EXPORT_FORMAT),
|
||||||
|
'{{RETURN_CHOICE_WEEKS}}': str(dpa.RETURN_CHOICE_WEEKS),
|
||||||
|
'{{DELETION_DAYS}}': str(dpa.DELETION_DAYS),
|
||||||
|
'{{REACTIVATION_MONTHS}}': str(dpa.REACTIVATION_MONTHS),
|
||||||
|
'{{TERMINATION_WEEKS}}': str(dpa.TERMINATION_WEEKS),
|
||||||
|
'{{CHANGE_NOTICE_WEEKS}}': str(dpa.CHANGE_NOTICE_WEEKS),
|
||||||
|
'{{THIRD_COUNTRY_OBJECTION_WEEKS}}': str(dpa.THIRD_COUNTRY_OBJECTION_WEEKS),
|
||||||
|
'{{AN_DSB_NAME}}': str(dpa.AN_DSB_NAME) || str(prv.DPO_NAME),
|
||||||
|
'{{AN_DSB_EMAIL}}': str(dpa.AN_DSB_EMAIL) || str(prv.DPO_EMAIL),
|
||||||
|
'{{AG_ORT}}': str(dpa.AG_ORT),
|
||||||
|
'{{AN_ORT}}': str(dpa.AN_ORT),
|
||||||
|
'{{VERTRAGSDATUM}}': str(dpa.VERTRAGSDATUM) || str(l.VERSION_DATE),
|
||||||
|
'{{AG_UNTERZEICHNER_NAME}}': str(dpa.AG_UNTERZEICHNER_NAME),
|
||||||
|
'{{AG_UNTERZEICHNER_FUNKTION}}': str(dpa.AG_UNTERZEICHNER_FUNKTION),
|
||||||
|
'{{AN_UNTERZEICHNER_NAME}}': str(dpa.AN_UNTERZEICHNER_NAME) || str(p.CEO_NAME),
|
||||||
|
'{{AN_UNTERZEICHNER_FUNKTION}}': str(dpa.AN_UNTERZEICHNER_FUNKTION),
|
||||||
|
'{{GERICHTSSTAND}}': str(dpa.GERICHTSSTAND) || str(l.JURISDICTION_CITY),
|
||||||
|
|
||||||
|
// --- FEATURES: Whistleblower ---
|
||||||
|
'{{WHISTLEBLOWER_CONTACT_NAME}}': str(f.WHISTLEBLOWER_CONTACT_NAME),
|
||||||
|
'{{WHISTLEBLOWER_CONTACT_ROLE}}': str(f.WHISTLEBLOWER_CONTACT_ROLE),
|
||||||
|
'{{WHISTLEBLOWER_EMAIL}}': str(f.WHISTLEBLOWER_EMAIL),
|
||||||
|
'{{WHISTLEBLOWER_PHONE}}': str(f.WHISTLEBLOWER_PHONE),
|
||||||
|
'{{WHISTLEBLOWER_URL}}': str(f.WHISTLEBLOWER_URL),
|
||||||
|
// --- FEATURES: Video Conference ---
|
||||||
|
'{{VIDEO_PROVIDER_NAME}}': str(f.VIDEO_PROVIDER_NAME),
|
||||||
|
'{{VIDEO_PROVIDER_COUNTRY}}': str(f.VIDEO_PROVIDER_COUNTRY),
|
||||||
|
'{{VIDEO_PROVIDER_ROLE}}': str(f.VIDEO_PROVIDER_ROLE),
|
||||||
|
'{{VIDEO_PROVIDER_PRIVACY_URL}}': str(f.VIDEO_PROVIDER_PRIVACY_URL),
|
||||||
|
'{{RECORDING_RETENTION_DAYS}}': str(f.RECORDING_RETENTION_DAYS),
|
||||||
|
// --- FEATURES: KI/AI ---
|
||||||
|
'{{APPROVED_AI_SYSTEMS}}': str(f.APPROVED_AI_SYSTEMS),
|
||||||
|
// --- FEATURES: BYOD ---
|
||||||
|
'{{BYOD_COST_DETAILS}}': str(f.BYOD_COST_DETAILS),
|
||||||
|
// --- FEATURES: Consent ---
|
||||||
|
'{{NEWSLETTER_SIGNUP_URL}}': str(f.NEWSLETTER_SIGNUP_URL),
|
||||||
|
// --- FEATURES: Social Media ---
|
||||||
|
'{{SOCIAL_MEDIA_PLATFORMS_LIST}}': str(f.SOCIAL_MEDIA_PLATFORMS_LIST),
|
||||||
|
'{{EDITORIAL_EMAIL}}': str(f.EDITORIAL_EMAIL),
|
||||||
|
// --- FEATURES: Transfer/SCC ---
|
||||||
|
'{{RECIPIENT_NAME}}': str(f.RECIPIENT_NAME),
|
||||||
|
'{{RECIPIENT_COUNTRY}}': str(f.RECIPIENT_COUNTRY),
|
||||||
|
'{{RECIPIENT_ADDRESS}}': str(f.RECIPIENT_ADDRESS),
|
||||||
|
'{{RECIPIENT_CONTACT}}': str(f.RECIPIENT_CONTACT),
|
||||||
|
'{{RECIPIENT_EMAIL}}': str(f.RECIPIENT_EMAIL),
|
||||||
|
'{{RECIPIENT_ROLE}}': str(f.RECIPIENT_ROLE),
|
||||||
|
'{{TRANSFER_PURPOSE}}': str(f.TRANSFER_PURPOSE),
|
||||||
|
'{{TRANSFER_MECHANISM}}': str(f.TRANSFER_MECHANISM),
|
||||||
|
'{{DATA_CATEGORIES_TRANSFERRED}}': str(f.DATA_CATEGORIES_TRANSFERRED),
|
||||||
|
'{{DATA_SUBJECTS}}': str(f.DATA_SUBJECTS),
|
||||||
|
'{{TRANSFER_FREQUENCY}}': str(f.TRANSFER_FREQUENCY),
|
||||||
|
// --- FEATURES: DSI ---
|
||||||
|
'{{DSI_TITLE}}': str(f.DSI_TITLE) || 'Datenschutzerklaerung',
|
||||||
|
'{{SERVICE_SCOPE_DESCRIPTION}}': str(f.SERVICE_SCOPE_DESCRIPTION),
|
||||||
|
'{{FULFILLMENT_LOCATION}}': str(f.FULFILLMENT_LOCATION),
|
||||||
|
'{{GUIDELINES_URL}}': str(f.GUIDELINES_URL),
|
||||||
|
'{{PROCESSOR_LIST_URL}}': str(f.PROCESSOR_LIST_URL),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +300,9 @@ const SECTION_COVERS: Record<keyof TemplateContext, string[]> = {
|
|||||||
NDA: ['{{PURPOSE}}', '{{DURATION_YEARS}}', '{{PENALTY_AMOUNT}}'],
|
NDA: ['{{PURPOSE}}', '{{DURATION_YEARS}}', '{{PENALTY_AMOUNT}}'],
|
||||||
CONSENT: ['{{WEBSITE_NAME}}', '{{ANALYTICS_TOOLS}}', '{{MARKETING_PARTNERS}}', '{{ANALYTICS_TOOLS_LIST}}', '{{MARKETING_PARTNERS_LIST}}'],
|
CONSENT: ['{{WEBSITE_NAME}}', '{{ANALYTICS_TOOLS}}', '{{MARKETING_PARTNERS}}', '{{ANALYTICS_TOOLS_LIST}}', '{{MARKETING_PARTNERS_LIST}}'],
|
||||||
HOSTING: ['{{HOSTING_PROVIDER_NAME}}', '{{HOSTING_PROVIDER_COUNTRY}}', '{{HOSTING_PROVIDER_CONTRACT_TYPE}}'],
|
HOSTING: ['{{HOSTING_PROVIDER_NAME}}', '{{HOSTING_PROVIDER_COUNTRY}}', '{{HOSTING_PROVIDER_CONTRACT_TYPE}}'],
|
||||||
FEATURES: ['{{CONSENT_WITHDRAWAL_PATH}}', '{{SECURITY_MEASURES_SUMMARY}}', '{{DATA_SUBJECT_REQUEST_CHANNEL}}', '{{TRANSFER_GUARDS}}', '{{REGULATED_PROFESSION_TEXT}}', '{{EDITORIAL_RESPONSIBLE_NAME}}', '{{EDITORIAL_RESPONSIBLE_ADDRESS}}', '{{DISPUTE_RESOLUTION_TEXT}}', '{{NEWSLETTER_PROVIDER_DETAIL}}', '{{PAYMENT_PROVIDER_DETAIL}}', '{{SOCIAL_MEDIA_DETAIL}}', '{{ANALYTICS_TOOLS_DETAIL}}', '{{MARKETING_TOOLS_DETAIL}}', '{{CMP_NAME}}', '{{PRICES_TEXT}}', '{{PAYMENT_TERMS_TEXT}}', '{{CONTRACT_TERM_TEXT}}', '{{SLA_URL}}', '{{EXPORT_POLICY_TEXT}}', '{{LIMITATION_CAP_TEXT}}', '{{CONSUMER_WITHDRAWAL_TEXT}}', '{{SUPPORT_CHANNELS_TEXT}}'],
|
FEATURES: ['{{CONSENT_WITHDRAWAL_PATH}}', '{{SECURITY_MEASURES_SUMMARY}}', '{{DATA_SUBJECT_REQUEST_CHANNEL}}', '{{TRANSFER_GUARDS}}', '{{REGULATED_PROFESSION_TEXT}}', '{{EDITORIAL_RESPONSIBLE_NAME}}', '{{EDITORIAL_RESPONSIBLE_ADDRESS}}', '{{DISPUTE_RESOLUTION_TEXT}}', '{{NEWSLETTER_PROVIDER_DETAIL}}', '{{PAYMENT_PROVIDER_DETAIL}}', '{{SOCIAL_MEDIA_DETAIL}}', '{{ANALYTICS_TOOLS_DETAIL}}', '{{MARKETING_TOOLS_DETAIL}}', '{{CMP_NAME}}', '{{PRICES_TEXT}}', '{{PAYMENT_TERMS_TEXT}}', '{{CONTRACT_TERM_TEXT}}', '{{SLA_URL}}', '{{EXPORT_POLICY_TEXT}}', '{{LIMITATION_CAP_TEXT}}', '{{CONSUMER_WITHDRAWAL_TEXT}}', '{{SUPPORT_CHANNELS_TEXT}}', '{{WHISTLEBLOWER_CONTACT_NAME}}', '{{WHISTLEBLOWER_EMAIL}}', '{{WHISTLEBLOWER_URL}}', '{{VIDEO_PROVIDER_NAME}}', '{{APPROVED_AI_SYSTEMS}}', '{{SOCIAL_MEDIA_PLATFORMS_LIST}}', '{{DSI_TITLE}}', '{{SERVICE_SCOPE_DESCRIPTION}}', '{{GUIDELINES_URL}}', '{{PROCESSOR_LIST_URL}}', '{{RECIPIENT_NAME}}', '{{RECIPIENT_COUNTRY}}', '{{TRANSFER_PURPOSE}}', '{{TRANSFER_MECHANISM}}'],
|
||||||
|
TOM: ['{{ISB_NAME}}', '{{GF_NAME}}', '{{DOCUMENT_VERSION}}', '{{NEXT_REVIEW_DATE}}'],
|
||||||
|
DPA: ['{{AG_NAME}}', '{{AG_STRASSE}}', '{{AG_PLZ_ORT}}', '{{AN_NAME}}', '{{AN_STRASSE}}', '{{AN_PLZ_ORT}}', '{{VERARBEITUNGSGEGENSTAND}}', '{{VERARBEITUNGSZWECK}}', '{{VERARBEITUNGSARTEN}}', '{{DATENKATEGORIEN}}', '{{PERSONENKATEGORIEN}}', '{{BREACH_NOTIFICATION_HOURS}}', '{{INSTRUCTION_RETENTION_YEARS}}', '{{SUB_PROCESSOR_NOTICE_WEEKS}}', '{{SUB_PROCESSOR_OBJECTION_WEEKS}}', '{{DATA_EXPORT_FORMAT}}', '{{RETURN_CHOICE_WEEKS}}', '{{DELETION_DAYS}}', '{{REACTIVATION_MONTHS}}', '{{TERMINATION_WEEKS}}', '{{AN_DSB_NAME}}', '{{AN_DSB_EMAIL}}', '{{AG_ORT}}', '{{AN_ORT}}', '{{VERTRAGSDATUM}}', '{{AG_UNTERZEICHNER_NAME}}', '{{AG_UNTERZEICHNER_FUNKTION}}', '{{AN_UNTERZEICHNER_NAME}}', '{{AN_UNTERZEICHNER_FUNKTION}}', '{{GERICHTSSTAND}}'],
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -167,6 +167,84 @@ export interface FeaturesCtx {
|
|||||||
SUPPORT_CHANNELS_TEXT: string
|
SUPPORT_CHANNELS_TEXT: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TOMCtx {
|
||||||
|
ISB_NAME: string
|
||||||
|
GF_NAME: string
|
||||||
|
DOCUMENT_VERSION: string
|
||||||
|
NEXT_REVIEW_DATE: string
|
||||||
|
// Conditional blocks
|
||||||
|
HAS_PHYSICAL_TRANSPORT: boolean
|
||||||
|
HAS_THIRD_COUNTRY_TRANSFER: boolean
|
||||||
|
HAS_CLOUD_SERVICES: boolean
|
||||||
|
HAS_MFA: boolean
|
||||||
|
HAS_USB_LOCKED: boolean
|
||||||
|
HAS_MOBILE_MEDIA: boolean
|
||||||
|
HAS_FOUR_EYES_DELETE: boolean
|
||||||
|
HAS_EXTERNAL_DESTRUCTION: boolean
|
||||||
|
HAS_REDUNDANCY: boolean
|
||||||
|
HAS_GEO_REDUNDANCY: boolean
|
||||||
|
HAS_USV: boolean
|
||||||
|
HAS_OWN_SERVER_ROOM: boolean
|
||||||
|
HAS_MULTI_TENANT: boolean
|
||||||
|
HAS_TEST_DATA_ANONYMIZED: boolean
|
||||||
|
// Selects
|
||||||
|
LOG_RETENTION_MONTHS: number | ''
|
||||||
|
DIN_66399_LEVEL: string
|
||||||
|
AVAILABILITY_TARGET: string
|
||||||
|
SEPARATION_TYPE: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DPACtx {
|
||||||
|
// Parties
|
||||||
|
AG_NAME: string
|
||||||
|
AG_STRASSE: string
|
||||||
|
AG_PLZ_ORT: string
|
||||||
|
AN_NAME: string
|
||||||
|
AN_STRASSE: string
|
||||||
|
AN_PLZ_ORT: string
|
||||||
|
// Processing details
|
||||||
|
VERARBEITUNGSGEGENSTAND: string
|
||||||
|
VERARBEITUNGSZWECK: string
|
||||||
|
VERARBEITUNGSARTEN: string
|
||||||
|
DATENKATEGORIEN: string
|
||||||
|
PERSONENKATEGORIEN: string
|
||||||
|
// Timings
|
||||||
|
BREACH_NOTIFICATION_HOURS: number | ''
|
||||||
|
INSTRUCTION_RETENTION_YEARS: number | ''
|
||||||
|
SUB_PROCESSOR_NOTICE_WEEKS: number | ''
|
||||||
|
SUB_PROCESSOR_OBJECTION_WEEKS: number | ''
|
||||||
|
RETURN_CHOICE_WEEKS: number | ''
|
||||||
|
DELETION_DAYS: number | ''
|
||||||
|
REACTIVATION_MONTHS: number | ''
|
||||||
|
TERMINATION_WEEKS: number | ''
|
||||||
|
CHANGE_NOTICE_WEEKS: number | ''
|
||||||
|
THIRD_COUNTRY_OBJECTION_WEEKS: number | ''
|
||||||
|
// Data return
|
||||||
|
DATA_EXPORT_FORMAT: string
|
||||||
|
// DSB
|
||||||
|
AN_DSB_NAME: string
|
||||||
|
AN_DSB_EMAIL: string
|
||||||
|
// Signatures
|
||||||
|
AG_ORT: string
|
||||||
|
AN_ORT: string
|
||||||
|
VERTRAGSDATUM: string
|
||||||
|
AG_UNTERZEICHNER_NAME: string
|
||||||
|
AG_UNTERZEICHNER_FUNKTION: string
|
||||||
|
AN_UNTERZEICHNER_NAME: string
|
||||||
|
AN_UNTERZEICHNER_FUNKTION: string
|
||||||
|
GERICHTSSTAND: string
|
||||||
|
// Optional clauses
|
||||||
|
HAS_LIABILITY_PROTECTION: boolean
|
||||||
|
HAS_SUPPORT_COST_CLAUSE: boolean
|
||||||
|
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: boolean
|
||||||
|
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: boolean
|
||||||
|
HAS_REACTIVATION_PERIOD: boolean
|
||||||
|
HAS_RETURN_COST_CLAUSE: boolean
|
||||||
|
HAS_GERICHTSSTAND_CLAUSE: boolean
|
||||||
|
HAS_UNILATERAL_CHANGE_RIGHT: boolean
|
||||||
|
HAS_THIRD_COUNTRY_OBJECTION: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface TemplateContext {
|
export interface TemplateContext {
|
||||||
PROVIDER: ProviderCtx
|
PROVIDER: ProviderCtx
|
||||||
CUSTOMER: CustomerCtx
|
CUSTOMER: CustomerCtx
|
||||||
@@ -180,6 +258,8 @@ export interface TemplateContext {
|
|||||||
CONSENT: ConsentCtx
|
CONSENT: ConsentCtx
|
||||||
HOSTING: HostingCtx
|
HOSTING: HostingCtx
|
||||||
FEATURES: FeaturesCtx
|
FEATURES: FeaturesCtx
|
||||||
|
TOM: TOMCtx
|
||||||
|
DPA: DPACtx
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComputedFlags {
|
export interface ComputedFlags {
|
||||||
@@ -263,6 +343,37 @@ export const EMPTY_CONTEXT: TemplateContext = {
|
|||||||
LIMITATION_CAP_TEXT: '', HAS_WITHDRAWAL: false, CONSUMER_WITHDRAWAL_TEXT: '',
|
LIMITATION_CAP_TEXT: '', HAS_WITHDRAWAL: false, CONSUMER_WITHDRAWAL_TEXT: '',
|
||||||
SUPPORT_CHANNELS_TEXT: '',
|
SUPPORT_CHANNELS_TEXT: '',
|
||||||
},
|
},
|
||||||
|
TOM: {
|
||||||
|
ISB_NAME: '', GF_NAME: '', DOCUMENT_VERSION: '1.0.0', NEXT_REVIEW_DATE: '',
|
||||||
|
HAS_PHYSICAL_TRANSPORT: false, HAS_THIRD_COUNTRY_TRANSFER: false,
|
||||||
|
HAS_CLOUD_SERVICES: false, HAS_MFA: true, HAS_USB_LOCKED: false,
|
||||||
|
HAS_MOBILE_MEDIA: false, HAS_FOUR_EYES_DELETE: false,
|
||||||
|
HAS_EXTERNAL_DESTRUCTION: false, HAS_REDUNDANCY: false,
|
||||||
|
HAS_GEO_REDUNDANCY: false, HAS_USV: true, HAS_OWN_SERVER_ROOM: false,
|
||||||
|
HAS_MULTI_TENANT: false, HAS_TEST_DATA_ANONYMIZED: true,
|
||||||
|
LOG_RETENTION_MONTHS: 6, DIN_66399_LEVEL: '3',
|
||||||
|
AVAILABILITY_TARGET: '99.5', SEPARATION_TYPE: 'logisch',
|
||||||
|
},
|
||||||
|
DPA: {
|
||||||
|
AG_NAME: '', AG_STRASSE: '', AG_PLZ_ORT: '',
|
||||||
|
AN_NAME: '', AN_STRASSE: '', AN_PLZ_ORT: '',
|
||||||
|
VERARBEITUNGSGEGENSTAND: '', VERARBEITUNGSZWECK: '', VERARBEITUNGSARTEN: '',
|
||||||
|
DATENKATEGORIEN: '', PERSONENKATEGORIEN: '',
|
||||||
|
BREACH_NOTIFICATION_HOURS: 24, INSTRUCTION_RETENTION_YEARS: 3,
|
||||||
|
SUB_PROCESSOR_NOTICE_WEEKS: 2, SUB_PROCESSOR_OBJECTION_WEEKS: 2,
|
||||||
|
RETURN_CHOICE_WEEKS: 4, DELETION_DAYS: 90, REACTIVATION_MONTHS: 3,
|
||||||
|
TERMINATION_WEEKS: 4, CHANGE_NOTICE_WEEKS: 4, THIRD_COUNTRY_OBJECTION_WEEKS: 3,
|
||||||
|
DATA_EXPORT_FORMAT: 'CSV/JSON', AN_DSB_NAME: '', AN_DSB_EMAIL: '',
|
||||||
|
AG_ORT: '', AN_ORT: '', VERTRAGSDATUM: '',
|
||||||
|
AG_UNTERZEICHNER_NAME: '', AG_UNTERZEICHNER_FUNKTION: 'Geschaeftsfuehrer',
|
||||||
|
AN_UNTERZEICHNER_NAME: '', AN_UNTERZEICHNER_FUNKTION: 'Geschaeftsfuehrer',
|
||||||
|
GERICHTSSTAND: '',
|
||||||
|
HAS_LIABILITY_PROTECTION: false, HAS_SUPPORT_COST_CLAUSE: false,
|
||||||
|
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: true, HAS_SUB_PROCESSOR_TERMINATION_RIGHT: false,
|
||||||
|
HAS_REACTIVATION_PERIOD: true, HAS_RETURN_COST_CLAUSE: false,
|
||||||
|
HAS_GERICHTSSTAND_CLAUSE: true, HAS_UNILATERAL_CHANGE_RIGHT: false,
|
||||||
|
HAS_THIRD_COUNTRY_OBJECTION: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"document_type": "ai_usage_policy",
|
||||||
|
"language": "de",
|
||||||
|
"context": {
|
||||||
|
"PROVIDER": { "LEGAL_NAME": "Muster GmbH" },
|
||||||
|
"FEATURES": {
|
||||||
|
"APPROVED_AI_SYSTEMS": "ChatGPT (OpenAI), GitHub Copilot, DeepL Pro",
|
||||||
|
"HAS_APPROVED_AI_LIST": true,
|
||||||
|
"HAS_AI_LABELING_INTERNAL": true,
|
||||||
|
"HAS_TDM_OPTOUT": true
|
||||||
|
},
|
||||||
|
"TOM": { "DOCUMENT_VERSION": "1.0.0", "NEXT_REVIEW_DATE": "2026-11-01" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"document_type": "dpa",
|
||||||
|
"language": "de",
|
||||||
|
"context": {
|
||||||
|
"DPA": {
|
||||||
|
"AG_NAME": "Muster GmbH",
|
||||||
|
"AG_STRASSE": "Musterstrasse 1",
|
||||||
|
"AG_PLZ_ORT": "10115 Berlin",
|
||||||
|
"AN_NAME": "BreakPilot GmbH",
|
||||||
|
"AN_STRASSE": "Hardtring 6",
|
||||||
|
"AN_PLZ_ORT": "78224 Singen",
|
||||||
|
"VERARBEITUNGSGEGENSTAND": "Bereitstellung und Betrieb einer SaaS-Compliance-Plattform",
|
||||||
|
"VERARBEITUNGSZWECK": "Compliance-Management, Dokumentengenerierung, Risikobewertung",
|
||||||
|
"VERARBEITUNGSARTEN": "Erheben, Speichern, Veraendern, Auslesen, Abfragen, Uebermitteln, Loeschen",
|
||||||
|
"DATENKATEGORIEN": "Stammdaten, Kontaktdaten, Vertragsdaten, Nutzungsdaten, Kommunikationsdaten",
|
||||||
|
"PERSONENKATEGORIEN": "Mitarbeitende des Auftraggebers, Kunden des Auftraggebers, Ansprechpartner",
|
||||||
|
"BREACH_NOTIFICATION_HOURS": 24,
|
||||||
|
"INSTRUCTION_RETENTION_YEARS": 3,
|
||||||
|
"SUB_PROCESSOR_NOTICE_WEEKS": 4,
|
||||||
|
"SUB_PROCESSOR_OBJECTION_WEEKS": 2,
|
||||||
|
"DATA_EXPORT_FORMAT": "CSV/JSON",
|
||||||
|
"RETURN_CHOICE_WEEKS": 4,
|
||||||
|
"DELETION_DAYS": 90,
|
||||||
|
"AN_DSB_NAME": "Max Mustermann",
|
||||||
|
"AN_DSB_EMAIL": "datenschutz@breakpilot.ai",
|
||||||
|
"VERTRAGSDATUM": "2026-05-01",
|
||||||
|
"AG_ORT": "Berlin",
|
||||||
|
"AN_ORT": "Singen",
|
||||||
|
"AG_UNTERZEICHNER_NAME": "Anna Beispiel",
|
||||||
|
"AG_UNTERZEICHNER_FUNKTION": "Geschaeftsfuehrerin",
|
||||||
|
"AN_UNTERZEICHNER_NAME": "Benjamin Boenisch",
|
||||||
|
"AN_UNTERZEICHNER_FUNKTION": "Geschaeftsfuehrer",
|
||||||
|
"GERICHTSSTAND": "Singen"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"document_type": "employee_dsi",
|
||||||
|
"language": "de",
|
||||||
|
"context": {
|
||||||
|
"PROVIDER": {
|
||||||
|
"LEGAL_NAME": "Muster GmbH",
|
||||||
|
"LEGAL_FORM": "GmbH",
|
||||||
|
"ADDRESS_LINE": "Musterstrasse 1",
|
||||||
|
"POSTAL_CODE": "10115",
|
||||||
|
"CITY": "Berlin",
|
||||||
|
"COUNTRY": "DE",
|
||||||
|
"EMAIL": "info@muster.de",
|
||||||
|
"PHONE": "+49 30 123456"
|
||||||
|
},
|
||||||
|
"PRIVACY": {
|
||||||
|
"DPO_NAME": "Dr. Datenschutz",
|
||||||
|
"DPO_EMAIL": "dsb@muster.de",
|
||||||
|
"SUPERVISORY_AUTHORITY_NAME": "Berliner Beauftragte fuer Datenschutz"
|
||||||
|
},
|
||||||
|
"FEATURES": {
|
||||||
|
"HAS_IT_USAGE_MONITORING": true,
|
||||||
|
"HAS_COMPANY_VEHICLE": false,
|
||||||
|
"HAS_ACCESS_CONTROL": true,
|
||||||
|
"HAS_VIDEO_SURVEILLANCE": false,
|
||||||
|
"HAS_COMPANY_PENSION": true,
|
||||||
|
"HAS_EXTERNAL_HR_SOFTWARE": true,
|
||||||
|
"HAS_WORKS_COUNCIL": false,
|
||||||
|
"HAS_SPECIAL_CATEGORIES_EMPLOYEES": true,
|
||||||
|
"DATA_SUBJECT_REQUEST_CHANNEL": "per E-Mail an dsb@muster.de"
|
||||||
|
},
|
||||||
|
"SECURITY": { "LOG_RETENTION_DAYS": 90 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"document_type": "social_media_dsi",
|
||||||
|
"language": "de",
|
||||||
|
"context": {
|
||||||
|
"PROVIDER": {
|
||||||
|
"LEGAL_NAME": "Muster GmbH",
|
||||||
|
"WEBSITE_URL": "https://www.muster.de",
|
||||||
|
"EMAIL": "info@muster.de",
|
||||||
|
"PHONE": "+49 30 123456"
|
||||||
|
},
|
||||||
|
"PRIVACY": {
|
||||||
|
"DPO_EMAIL": "dsb@muster.de",
|
||||||
|
"SUPERVISORY_AUTHORITY_NAME": "Berliner Beauftragte fuer Datenschutz",
|
||||||
|
"SUPERVISORY_AUTHORITY_ADDRESS": "Friedrichstr. 219, 10969 Berlin"
|
||||||
|
},
|
||||||
|
"FEATURES": {
|
||||||
|
"HAS_FACEBOOK": true,
|
||||||
|
"HAS_YOUTUBE": true,
|
||||||
|
"HAS_LINKEDIN": true,
|
||||||
|
"HAS_TIKTOK": false,
|
||||||
|
"HAS_X_TWITTER": false,
|
||||||
|
"HAS_META_PIXEL": true,
|
||||||
|
"HAS_RECRUITING_VIA_SOCIAL": true,
|
||||||
|
"SOCIAL_MEDIA_PLATFORMS_LIST": "Facebook, Instagram, YouTube und LinkedIn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"document_type": "transfer_impact_assessment",
|
||||||
|
"language": "de",
|
||||||
|
"context": {
|
||||||
|
"PROVIDER": { "LEGAL_NAME": "Muster GmbH" },
|
||||||
|
"PRIVACY": { "DPO_NAME": "Dr. Datenschutz", "DPO_EMAIL": "dsb@muster.de" },
|
||||||
|
"FEATURES": {
|
||||||
|
"RECIPIENT_NAME": "Cloud Provider Inc.",
|
||||||
|
"RECIPIENT_COUNTRY": "US",
|
||||||
|
"RECIPIENT_ROLE": "Auftragsverarbeiter",
|
||||||
|
"TRANSFER_PURPOSE": "Hosting der Anwendungsdaten",
|
||||||
|
"TRANSFER_MECHANISM": "EU-Standardvertragsklauseln (SCC) + EU-US DPF",
|
||||||
|
"DATA_CATEGORIES_TRANSFERRED": "Stammdaten, Kontaktdaten, Nutzungsdaten",
|
||||||
|
"DATA_SUBJECTS": "Kunden, Nutzer der Plattform",
|
||||||
|
"TRANSFER_FREQUENCY": "Kontinuierlich (Echtzeit-Datenverarbeitung)"
|
||||||
|
},
|
||||||
|
"TOM": { "GF_NAME": "Max Geschaeftsfuehrer", "DOCUMENT_VERSION": "1.0.0", "NEXT_REVIEW_DATE": "2027-05-01" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"document_type": "tom_documentation",
|
||||||
|
"language": "de",
|
||||||
|
"context": {
|
||||||
|
"TOM": {
|
||||||
|
"ISB_NAME": "Thomas Sicher",
|
||||||
|
"GF_NAME": "Benjamin Boenisch",
|
||||||
|
"DOCUMENT_VERSION": "2.0.0",
|
||||||
|
"NEXT_REVIEW_DATE": "2027-05-01",
|
||||||
|
"HAS_MFA": true,
|
||||||
|
"HAS_USB_LOCKED": false,
|
||||||
|
"HAS_MOBILE_MEDIA": false,
|
||||||
|
"HAS_FOUR_EYES_DELETE": true,
|
||||||
|
"HAS_EXTERNAL_DESTRUCTION": true,
|
||||||
|
"HAS_PHYSICAL_TRANSPORT": false,
|
||||||
|
"HAS_THIRD_COUNTRY_TRANSFER": false,
|
||||||
|
"HAS_CLOUD_SERVICES": true,
|
||||||
|
"HAS_REDUNDANCY": true,
|
||||||
|
"HAS_GEO_REDUNDANCY": false,
|
||||||
|
"HAS_USV": true,
|
||||||
|
"HAS_OWN_SERVER_ROOM": true,
|
||||||
|
"HAS_MULTI_TENANT": true,
|
||||||
|
"HAS_TEST_DATA_ANONYMIZED": true,
|
||||||
|
"LOG_RETENTION_MONTHS": 12,
|
||||||
|
"DIN_66399_LEVEL": "4",
|
||||||
|
"AVAILABILITY_TARGET": "99.9",
|
||||||
|
"SEPARATION_TYPE": "logisch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"document_type": "whistleblower_policy",
|
||||||
|
"language": "de",
|
||||||
|
"context": {
|
||||||
|
"PROVIDER": {
|
||||||
|
"LEGAL_NAME": "Muster GmbH"
|
||||||
|
},
|
||||||
|
"FEATURES": {
|
||||||
|
"WHISTLEBLOWER_CONTACT_NAME": "Dr. Maria Compliance",
|
||||||
|
"WHISTLEBLOWER_CONTACT_ROLE": "Compliance-Beauftragte / Meldestellenbeauftragte",
|
||||||
|
"WHISTLEBLOWER_EMAIL": "meldestelle@muster.de",
|
||||||
|
"WHISTLEBLOWER_PHONE": "+49 123 456789",
|
||||||
|
"WHISTLEBLOWER_URL": "https://muster.de/meldestelle",
|
||||||
|
"HAS_ANONYMOUS_REPORTING": true,
|
||||||
|
"HAS_EXTERNAL_REPORTING": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,8 +11,10 @@ import { generateAllPlaceholders } from '@/lib/sdk/document-generator/datapoint-
|
|||||||
import { loadAllTemplates } from './searchTemplates'
|
import { loadAllTemplates } from './searchTemplates'
|
||||||
import { TemplateContext, EMPTY_CONTEXT } from './contextBridge'
|
import { TemplateContext, EMPTY_CONTEXT } from './contextBridge'
|
||||||
import { CATEGORIES } from './_constants'
|
import { CATEGORIES } from './_constants'
|
||||||
|
import { getGeneratorDefaults, getProfileLabel } from './scopeDefaults'
|
||||||
import TemplateLibrary from './_components/TemplateLibrary'
|
import TemplateLibrary from './_components/TemplateLibrary'
|
||||||
import GeneratorSection from './_components/GeneratorSection'
|
import GeneratorSection from './_components/GeneratorSection'
|
||||||
|
import RecommendedDocuments from './_components/RecommendedDocuments'
|
||||||
|
|
||||||
function DocumentGeneratorPageInner() {
|
function DocumentGeneratorPageInner() {
|
||||||
const { state } = useSDK()
|
const { state } = useSDK()
|
||||||
@@ -86,6 +88,119 @@ function DocumentGeneratorPageInner() {
|
|||||||
}
|
}
|
||||||
}, [state?.companyProfile])
|
}, [state?.companyProfile])
|
||||||
|
|
||||||
|
// Pre-fill TOM/DPA context from Compliance Scope Engine
|
||||||
|
useEffect(() => {
|
||||||
|
const scopeLevel = state?.complianceScope?.determinedLevel
|
||||||
|
if (scopeLevel) {
|
||||||
|
const defaults = getGeneratorDefaults(scopeLevel, state?.companyProfile as never)
|
||||||
|
setContext((prev) => ({
|
||||||
|
...prev,
|
||||||
|
TOM: { ...prev.TOM, ...defaults.tom },
|
||||||
|
DPA: { ...prev.DPA, ...defaults.dpa },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}, [state?.complianceScope?.determinedLevel, state?.companyProfile])
|
||||||
|
|
||||||
|
// ── MODULE WIRING: Backend Banner-Config → CONSENT + FEATURES ────────────
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch real vendor/category data from backend if SDK state has no banner
|
||||||
|
if (state?.cookieBanner) return // SDK state takes priority
|
||||||
|
fetch('/api/sdk/v1/banner/admin/sites', { headers: { 'x-tenant-id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' } })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then((sites: Array<{ site_id: string }>) => {
|
||||||
|
if (!sites?.length) return
|
||||||
|
return fetch(`/api/sdk/v1/banner/config/${sites[0].site_id}`, { headers: { 'x-tenant-id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' } })
|
||||||
|
})
|
||||||
|
.then(r => r?.json())
|
||||||
|
.then(config => {
|
||||||
|
if (!config?.vendors?.length) return
|
||||||
|
const analytics = config.vendors.filter((v: { category_key: string }) => v.category_key === 'statistics' || v.category_key === 'analytics').map((v: { vendor_name: string }) => v.vendor_name)
|
||||||
|
const marketing = config.vendors.filter((v: { category_key: string }) => v.category_key === 'marketing').map((v: { vendor_name: string }) => v.vendor_name)
|
||||||
|
setContext(prev => ({
|
||||||
|
...prev,
|
||||||
|
CONSENT: {
|
||||||
|
...prev.CONSENT,
|
||||||
|
ANALYTICS_TOOLS: analytics.length > 0 ? analytics.join(', ') : prev.CONSENT.ANALYTICS_TOOLS,
|
||||||
|
MARKETING_PARTNERS: marketing.length > 0 ? marketing.join(', ') : prev.CONSENT.MARKETING_PARTNERS,
|
||||||
|
},
|
||||||
|
FEATURES: { ...prev.FEATURES, CMP_NAME: 'BreakPilot CMP', CMP_LOGS_CONSENTS: true },
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [state?.cookieBanner])
|
||||||
|
|
||||||
|
// ── MODULE WIRING: CookieBanner SDK State → CONSENT + FEATURES ──────────
|
||||||
|
useEffect(() => {
|
||||||
|
const banner = state?.cookieBanner
|
||||||
|
if (!banner) return
|
||||||
|
const cats = banner.categories || []
|
||||||
|
const analyticsTools = cats
|
||||||
|
.filter((c) => c.id === 'analytics' || c.id === 'statistics')
|
||||||
|
.flatMap((c) => c.cookies?.map((ck) => ck.name) ?? [])
|
||||||
|
const marketingTools = cats
|
||||||
|
.filter((c) => c.id === 'marketing')
|
||||||
|
.flatMap((c) => c.cookies?.map((ck) => ck.name) ?? [])
|
||||||
|
const hasFunctional = cats.some((c) => c.id === 'functional')
|
||||||
|
|
||||||
|
setContext((prev) => ({
|
||||||
|
...prev,
|
||||||
|
CONSENT: {
|
||||||
|
...prev.CONSENT,
|
||||||
|
ANALYTICS_TOOLS: analyticsTools.length > 0 ? analyticsTools.join(', ') : prev.CONSENT.ANALYTICS_TOOLS,
|
||||||
|
MARKETING_PARTNERS: marketingTools.length > 0 ? marketingTools.join(', ') : prev.CONSENT.MARKETING_PARTNERS,
|
||||||
|
},
|
||||||
|
FEATURES: {
|
||||||
|
...prev.FEATURES,
|
||||||
|
CMP_NAME: 'BreakPilot CMP',
|
||||||
|
CMP_LOGS_CONSENTS: true,
|
||||||
|
HAS_FUNCTIONAL_COOKIES: hasFunctional || prev.FEATURES.HAS_FUNCTIONAL_COOKIES,
|
||||||
|
CONSENT_WITHDRAWAL_PATH: 'Footer-Link "Cookie-Einstellungen"',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}, [state?.cookieBanner])
|
||||||
|
|
||||||
|
// ── MODULE WIRING: Loeschfristen → PRIVACY retention ──────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const policies = state?.retentionPolicies
|
||||||
|
if (!policies || policies.length === 0) return
|
||||||
|
const maxMonths = policies.reduce((max, p) => {
|
||||||
|
const match = p.retentionPeriod?.match(/(\d+)\s*(Monat|Jahr|Tag)/i)
|
||||||
|
if (!match) return max
|
||||||
|
const val = parseInt(match[1], 10)
|
||||||
|
const unit = match[2].toLowerCase()
|
||||||
|
const months = unit.startsWith('jahr') ? val * 12 : unit.startsWith('tag') ? Math.ceil(val / 30) : val
|
||||||
|
return Math.max(max, months)
|
||||||
|
}, 0)
|
||||||
|
if (maxMonths > 0) {
|
||||||
|
setContext((prev) => ({
|
||||||
|
...prev,
|
||||||
|
PRIVACY: { ...prev.PRIVACY, ANALYTICS_RETENTION_MONTHS: maxMonths },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}, [state?.retentionPolicies])
|
||||||
|
|
||||||
|
// ── MODULE WIRING: UseCases → FEATURES flags ─────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
const useCases = state?.useCases
|
||||||
|
if (!useCases || useCases.length === 0) return
|
||||||
|
const allText = useCases.map((uc) => `${uc.name} ${uc.description}`).join(' ').toLowerCase()
|
||||||
|
const hasAccount = allText.includes('account') || allText.includes('konto') || allText.includes('registrier')
|
||||||
|
const hasPayments = allText.includes('zahlung') || allText.includes('payment') || allText.includes('stripe') || allText.includes('paypal')
|
||||||
|
const hasNewsletter = allText.includes('newsletter') || allText.includes('mailchimp') || allText.includes('e-mail-marketing')
|
||||||
|
const hasSocial = allText.includes('social') || allText.includes('linkedin') || allText.includes('facebook') || allText.includes('instagram')
|
||||||
|
|
||||||
|
setContext((prev) => ({
|
||||||
|
...prev,
|
||||||
|
FEATURES: {
|
||||||
|
...prev.FEATURES,
|
||||||
|
HAS_ACCOUNT: hasAccount || prev.FEATURES.HAS_ACCOUNT,
|
||||||
|
HAS_PAYMENTS: hasPayments || prev.FEATURES.HAS_PAYMENTS,
|
||||||
|
HAS_NEWSLETTER: hasNewsletter || prev.FEATURES.HAS_NEWSLETTER,
|
||||||
|
HAS_SOCIAL_MEDIA: hasSocial || prev.FEATURES.HAS_SOCIAL_MEDIA,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}, [state?.useCases])
|
||||||
|
|
||||||
// Pre-fill extra placeholders from Einwilligungen data points
|
// Pre-fill extra placeholders from Einwilligungen data points
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedDataPointsData && selectedDataPointsData.length > 0) {
|
if (selectedDataPointsData && selectedDataPointsData.length > 0) {
|
||||||
@@ -177,6 +292,12 @@ function DocumentGeneratorPageInner() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Recommended documents based on scope profile */}
|
||||||
|
<RecommendedDocuments
|
||||||
|
allTemplates={allTemplates}
|
||||||
|
onUseTemplate={handleUseTemplate}
|
||||||
|
/>
|
||||||
|
|
||||||
<TemplateLibrary
|
<TemplateLibrary
|
||||||
allTemplates={allTemplates}
|
allTemplates={allTemplates}
|
||||||
filteredTemplates={filteredTemplates}
|
filteredTemplates={filteredTemplates}
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
/**
|
||||||
|
* Scope-basierte Generator-Defaults
|
||||||
|
*
|
||||||
|
* Nimmt ScopeDecision.determinedLevel + CompanyProfile und liefert
|
||||||
|
* vorausgefuellte TOM/DPA-Context-Werte. Alle Felder bleiben vom
|
||||||
|
* Kunden aenderbar — die Defaults sind Empfehlungen.
|
||||||
|
*
|
||||||
|
* Mapping:
|
||||||
|
* L1 = Lean Startup (≤10 MA, Cloud-only, Home Office)
|
||||||
|
* L2 = KMU Standard (11-249 MA)
|
||||||
|
* L3 = Erweitert (risikoreich oder >100 MA)
|
||||||
|
* L4 = Zertifizierungsbereit (≥250 MA oder regulierte Branche)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ComplianceDepthLevel } from '../../lib/sdk/compliance-scope-types/core-levels'
|
||||||
|
import type { CompanyProfile } from '../../lib/sdk/types'
|
||||||
|
import type { TOMCtx, DPACtx } from './contextBridge'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TOM Defaults per Level
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const TOM_DEFAULTS: Record<ComplianceDepthLevel, Partial<TOMCtx>> = {
|
||||||
|
L1: {
|
||||||
|
// Lean Startup: Cloud-only, kein eigener Serverraum, Home Office
|
||||||
|
HAS_MFA: true,
|
||||||
|
HAS_USB_LOCKED: false,
|
||||||
|
HAS_MOBILE_MEDIA: false,
|
||||||
|
HAS_FOUR_EYES_DELETE: false,
|
||||||
|
HAS_EXTERNAL_DESTRUCTION: false,
|
||||||
|
HAS_PHYSICAL_TRANSPORT: false,
|
||||||
|
HAS_THIRD_COUNTRY_TRANSFER: false,
|
||||||
|
HAS_CLOUD_SERVICES: true,
|
||||||
|
HAS_REDUNDANCY: false,
|
||||||
|
HAS_GEO_REDUNDANCY: false,
|
||||||
|
HAS_USV: false,
|
||||||
|
HAS_OWN_SERVER_ROOM: false,
|
||||||
|
HAS_MULTI_TENANT: false,
|
||||||
|
HAS_TEST_DATA_ANONYMIZED: true,
|
||||||
|
LOG_RETENTION_MONTHS: 3,
|
||||||
|
DIN_66399_LEVEL: '3',
|
||||||
|
AVAILABILITY_TARGET: '99.0',
|
||||||
|
SEPARATION_TYPE: 'logisch',
|
||||||
|
},
|
||||||
|
L2: {
|
||||||
|
// KMU Standard
|
||||||
|
HAS_MFA: true,
|
||||||
|
HAS_USB_LOCKED: false,
|
||||||
|
HAS_MOBILE_MEDIA: false,
|
||||||
|
HAS_FOUR_EYES_DELETE: false,
|
||||||
|
HAS_EXTERNAL_DESTRUCTION: false,
|
||||||
|
HAS_PHYSICAL_TRANSPORT: false,
|
||||||
|
HAS_THIRD_COUNTRY_TRANSFER: false,
|
||||||
|
HAS_CLOUD_SERVICES: true,
|
||||||
|
HAS_REDUNDANCY: false,
|
||||||
|
HAS_GEO_REDUNDANCY: false,
|
||||||
|
HAS_USV: false,
|
||||||
|
HAS_OWN_SERVER_ROOM: false,
|
||||||
|
HAS_MULTI_TENANT: false,
|
||||||
|
HAS_TEST_DATA_ANONYMIZED: true,
|
||||||
|
LOG_RETENTION_MONTHS: 6,
|
||||||
|
DIN_66399_LEVEL: '3',
|
||||||
|
AVAILABILITY_TARGET: '99.5',
|
||||||
|
SEPARATION_TYPE: 'logisch',
|
||||||
|
},
|
||||||
|
L3: {
|
||||||
|
// Erweitert
|
||||||
|
HAS_MFA: true,
|
||||||
|
HAS_USB_LOCKED: false,
|
||||||
|
HAS_MOBILE_MEDIA: false,
|
||||||
|
HAS_FOUR_EYES_DELETE: true,
|
||||||
|
HAS_EXTERNAL_DESTRUCTION: true,
|
||||||
|
HAS_PHYSICAL_TRANSPORT: false,
|
||||||
|
HAS_THIRD_COUNTRY_TRANSFER: false,
|
||||||
|
HAS_CLOUD_SERVICES: true,
|
||||||
|
HAS_REDUNDANCY: true,
|
||||||
|
HAS_GEO_REDUNDANCY: false,
|
||||||
|
HAS_USV: true,
|
||||||
|
HAS_OWN_SERVER_ROOM: true,
|
||||||
|
HAS_MULTI_TENANT: true,
|
||||||
|
HAS_TEST_DATA_ANONYMIZED: true,
|
||||||
|
LOG_RETENTION_MONTHS: 12,
|
||||||
|
DIN_66399_LEVEL: '4',
|
||||||
|
AVAILABILITY_TARGET: '99.9',
|
||||||
|
SEPARATION_TYPE: 'logisch',
|
||||||
|
},
|
||||||
|
L4: {
|
||||||
|
// Zertifizierungsbereit / Enterprise
|
||||||
|
HAS_MFA: true,
|
||||||
|
HAS_USB_LOCKED: true,
|
||||||
|
HAS_MOBILE_MEDIA: false,
|
||||||
|
HAS_FOUR_EYES_DELETE: true,
|
||||||
|
HAS_EXTERNAL_DESTRUCTION: true,
|
||||||
|
HAS_PHYSICAL_TRANSPORT: false,
|
||||||
|
HAS_THIRD_COUNTRY_TRANSFER: false,
|
||||||
|
HAS_CLOUD_SERVICES: true,
|
||||||
|
HAS_REDUNDANCY: true,
|
||||||
|
HAS_GEO_REDUNDANCY: true,
|
||||||
|
HAS_USV: true,
|
||||||
|
HAS_OWN_SERVER_ROOM: true,
|
||||||
|
HAS_MULTI_TENANT: true,
|
||||||
|
HAS_TEST_DATA_ANONYMIZED: true,
|
||||||
|
LOG_RETENTION_MONTHS: 24,
|
||||||
|
DIN_66399_LEVEL: '5',
|
||||||
|
AVAILABILITY_TARGET: '99.99',
|
||||||
|
SEPARATION_TYPE: 'logisch',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DPA Defaults per Level
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const DPA_DEFAULTS: Record<ComplianceDepthLevel, Partial<DPACtx>> = {
|
||||||
|
L1: {
|
||||||
|
BREACH_NOTIFICATION_HOURS: 48,
|
||||||
|
INSTRUCTION_RETENTION_YEARS: 3,
|
||||||
|
SUB_PROCESSOR_NOTICE_WEEKS: 2,
|
||||||
|
SUB_PROCESSOR_OBJECTION_WEEKS: 2,
|
||||||
|
DATA_EXPORT_FORMAT: 'CSV/JSON',
|
||||||
|
RETURN_CHOICE_WEEKS: 4,
|
||||||
|
DELETION_DAYS: 90,
|
||||||
|
HAS_LIABILITY_PROTECTION: false,
|
||||||
|
HAS_SUPPORT_COST_CLAUSE: false,
|
||||||
|
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: true,
|
||||||
|
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: false,
|
||||||
|
HAS_REACTIVATION_PERIOD: true,
|
||||||
|
REACTIVATION_MONTHS: 3,
|
||||||
|
HAS_RETURN_COST_CLAUSE: false,
|
||||||
|
HAS_GERICHTSSTAND_CLAUSE: false,
|
||||||
|
HAS_UNILATERAL_CHANGE_RIGHT: false,
|
||||||
|
HAS_THIRD_COUNTRY_OBJECTION: false,
|
||||||
|
},
|
||||||
|
L2: {
|
||||||
|
BREACH_NOTIFICATION_HOURS: 24,
|
||||||
|
INSTRUCTION_RETENTION_YEARS: 3,
|
||||||
|
SUB_PROCESSOR_NOTICE_WEEKS: 4,
|
||||||
|
SUB_PROCESSOR_OBJECTION_WEEKS: 2,
|
||||||
|
DATA_EXPORT_FORMAT: 'CSV/JSON',
|
||||||
|
RETURN_CHOICE_WEEKS: 4,
|
||||||
|
DELETION_DAYS: 90,
|
||||||
|
HAS_LIABILITY_PROTECTION: false,
|
||||||
|
HAS_SUPPORT_COST_CLAUSE: false,
|
||||||
|
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: true,
|
||||||
|
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: false,
|
||||||
|
HAS_REACTIVATION_PERIOD: true,
|
||||||
|
REACTIVATION_MONTHS: 3,
|
||||||
|
HAS_RETURN_COST_CLAUSE: false,
|
||||||
|
HAS_GERICHTSSTAND_CLAUSE: true,
|
||||||
|
HAS_UNILATERAL_CHANGE_RIGHT: false,
|
||||||
|
HAS_THIRD_COUNTRY_OBJECTION: false,
|
||||||
|
},
|
||||||
|
L3: {
|
||||||
|
BREACH_NOTIFICATION_HOURS: 24,
|
||||||
|
INSTRUCTION_RETENTION_YEARS: 5,
|
||||||
|
SUB_PROCESSOR_NOTICE_WEEKS: 4,
|
||||||
|
SUB_PROCESSOR_OBJECTION_WEEKS: 4,
|
||||||
|
DATA_EXPORT_FORMAT: 'CSV/JSON',
|
||||||
|
RETURN_CHOICE_WEEKS: 4,
|
||||||
|
DELETION_DAYS: 60,
|
||||||
|
HAS_LIABILITY_PROTECTION: true,
|
||||||
|
HAS_SUPPORT_COST_CLAUSE: true,
|
||||||
|
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: true,
|
||||||
|
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: true,
|
||||||
|
HAS_REACTIVATION_PERIOD: true,
|
||||||
|
REACTIVATION_MONTHS: 3,
|
||||||
|
HAS_RETURN_COST_CLAUSE: true,
|
||||||
|
HAS_GERICHTSSTAND_CLAUSE: true,
|
||||||
|
HAS_UNILATERAL_CHANGE_RIGHT: false,
|
||||||
|
HAS_THIRD_COUNTRY_OBJECTION: false,
|
||||||
|
},
|
||||||
|
L4: {
|
||||||
|
BREACH_NOTIFICATION_HOURS: 12,
|
||||||
|
INSTRUCTION_RETENTION_YEARS: 5,
|
||||||
|
SUB_PROCESSOR_NOTICE_WEEKS: 6,
|
||||||
|
SUB_PROCESSOR_OBJECTION_WEEKS: 4,
|
||||||
|
DATA_EXPORT_FORMAT: 'CSV/JSON',
|
||||||
|
RETURN_CHOICE_WEEKS: 8,
|
||||||
|
DELETION_DAYS: 30,
|
||||||
|
HAS_LIABILITY_PROTECTION: true,
|
||||||
|
HAS_SUPPORT_COST_CLAUSE: true,
|
||||||
|
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: false,
|
||||||
|
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: true,
|
||||||
|
HAS_REACTIVATION_PERIOD: false,
|
||||||
|
REACTIVATION_MONTHS: 3,
|
||||||
|
HAS_RETURN_COST_CLAUSE: true,
|
||||||
|
HAS_GERICHTSSTAND_CLAUSE: true,
|
||||||
|
HAS_UNILATERAL_CHANGE_RIGHT: false,
|
||||||
|
HAS_THIRD_COUNTRY_OBJECTION: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Public API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface GeneratorDefaults {
|
||||||
|
tom: Partial<TOMCtx>
|
||||||
|
dpa: Partial<DPACtx>
|
||||||
|
/** Which fields were set by the scope engine (for UI highlighting) */
|
||||||
|
scopeSet: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechnet Generator-Defaults basierend auf dem Compliance-Level
|
||||||
|
* und dem CompanyProfile. Alle Werte sind Vorschlaege — der Kunde
|
||||||
|
* kann sie aendern.
|
||||||
|
*/
|
||||||
|
export function getGeneratorDefaults(
|
||||||
|
level: ComplianceDepthLevel,
|
||||||
|
profile?: CompanyProfile | null,
|
||||||
|
): GeneratorDefaults {
|
||||||
|
const tomBase = { ...TOM_DEFAULTS[level] }
|
||||||
|
const dpaBase = { ...DPA_DEFAULTS[level] }
|
||||||
|
const scopeSet = new Set<string>()
|
||||||
|
|
||||||
|
// CompanyProfile-Felder in TOM/DPA uebernehmen
|
||||||
|
if (profile) {
|
||||||
|
if (profile.company_name) {
|
||||||
|
dpaBase.AN_NAME = profile.company_name
|
||||||
|
scopeSet.add('DPA.AN_NAME')
|
||||||
|
}
|
||||||
|
if (profile.address) {
|
||||||
|
dpaBase.AN_STRASSE = profile.address
|
||||||
|
scopeSet.add('DPA.AN_STRASSE')
|
||||||
|
}
|
||||||
|
if (profile.city && profile.postal_code) {
|
||||||
|
dpaBase.AN_PLZ_ORT = `${profile.postal_code} ${profile.city}`
|
||||||
|
scopeSet.add('DPA.AN_PLZ_ORT')
|
||||||
|
}
|
||||||
|
if (profile.dpo_name) {
|
||||||
|
tomBase.ISB_NAME = tomBase.ISB_NAME || ''
|
||||||
|
dpaBase.AN_DSB_NAME = profile.dpo_name
|
||||||
|
scopeSet.add('DPA.AN_DSB_NAME')
|
||||||
|
}
|
||||||
|
if (profile.dpo_email) {
|
||||||
|
dpaBase.AN_DSB_EMAIL = profile.dpo_email
|
||||||
|
scopeSet.add('DPA.AN_DSB_EMAIL')
|
||||||
|
}
|
||||||
|
if (profile.ceo_name) {
|
||||||
|
dpaBase.AN_UNTERZEICHNER_NAME = profile.ceo_name
|
||||||
|
tomBase.GF_NAME = profile.ceo_name
|
||||||
|
scopeSet.add('DPA.AN_UNTERZEICHNER_NAME')
|
||||||
|
scopeSet.add('TOM.GF_NAME')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle gesetzten TOM/DPA Felder als scope-set markieren
|
||||||
|
for (const key of Object.keys(tomBase)) {
|
||||||
|
scopeSet.add(`TOM.${key}`)
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(dpaBase)) {
|
||||||
|
scopeSet.add(`DPA.${key}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tom: tomBase, dpa: dpaBase, scopeSet }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt das empfohlene Profil-Label zurueck (fuer UI-Anzeige).
|
||||||
|
*/
|
||||||
|
export function getProfileLabel(level: ComplianceDepthLevel): string {
|
||||||
|
const labels: Record<ComplianceDepthLevel, string> = {
|
||||||
|
L1: 'Startup / Kleinstunternehmen',
|
||||||
|
L2: 'KMU Standard',
|
||||||
|
L3: 'Erweiterte Compliance',
|
||||||
|
L4: 'Zertifizierungsbereit / Enterprise',
|
||||||
|
}
|
||||||
|
return labels[level]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empfiehlt relevante Dokumenttypen basierend auf dem Compliance-Level.
|
||||||
|
* Hilft dem Kunden zu verstehen, welche Dokumente er braucht.
|
||||||
|
*/
|
||||||
|
export function getRecommendedDocuments(level: ComplianceDepthLevel): {
|
||||||
|
required: string[]
|
||||||
|
recommended: string[]
|
||||||
|
optional: string[]
|
||||||
|
} {
|
||||||
|
const always = [
|
||||||
|
'privacy_policy', 'impressum', 'agb', 'cookie_banner', 'cookie_policy',
|
||||||
|
]
|
||||||
|
const l2plus = [
|
||||||
|
'dpa', 'tom_documentation', 'vvt_register', 'loeschkonzept',
|
||||||
|
'community_guidelines', 'terms_of_use',
|
||||||
|
]
|
||||||
|
const l3plus = [
|
||||||
|
'it_security_concept', 'data_protection_concept', 'incident_response_plan',
|
||||||
|
'access_control_concept', 'backup_recovery_concept', 'logging_concept',
|
||||||
|
'risk_management_concept', 'pflichtenregister',
|
||||||
|
'password_policy', 'encryption_policy', 'information_security_policy',
|
||||||
|
'access_control_policy', 'whistleblower_policy',
|
||||||
|
'employee_dsi', 'applicant_dsi', 'ai_usage_policy',
|
||||||
|
]
|
||||||
|
const l4only = [
|
||||||
|
'isms_manual', 'cybersecurity_policy', 'byod_policy',
|
||||||
|
'dsfa', 'social_media_dsi', 'media_content_policy',
|
||||||
|
'video_conference_dsi', 'consent_texts',
|
||||||
|
'data_protection_policy', 'data_classification_policy',
|
||||||
|
'data_retention_policy', 'data_transfer_policy',
|
||||||
|
'privacy_incident_policy', 'employee_security_policy',
|
||||||
|
'security_awareness_policy', 'remote_work_policy',
|
||||||
|
'offboarding_policy', 'vendor_risk_management_policy',
|
||||||
|
'third_party_security_policy', 'supplier_security_policy',
|
||||||
|
'business_continuity_policy', 'disaster_recovery_policy',
|
||||||
|
'crisis_management_policy',
|
||||||
|
]
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case 'L1':
|
||||||
|
return { required: always, recommended: [], optional: l2plus }
|
||||||
|
case 'L2':
|
||||||
|
return { required: always, recommended: l2plus, optional: l3plus }
|
||||||
|
case 'L3':
|
||||||
|
return { required: [...always, ...l2plus], recommended: l3plus, optional: l4only }
|
||||||
|
case 'L4':
|
||||||
|
return { required: [...always, ...l2plus, ...l3plus], recommended: l4only, optional: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
/**
|
||||||
|
* Template Recommendations — Maps scope answers to document templates
|
||||||
|
*
|
||||||
|
* Bridges the gap between the Compliance Scope Engine (23 ScopeDocumentTypes)
|
||||||
|
* and the Document Generator (70+ database templates).
|
||||||
|
*
|
||||||
|
* The scope engine recommends high-level document categories (vvt, tom, dsfa...).
|
||||||
|
* This module recommends SPECIFIC templates based on additional context from
|
||||||
|
* the CompanyProfile and scope answers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ComplianceDepthLevel } from '../../lib/sdk/compliance-scope-types/core-levels'
|
||||||
|
import type { ScopeProfilingAnswer } from '../../lib/sdk/compliance-scope-types/state'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Template recommendation rules
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface TemplateRule {
|
||||||
|
/** Database document_type */
|
||||||
|
templateType: string
|
||||||
|
/** Human-readable label */
|
||||||
|
label: string
|
||||||
|
/** When to recommend this template */
|
||||||
|
condition: (answers: Map<string, string>, level: ComplianceDepthLevel, profile: Record<string, unknown>) => 'required' | 'recommended' | 'optional' | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rules that map scope answers + profile to specific template recommendations.
|
||||||
|
* These cover templates NOT directly output by the scope engine.
|
||||||
|
*/
|
||||||
|
const TEMPLATE_RULES: TemplateRule[] = [
|
||||||
|
// ── HR-DSI ──────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'employee_dsi',
|
||||||
|
label: 'Mitarbeiter-Datenschutzinformation',
|
||||||
|
condition: (answers, level) => {
|
||||||
|
const hasEmployees = answers.get('org_has_employees')
|
||||||
|
const empCount = answers.get('org_employee_count')
|
||||||
|
if (hasEmployees === 'yes' || (empCount && empCount !== 'none' && empCount !== '0')) {
|
||||||
|
return level >= 'L2' ? 'required' : 'recommended'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'applicant_dsi',
|
||||||
|
label: 'Bewerber-Datenschutzinformation',
|
||||||
|
condition: (answers, level) => {
|
||||||
|
const hasEmployees = answers.get('org_has_employees')
|
||||||
|
const empCount = answers.get('org_employee_count')
|
||||||
|
if (hasEmployees === 'yes' || (empCount && empCount !== 'none' && empCount !== '0')) {
|
||||||
|
return level >= 'L2' ? 'recommended' : 'optional'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Whistleblower ───────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'whistleblower_policy',
|
||||||
|
label: 'Hinweisgeberrichtlinie (HinSchG)',
|
||||||
|
condition: (answers) => {
|
||||||
|
const empCount = answers.get('org_employee_count')
|
||||||
|
// HinSchG Pflicht ab 50 MA
|
||||||
|
if (empCount === '50_249' || empCount === '250_999' || empCount === '1000_plus') return 'required'
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── KI ──────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'ai_usage_policy',
|
||||||
|
label: 'KI-Nutzungsrichtlinie',
|
||||||
|
condition: (answers) => {
|
||||||
|
const aiUsage = answers.get('proc_ai_usage') || answers.get('proc_uses_ai_tools')
|
||||||
|
if (aiUsage && aiUsage !== 'none' && aiUsage !== 'no') return 'required'
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── BYOD ────────────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'byod_policy',
|
||||||
|
label: 'BYOD-Richtlinie',
|
||||||
|
condition: (answers, level) => {
|
||||||
|
const byod = answers.get('proc_byod_allowed')
|
||||||
|
if (byod === 'yes') return 'required'
|
||||||
|
if (level >= 'L3') return 'recommended'
|
||||||
|
return 'optional'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Social Media ────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'social_media_dsi',
|
||||||
|
label: 'Social-Media-Datenschutzinformation',
|
||||||
|
condition: (answers, level) => {
|
||||||
|
const sm = answers.get('org_has_social_media')
|
||||||
|
if (sm === 'yes') return 'required'
|
||||||
|
return level >= 'L2' ? 'recommended' : 'optional'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Videokonferenzen ────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'video_conference_dsi',
|
||||||
|
label: 'Videokonferenz-Datenschutzinformation',
|
||||||
|
condition: (answers, level) => {
|
||||||
|
const video = answers.get('org_has_video_conferencing')
|
||||||
|
if (video === 'yes') return 'recommended'
|
||||||
|
if (level >= 'L3') return 'recommended'
|
||||||
|
return 'optional'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Security Policies (nur ab L3/L4) ───────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'information_security_policy',
|
||||||
|
label: 'Informationssicherheitsrichtlinie',
|
||||||
|
condition: (_answers, level) => {
|
||||||
|
if (level >= 'L3') return 'required'
|
||||||
|
if (level === 'L2') return 'recommended'
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'password_policy',
|
||||||
|
label: 'Passwortrichtlinie',
|
||||||
|
condition: (_answers, level) => level >= 'L2' ? 'recommended' : 'optional',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'encryption_policy',
|
||||||
|
label: 'Verschluesselungsrichtlinie',
|
||||||
|
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'access_control_policy',
|
||||||
|
label: 'Zugriffskontrollrichtlinie',
|
||||||
|
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Security Concepts (nur ab L3) ──────────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'it_security_concept',
|
||||||
|
label: 'IT-Sicherheitskonzept',
|
||||||
|
condition: (_answers, level) => level >= 'L3' ? 'required' : 'optional',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'backup_recovery_concept',
|
||||||
|
label: 'Backup-Recovery-Konzept',
|
||||||
|
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'logging_concept',
|
||||||
|
label: 'Logging-Konzept',
|
||||||
|
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'access_control_concept',
|
||||||
|
label: 'Zugriffskonzept',
|
||||||
|
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Plattform/UGC ──────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'community_guidelines',
|
||||||
|
label: 'Gemeinschaftsrichtlinien',
|
||||||
|
condition: (answers) => {
|
||||||
|
const model = answers.get('org_business_model')
|
||||||
|
const ugc = answers.get('prod_ugc_platform')
|
||||||
|
if (ugc === 'yes' || model === 'platform' || model === 'marketplace' || model === 'social') return 'required'
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'terms_of_use',
|
||||||
|
label: 'Nutzungsbedingungen',
|
||||||
|
condition: (answers) => {
|
||||||
|
const model = answers.get('org_business_model')
|
||||||
|
const ugc = answers.get('prod_ugc_platform')
|
||||||
|
if (ugc === 'yes' || model === 'platform' || model === 'marketplace' || model === 'social' || model === 'saas') return 'required'
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'media_content_policy',
|
||||||
|
label: 'Medien- und Inhalte-Richtlinie',
|
||||||
|
condition: (answers) => {
|
||||||
|
const model = answers.get('org_business_model')
|
||||||
|
if (model === 'platform' || model === 'media') return 'recommended'
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── E-Commerce ─────────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'widerruf',
|
||||||
|
label: 'Widerrufsbelehrung',
|
||||||
|
condition: (answers) => {
|
||||||
|
const shop = answers.get('prod_webshop')
|
||||||
|
if (shop && shop !== 'no') return 'required'
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'consent_texts',
|
||||||
|
label: 'Einwilligungstexte (Double-Opt-In)',
|
||||||
|
condition: (answers) => {
|
||||||
|
const consent = answers.get('prod_consent_management')
|
||||||
|
if (consent && consent !== 'no') return 'recommended'
|
||||||
|
return 'optional'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Impressum + Cookie ─────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'impressum',
|
||||||
|
label: 'Impressum',
|
||||||
|
condition: () => 'required', // Immer Pflicht
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'cookie_policy',
|
||||||
|
label: 'Cookie-Richtlinie',
|
||||||
|
condition: () => 'required', // Immer Pflicht bei Websites
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Drittlandtransfer (SCC + TIA) ───────────────────────────────────────
|
||||||
|
// SCC+TIA nur erforderlich wenn Drittlandtransfer OHNE Angemessenheitsbeschluss/DPF
|
||||||
|
{
|
||||||
|
templateType: 'transfer_impact_assessment',
|
||||||
|
label: 'Transfer Impact Assessment (TIA)',
|
||||||
|
condition: (answers) => {
|
||||||
|
const thirdCountry = answers.get('tech_third_country')
|
||||||
|
if (!thirdCountry || thirdCountry === 'no') return null
|
||||||
|
// Wenn nur DPF-zertifizierte US-Anbieter: empfohlen statt pflicht
|
||||||
|
if (thirdCountry === 'us_dpf_only') return 'optional'
|
||||||
|
// Wenn nur Laender mit Angemessenheitsbeschluss: nicht noetig
|
||||||
|
if (thirdCountry === 'adequate_only') return null
|
||||||
|
return 'required'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'scc_companion',
|
||||||
|
label: 'Standardvertragsklauseln (SCC) — Anhaenge',
|
||||||
|
condition: (answers) => {
|
||||||
|
const thirdCountry = answers.get('tech_third_country')
|
||||||
|
if (!thirdCountry || thirdCountry === 'no') return null
|
||||||
|
if (thirdCountry === 'us_dpf_only') return 'optional'
|
||||||
|
if (thirdCountry === 'adequate_only') return null
|
||||||
|
return 'required'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── ISMS (nur bei Zertifizierungsziel) ─────────────────────────────────
|
||||||
|
{
|
||||||
|
templateType: 'isms_manual',
|
||||||
|
label: 'ISMS-Handbuch',
|
||||||
|
condition: (answers) => {
|
||||||
|
const cert = answers.get('org_cert_target')
|
||||||
|
if (cert === 'iso27001' || cert === 'iso27701' || cert === 'tisax') return 'required'
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Vendor/BCM (nur ab L4 oder bei Vendor-Management) ─────────────────
|
||||||
|
{
|
||||||
|
templateType: 'vendor_risk_management_policy',
|
||||||
|
label: 'Vendor-Risikomanagement',
|
||||||
|
condition: (answers, level) => {
|
||||||
|
const vendor = answers.get('comp_vendor_management')
|
||||||
|
if (vendor && vendor !== 'no') return 'recommended'
|
||||||
|
if (level === 'L4') return 'required'
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
templateType: 'business_continuity_policy',
|
||||||
|
label: 'Business-Continuity-Richtlinie',
|
||||||
|
condition: (_answers, level) => level === 'L4' ? 'required' : 'optional',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Public API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface TemplateRecommendation {
|
||||||
|
templateType: string
|
||||||
|
label: string
|
||||||
|
requirement: 'required' | 'recommended' | 'optional'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates all template rules against the user's scope answers and profile.
|
||||||
|
* Returns a prioritized list of template recommendations.
|
||||||
|
*/
|
||||||
|
export function evaluateTemplateRecommendations(
|
||||||
|
scopeAnswers: ScopeProfilingAnswer[],
|
||||||
|
level: ComplianceDepthLevel,
|
||||||
|
profile: Record<string, unknown> = {},
|
||||||
|
): TemplateRecommendation[] {
|
||||||
|
const answerMap = new Map<string, string>()
|
||||||
|
for (const a of scopeAnswers) {
|
||||||
|
answerMap.set(a.questionId, String(a.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: TemplateRecommendation[] = []
|
||||||
|
|
||||||
|
for (const rule of TEMPLATE_RULES) {
|
||||||
|
const requirement = rule.condition(answerMap, level, profile)
|
||||||
|
if (requirement) {
|
||||||
|
results.push({
|
||||||
|
templateType: rule.templateType,
|
||||||
|
label: rule.label,
|
||||||
|
requirement,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: required first, then recommended, then optional
|
||||||
|
const order = { required: 0, recommended: 1, optional: 2 }
|
||||||
|
results.sort((a, b) => order[a.requirement] - order[b.requirement])
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
@@ -2,16 +2,38 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import type { DSFA } from './DSFACard'
|
import type { DSFA } from './DSFACard'
|
||||||
|
import type { DSFAPrefillResult } from '@/lib/sdk/dsfa/prefill-from-scope'
|
||||||
|
|
||||||
export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; onSubmit: (data: Partial<DSFA>) => Promise<void> }) {
|
interface GeneratorWizardProps {
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (data: Partial<DSFA>) => Promise<void>
|
||||||
|
prefill?: DSFAPrefillResult | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GeneratorWizard({ onClose, onSubmit, prefill }: GeneratorWizardProps) {
|
||||||
const [step, setStep] = useState(1)
|
const [step, setStep] = useState(1)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState(prefill?.title || '')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState(prefill?.description || '')
|
||||||
const [processingActivity, setProcessingActivity] = useState('')
|
const [processingActivity, setProcessingActivity] = useState(prefill?.processingActivity || '')
|
||||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
|
const [selectedCategories, setSelectedCategories] = useState<string[]>(prefill?.dataCategories || [])
|
||||||
const [riskLevel, setRiskLevel] = useState<'low' | 'medium' | 'high' | 'critical'>('low')
|
const riskMap2: Record<string, 'low' | 'medium' | 'high' | 'critical'> = { niedrig: 'low', mittel: 'medium', hoch: 'high', kritisch: 'critical' }
|
||||||
const [selectedMeasures, setSelectedMeasures] = useState<string[]>([])
|
const [riskLevel, setRiskLevel] = useState<'low' | 'medium' | 'high' | 'critical'>(riskMap2[prefill?.riskLevel || ''] || 'low')
|
||||||
|
const [residualRisk, setResidualRisk] = useState<'low' | 'medium' | 'high' | 'critical'>('low')
|
||||||
|
const [selectedMeasures, setSelectedMeasures] = useState<string[]>(prefill?.measures || [])
|
||||||
|
const [linkedVvtId, setLinkedVvtId] = useState('')
|
||||||
|
const [vvtActivities, setVvtActivities] = useState<Array<{ id: string; name: string }>>([])
|
||||||
|
|
||||||
|
// Load VVT activities for linking
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetch('/api/sdk/v1/compliance/vvt')
|
||||||
|
.then(r => r.ok ? r.json() : [])
|
||||||
|
.then(data => {
|
||||||
|
const items = Array.isArray(data) ? data : data.activities || []
|
||||||
|
setVvtActivities(items.map((a: any) => ({ id: a.id, name: a.name || a.processing_name || a.title || 'Unbenannt' })))
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
const riskMap: Record<string, 'low' | 'medium' | 'high' | 'critical'> = {
|
const riskMap: Record<string, 'low' | 'medium' | 'high' | 'critical'> = {
|
||||||
Niedrig: 'low', Mittel: 'medium', Hoch: 'high', Kritisch: 'critical',
|
Niedrig: 'low', Mittel: 'medium', Hoch: 'high', Kritisch: 'critical',
|
||||||
@@ -28,7 +50,12 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
|||||||
riskLevel,
|
riskLevel,
|
||||||
measures: selectedMeasures,
|
measures: selectedMeasures,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
})
|
...(prefill?.federalState ? { federal_state: prefill.federalState } : {}),
|
||||||
|
...(prefill?.involvesAi ? { involves_ai: true } : {}),
|
||||||
|
...(prefill?.legalBasis ? { legal_basis: prefill.legalBasis } : {}),
|
||||||
|
...(linkedVvtId ? { linked_vvt_id: linkedVvtId } : {}),
|
||||||
|
...(residualRisk !== 'low' ? { residual_risk_level: residualRisk } : {}),
|
||||||
|
} as Partial<DSFA>)
|
||||||
onClose()
|
onClose()
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
@@ -48,7 +75,7 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
|||||||
|
|
||||||
{/* Progress Steps */}
|
{/* Progress Steps */}
|
||||||
<div className="flex items-center gap-2 mb-6">
|
<div className="flex items-center gap-2 mb-6">
|
||||||
{[1, 2, 3, 4].map(s => (
|
{[1, 2, 3, 4, 5].map(s => (
|
||||||
<React.Fragment key={s}>
|
<React.Fragment key={s}>
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||||
s < step ? 'bg-green-500 text-white' :
|
s < step ? 'bg-green-500 text-white' :
|
||||||
@@ -60,7 +87,7 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
|||||||
</svg>
|
</svg>
|
||||||
) : s}
|
) : s}
|
||||||
</div>
|
</div>
|
||||||
{s < 4 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
|
{s < 5 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -89,6 +116,20 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
|||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{vvtActivities.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Verknuepfte VVT-Aktivitaet (Art. 30)</label>
|
||||||
|
<select value={linkedVvtId} onChange={e => {
|
||||||
|
setLinkedVvtId(e.target.value)
|
||||||
|
const selected = vvtActivities.find(a => a.id === e.target.value)
|
||||||
|
if (selected && !processingActivity) setProcessingActivity(selected.name)
|
||||||
|
}} className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 bg-white">
|
||||||
|
<option value="">— Keine Verknuepfung —</option>
|
||||||
|
{vvtActivities.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">Ordnen Sie diese DSFA einer VVT-Verarbeitungstaetigkeit zu.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungstaetigkeit</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungstaetigkeit</label>
|
||||||
<input
|
<input
|
||||||
@@ -167,6 +208,43 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{step === 5 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Restrisiko nach Massnahmen</label>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
|
Bewerten Sie das verbleibende Risiko NACH Umsetzung der Schutzmassnahmen.
|
||||||
|
Bei hohem Restrisiko → Art. 36 Vorabkonsultation der Aufsichtsbehoerde.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{[
|
||||||
|
{ value: 'low' as const, label: 'Niedrig', desc: 'Risiko ausreichend gemindert', color: 'border-green-300 bg-green-50' },
|
||||||
|
{ value: 'medium' as const, label: 'Mittel', desc: 'Akzeptables Restrisiko', color: 'border-yellow-300 bg-yellow-50' },
|
||||||
|
{ value: 'high' as const, label: 'Hoch', desc: 'Art. 36 Konsultation pruefen', color: 'border-orange-300 bg-orange-50' },
|
||||||
|
{ value: 'critical' as const, label: 'Kritisch', desc: 'Art. 36 Konsultation PFLICHT', color: 'border-red-300 bg-red-50' },
|
||||||
|
].map(r => (
|
||||||
|
<label key={r.value} className={`flex items-start gap-2 p-3 border-2 rounded-lg cursor-pointer ${
|
||||||
|
residualRisk === r.value ? r.color : 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}>
|
||||||
|
<input type="radio" name="residualRisk" value={r.value} checked={residualRisk === r.value}
|
||||||
|
onChange={() => setResidualRisk(r.value)} className="mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">{r.label}</span>
|
||||||
|
<p className="text-xs text-gray-500">{r.desc}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{(residualRisk === 'high' || residualRisk === 'critical') && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-red-700 font-medium">Vorabkonsultation erforderlich (Art. 36 DSGVO)</p>
|
||||||
|
<p className="text-xs text-red-600 mt-1">
|
||||||
|
Bei hohem Restrisiko muss die Aufsichtsbehoerde VOR Beginn der Verarbeitung konsultiert werden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
@@ -179,11 +257,11 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
|||||||
{step === 1 ? 'Abbrechen' : 'Zurueck'}
|
{step === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => step < 4 ? setStep(step + 1) : handleSubmit()}
|
onClick={() => step < 5 ? setStep(step + 1) : handleSubmit()}
|
||||||
disabled={saving || (step === 1 && !title.trim())}
|
disabled={saving || (step === 1 && !title.trim())}
|
||||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{step === 4 ? (saving ? 'Wird erstellt...' : 'DSFA erstellen') : 'Weiter'}
|
{step === 5 ? (saving ? 'Wird erstellt...' : 'DSFA erstellen') : 'Weiter'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from 'react'
|
import { useState, useCallback, useEffect, useMemo } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useSDK } from '@/lib/sdk'
|
import { useSDK } from '@/lib/sdk'
|
||||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||||
import { DocumentUploadSection, type UploadedDocument } from '@/components/sdk'
|
import { DocumentUploadSection, type UploadedDocument } from '@/components/sdk'
|
||||||
import { DSFACard, type DSFA } from './_components/DSFACard'
|
import { DSFACard, type DSFA } from './_components/DSFACard'
|
||||||
import { GeneratorWizard } from './_components/GeneratorWizard'
|
import { GeneratorWizard } from './_components/GeneratorWizard'
|
||||||
|
import { prefillDSFAFromScope, isDSFARequired } from '@/lib/sdk/dsfa/prefill-from-scope'
|
||||||
|
|
||||||
export default function DSFAPage() {
|
export default function DSFAPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -17,6 +18,17 @@ export default function DSFAPage() {
|
|||||||
const [showGenerator, setShowGenerator] = useState(false)
|
const [showGenerator, setShowGenerator] = useState(false)
|
||||||
const [filter, setFilter] = useState<string>('all')
|
const [filter, setFilter] = useState<string>('all')
|
||||||
|
|
||||||
|
// Pre-fill from Company Profile + Scope answers
|
||||||
|
const scopeAnswers = state.complianceScope?.answers || []
|
||||||
|
const prefill = useMemo(
|
||||||
|
() => prefillDSFAFromScope(state.companyProfile || null, scopeAnswers),
|
||||||
|
[state.companyProfile, scopeAnswers]
|
||||||
|
)
|
||||||
|
const dsfaCheck = useMemo(
|
||||||
|
() => isDSFARequired(scopeAnswers, state.companyProfile?.headquartersState),
|
||||||
|
[scopeAnswers, state.companyProfile?.headquartersState]
|
||||||
|
)
|
||||||
|
|
||||||
const loadDSFAs = useCallback(async () => {
|
const loadDSFAs = useCallback(async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -120,10 +132,42 @@ export default function DSFAPage() {
|
|||||||
)}
|
)}
|
||||||
</StepHeader>
|
</StepHeader>
|
||||||
|
|
||||||
|
{/* DSFA Requirement Check */}
|
||||||
|
{dsfaCheck.required && dsfas.length === 0 && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-xl p-5">
|
||||||
|
<h3 className="font-semibold text-red-800">DSFA erforderlich (Art. 35 DSGVO)</h3>
|
||||||
|
<p className="text-sm text-red-700 mt-1">Basierend auf Ihrem Scope-Profiling wurde festgestellt:</p>
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{dsfaCheck.triggers.map(t => (
|
||||||
|
<li key={t} className="text-sm text-red-600 flex items-center gap-2">
|
||||||
|
<span className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0" />
|
||||||
|
{t}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{dsfaCheck.blacklistMatches.length > 0 && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-red-200">
|
||||||
|
<p className="text-xs font-medium text-red-800 mb-1">
|
||||||
|
Blacklist {dsfaCheck.authority || 'Aufsichtsbehoerde'} (Art. 35 Abs. 4):
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{dsfaCheck.blacklistMatches.map(m => (
|
||||||
|
<li key={m} className="text-xs text-red-600 flex items-center gap-2">
|
||||||
|
<span className="w-1 h-1 bg-red-400 rounded-full flex-shrink-0" />
|
||||||
|
{m}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showGenerator && (
|
{showGenerator && (
|
||||||
<GeneratorWizard
|
<GeneratorWizard
|
||||||
onClose={() => setShowGenerator(false)}
|
onClose={() => setShowGenerator(false)}
|
||||||
onSubmit={handleCreateDSFA}
|
onSubmit={handleCreateDSFA}
|
||||||
|
prefill={prefill}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export function ActionButtons({
|
|||||||
onExtendDeadline,
|
onExtendDeadline,
|
||||||
onComplete,
|
onComplete,
|
||||||
onReject,
|
onReject,
|
||||||
onAssign
|
onAssign,
|
||||||
|
onRejectArt11,
|
||||||
}: {
|
}: {
|
||||||
request: DSRRequest
|
request: DSRRequest
|
||||||
onVerifyIdentity: () => void
|
onVerifyIdentity: () => void
|
||||||
@@ -17,15 +18,31 @@ export function ActionButtons({
|
|||||||
onComplete: () => void
|
onComplete: () => void
|
||||||
onReject: () => void
|
onReject: () => void
|
||||||
onAssign: () => void
|
onAssign: () => void
|
||||||
|
onRejectArt11?: () => void
|
||||||
}) {
|
}) {
|
||||||
const isTerminal = request.status === 'completed' || request.status === 'rejected' || request.status === 'cancelled'
|
const isTerminal = request.status === 'completed' || request.status === 'rejected' || request.status === 'cancelled'
|
||||||
|
|
||||||
if (isTerminal) {
|
if (isTerminal) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<button className="w-full px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm">
|
<button
|
||||||
|
onClick={() => window.open(`/api/sdk/v1/dsr/${request.id}/export-user-data?format=pdf`, '_blank')}
|
||||||
|
className="w-full px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
PDF exportieren
|
PDF exportieren
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.open(`/api/sdk/v1/dsr/${request.id}/export-user-data?format=json`, '_blank')}
|
||||||
|
className="w-full px-4 py-2 text-purple-600 bg-purple-50 hover:bg-purple-100 rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
JSON exportieren (Art. 20)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.open(`/api/sdk/v1/dsr/${request.id}/export-user-data?format=csv`, '_blank')}
|
||||||
|
className="w-full px-4 py-2 text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
CSV exportieren
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -33,12 +50,23 @@ export function ActionButtons({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{!request.identityVerification.verified && (
|
{!request.identityVerification.verified && (
|
||||||
<button
|
<>
|
||||||
onClick={onVerifyIdentity}
|
<button
|
||||||
className="w-full px-4 py-2 bg-yellow-500 text-white hover:bg-yellow-600 rounded-lg transition-colors text-sm font-medium"
|
onClick={onVerifyIdentity}
|
||||||
>
|
className="w-full px-4 py-2 bg-yellow-500 text-white hover:bg-yellow-600 rounded-lg transition-colors text-sm font-medium"
|
||||||
Identitaet verifizieren
|
>
|
||||||
</button>
|
Identitaet verifizieren
|
||||||
|
</button>
|
||||||
|
{onRejectArt11 && (
|
||||||
|
<button
|
||||||
|
onClick={onRejectArt11}
|
||||||
|
className="w-full px-4 py-2 text-gray-600 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg transition-colors text-sm"
|
||||||
|
title="Person kann anhand der gespeicherten Daten nicht identifiziert werden (Art. 11 DSGVO)"
|
||||||
|
>
|
||||||
|
Nicht identifizierbar (Art. 11)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,340 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { useBannerConsents } from '../_hooks/useBannerConsents'
|
||||||
|
import { BannerConsentRecord, PAGE_SIZE } from '../_types'
|
||||||
|
|
||||||
|
const BANNER_API = '/api/sdk/v1/banner'
|
||||||
|
const TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
|
||||||
|
function formatDate(iso: string | null): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortenFingerprint(fp: string): string {
|
||||||
|
return fp.length > 12 ? fp.slice(0, 12) + '...' : fp
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortenUA(ua: string | null): string {
|
||||||
|
if (!ua) return '—'
|
||||||
|
const match = ua.match(/(Chrome|Safari|Firefox|Edge|Opera)\/[\d.]+/)
|
||||||
|
if (match) return match[0]
|
||||||
|
return ua.length > 30 ? ua.slice(0, 30) + '...' : ua
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryColors: Record<string, string> = {
|
||||||
|
essential: 'bg-gray-100 text-gray-700',
|
||||||
|
functional: 'bg-blue-100 text-blue-700',
|
||||||
|
analytics: 'bg-purple-100 text-purple-700',
|
||||||
|
marketing: 'bg-pink-100 text-pink-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
const methodLabels: Record<string, string> = {
|
||||||
|
accept_all: 'Alle akzeptiert',
|
||||||
|
reject_all: 'Nur notwendige',
|
||||||
|
custom_selection: 'Individuelle Auswahl',
|
||||||
|
}
|
||||||
|
|
||||||
|
const methodColors: Record<string, string> = {
|
||||||
|
accept_all: 'bg-green-100 text-green-700',
|
||||||
|
reject_all: 'bg-red-100 text-red-700',
|
||||||
|
custom_selection: 'bg-yellow-100 text-yellow-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BannerConsentsTab() {
|
||||||
|
const {
|
||||||
|
records, sites, selectedSite, changeSite,
|
||||||
|
stats, currentPage, setCurrentPage, totalRecords, loading, reload,
|
||||||
|
} = useBannerConsents()
|
||||||
|
|
||||||
|
const [detail, setDetail] = useState<BannerConsentRecord | null>(null)
|
||||||
|
const [linkEmailInput, setLinkEmailInput] = useState('')
|
||||||
|
const [linkingEmail, setLinkingEmail] = useState(false)
|
||||||
|
const totalPages = Math.ceil(totalRecords / PAGE_SIZE)
|
||||||
|
|
||||||
|
const withdrawConsent = useCallback(async (id: string) => {
|
||||||
|
if (!confirm('Consent wirklich widerrufen? Diese Aktion kann nicht rueckgaengig gemacht werden.')) return
|
||||||
|
await fetch(`${BANNER_API}/consent/${id}`, { method: 'DELETE', headers: { 'x-tenant-id': TENANT_ID } })
|
||||||
|
setDetail(null)
|
||||||
|
reload()
|
||||||
|
}, [reload])
|
||||||
|
|
||||||
|
const linkEmail = useCallback(async (record: BannerConsentRecord) => {
|
||||||
|
if (!linkEmailInput.includes('@')) return
|
||||||
|
setLinkingEmail(true)
|
||||||
|
await fetch(`${BANNER_API}/consent/link-email`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-tenant-id': TENANT_ID },
|
||||||
|
body: JSON.stringify({ site_id: record.site_id, device_fingerprint: record.device_fingerprint, email: linkEmailInput }),
|
||||||
|
})
|
||||||
|
setLinkingEmail(false)
|
||||||
|
setLinkEmailInput('')
|
||||||
|
setDetail({ ...record, linked_email: linkEmailInput })
|
||||||
|
reload()
|
||||||
|
}, [linkEmailInput, reload])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Stats + Site Selector */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
<span className="text-2xl font-bold text-gray-900">{totalRecords}</span> Consents
|
||||||
|
</div>
|
||||||
|
{stats && Object.keys(stats.category_acceptance).length > 0 && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{Object.entries(stats.category_acceptance).map(([cat, data]) => (
|
||||||
|
<span key={cat} className={`text-xs px-2 py-1 rounded-full ${categoryColors[cat] || 'bg-gray-100 text-gray-600'}`}>
|
||||||
|
{cat}: {data.rate}%
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{sites.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={selectedSite}
|
||||||
|
onChange={e => changeSite(e.target.value)}
|
||||||
|
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm bg-white"
|
||||||
|
>
|
||||||
|
{sites.map(s => (
|
||||||
|
<option key={s.site_id} value={s.site_id}>
|
||||||
|
{s.site_name || s.site_id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-white border border-gray-200 rounded-xl 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-500">Device</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-500">Kategorien</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-500">Methode</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-500">Erteilt am</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-500">Ablauf</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium text-gray-500">Browser</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium text-gray-500">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{loading && records.length === 0 ? (
|
||||||
|
<tr><td colSpan={7} className="px-4 py-8 text-center text-gray-400">Laden...</td></tr>
|
||||||
|
) : records.length === 0 ? (
|
||||||
|
<tr><td colSpan={7} className="px-4 py-8 text-center text-gray-400">Keine Consents vorhanden</td></tr>
|
||||||
|
) : (
|
||||||
|
records.map(record => (
|
||||||
|
<tr key={record.id} className="hover:bg-gray-50 transition-colors">
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-600">{shortenFingerprint(record.device_fingerprint)}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{record.categories.length > 0 ? record.categories.map(cat => (
|
||||||
|
<span key={cat} className={`text-xs px-2 py-0.5 rounded-full ${categoryColors[cat] || 'bg-gray-100 text-gray-600'}`}>
|
||||||
|
{cat}
|
||||||
|
</span>
|
||||||
|
)) : <span className="text-xs text-gray-400">—</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs">
|
||||||
|
{record.consent_method ? (
|
||||||
|
<span className={`px-2 py-0.5 rounded-full ${methodColors[record.consent_method] || 'bg-gray-100 text-gray-600'}`}>
|
||||||
|
{methodLabels[record.consent_method] || record.consent_method}
|
||||||
|
</span>
|
||||||
|
) : <span className="text-gray-400">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-gray-600">{formatDate(record.created_at)}</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-gray-600">{formatDate(record.expires_at)}</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-gray-500">{shortenUA(record.user_agent)}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => setDetail(record)}
|
||||||
|
className="text-xs text-purple-600 hover:text-purple-800 font-medium"
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
Seite {currentPage} von {totalPages} ({totalRecords} Einträge)
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-30"
|
||||||
|
>
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-30"
|
||||||
|
>
|
||||||
|
Weiter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Detail Modal */}
|
||||||
|
{detail && (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={() => setDetail(null)}>
|
||||||
|
<div className="bg-white rounded-2xl shadow-xl max-w-lg w-full p-6 max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-bold text-gray-900">Consent Details</h3>
|
||||||
|
<button onClick={() => setDetail(null)} className="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="flex justify-between"><span className="text-gray-500">ID</span><span className="font-mono text-xs">{detail.id}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-gray-500">Site</span><span>{detail.site_id}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-gray-500">Device</span><span className="font-mono text-xs">{detail.device_fingerprint}</span></div>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<span className="text-gray-500">Kategorien</span>
|
||||||
|
<div className="flex flex-wrap gap-1 justify-end">
|
||||||
|
{detail.categories.map(cat => (
|
||||||
|
<span key={cat} className={`text-xs px-2 py-0.5 rounded-full ${categoryColors[cat] || 'bg-gray-100'}`}>{cat}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{detail.vendor_consents && Object.keys(detail.vendor_consents).length > 0 && (
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<span className="text-gray-500">Vendors</span>
|
||||||
|
<div className="flex flex-wrap gap-1 justify-end">
|
||||||
|
{Object.entries(detail.vendor_consents).map(([name, accepted]) => (
|
||||||
|
<span key={name} className={`text-xs px-2 py-0.5 rounded-full ${accepted ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-500">Methode</span>
|
||||||
|
<span>{detail.consent_method ? (
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${methodColors[detail.consent_method] || 'bg-gray-100'}`}>
|
||||||
|
{methodLabels[detail.consent_method] || detail.consent_method}
|
||||||
|
</span>
|
||||||
|
) : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-gray-500">Verknüpft mit</span>
|
||||||
|
{detail.linked_email ? (
|
||||||
|
<span className="text-purple-600 text-xs">{detail.linked_email}</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="E-Mail verknüpfen..."
|
||||||
|
value={linkEmailInput}
|
||||||
|
onChange={e => setLinkEmailInput(e.target.value)}
|
||||||
|
className="text-xs border border-gray-200 rounded px-2 py-1 w-40"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => linkEmail(detail)}
|
||||||
|
disabled={linkingEmail || !linkEmailInput.includes('@')}
|
||||||
|
className="text-xs px-2 py-1 bg-purple-600 text-white rounded disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{linkingEmail ? '...' : 'Link'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between"><span className="text-gray-500">Erteilt</span><span>{formatDate(detail.created_at)}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-gray-500">Ablauf</span><span>{formatDate(detail.expires_at)}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-gray-500">Aktualisiert</span><span>{formatDate(detail.updated_at)}</span></div>
|
||||||
|
<div className="flex justify-between"><span className="text-gray-500">Geltungsbereich</span><span>{detail.consent_scope || '—'}</span></div>
|
||||||
|
{detail.banner_version && (
|
||||||
|
<div className="flex justify-between"><span className="text-gray-500">Banner-Version</span><span>{detail.banner_version}</span></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tracking-Kontext */}
|
||||||
|
<div className="border-t border-gray-100 pt-3">
|
||||||
|
<p className="text-xs font-semibold text-gray-700 mb-2">Tracking-Kontext</p>
|
||||||
|
{detail.page_url && <div className="flex justify-between"><span className="text-gray-500 text-xs">Seite</span><span className="text-xs text-gray-600 truncate max-w-[250px]">{detail.page_url}</span></div>}
|
||||||
|
{detail.referrer && <div className="flex justify-between mt-1"><span className="text-gray-500 text-xs">Referrer</span><span className="text-xs text-gray-600 truncate max-w-[250px]">{detail.referrer}</span></div>}
|
||||||
|
{detail.geo_country && <div className="flex justify-between mt-1"><span className="text-gray-500 text-xs">Land</span><span className="text-xs text-gray-600">{detail.geo_country}{detail.geo_region ? ` / ${detail.geo_region}` : ''}</span></div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device-Informationen */}
|
||||||
|
<div className="border-t border-gray-100 pt-3">
|
||||||
|
<p className="text-xs font-semibold text-gray-700 mb-2">Device</p>
|
||||||
|
<div className="grid grid-cols-2 gap-1 text-xs">
|
||||||
|
<span className="text-gray-500">Typ</span><span className="text-gray-600">{detail.device_type || '—'}</span>
|
||||||
|
<span className="text-gray-500">Browser</span><span className="text-gray-600">{detail.browser || shortenUA(detail.user_agent)}</span>
|
||||||
|
<span className="text-gray-500">OS</span><span className="text-gray-600">{detail.os || '—'}</span>
|
||||||
|
<span className="text-gray-500">Auflösung</span><span className="text-gray-600">{detail.screen_resolution || '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scripts & Cookies */}
|
||||||
|
{(detail.scripts_released?.length > 0 || detail.cookies_set?.length > 0) && (
|
||||||
|
<div className="border-t border-gray-100 pt-3">
|
||||||
|
<p className="text-xs font-semibold text-gray-700 mb-2">Scripts & Cookies</p>
|
||||||
|
{detail.scripts_released?.length > 0 && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="text-gray-500 text-xs">Freigegebene Scripts</span>
|
||||||
|
{detail.scripts_released.map((s, i) => (
|
||||||
|
<p key={i} className="text-xs text-gray-600 font-mono truncate">{s.src} <span className={`px-1 rounded ${categoryColors[s.category] || 'bg-gray-100'}`}>{s.category}</span></p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detail.scripts_blocked?.length > 0 && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="text-gray-500 text-xs">Blockierte Scripts</span>
|
||||||
|
{detail.scripts_blocked.map((s, i) => (
|
||||||
|
<p key={i} className="text-xs text-red-600 font-mono truncate">{s.src} <span className="px-1 rounded bg-red-100 text-red-700">{s.category}</span></p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detail.cookies_set?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500 text-xs">Gesetzte Cookies</span>
|
||||||
|
{detail.cookies_set.map((c, i) => (
|
||||||
|
<p key={i} className="text-xs text-gray-600 font-mono">{c.name} <span className="text-gray-400">({c.domain})</span> <span className={`px-1 rounded ${categoryColors[c.category] || 'bg-gray-100'}`}>{c.category}</span></p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Technische Details */}
|
||||||
|
<div className="border-t border-gray-100 pt-3">
|
||||||
|
<p className="text-xs font-semibold text-gray-700 mb-2">Technisch</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div><span className="text-gray-500 text-xs">User-Agent</span><p className="text-xs text-gray-600 font-mono break-all">{detail.user_agent || '—'}</p></div>
|
||||||
|
{detail.ip_hash && <div><span className="text-gray-500 text-xs">IP-Hash</span><p className="text-xs text-gray-600 font-mono">{detail.ip_hash}</p></div>}
|
||||||
|
{detail.session_id && <div><span className="text-gray-500 text-xs">Session</span><p className="text-xs text-gray-600 font-mono">{detail.session_id}</p></div>}
|
||||||
|
{detail.banner_config_hash && <div><span className="text-gray-500 text-xs">Config-Hash</span><p className="text-xs text-gray-600 font-mono">{detail.banner_config_hash}</p></div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Widerruf-Button */}
|
||||||
|
<div className="border-t border-gray-100 pt-4 mt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => withdrawConsent(detail.id)}
|
||||||
|
className="w-full px-4 py-2 text-xs font-semibold text-red-600 border border-red-200 rounded-lg hover:bg-red-50 transition-colors"
|
||||||
|
>
|
||||||
|
Consent widerrufen (Art. 17 DSGVO)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { BannerConsentRecord, BannerConsentStats, BannerSite, PAGE_SIZE } from '../_types'
|
||||||
|
|
||||||
|
const BANNER_API = '/api/sdk/v1/banner'
|
||||||
|
const TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||||
|
const HEADERS = { 'x-tenant-id': TENANT_ID }
|
||||||
|
|
||||||
|
function fb(path: string) {
|
||||||
|
return fetch(`${BANNER_API}/${path}`, { headers: HEADERS })
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.catch(() => null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBannerConsents() {
|
||||||
|
const [records, setRecords] = useState<BannerConsentRecord[]>([])
|
||||||
|
const [sites, setSites] = useState<BannerSite[]>([])
|
||||||
|
const [selectedSite, setSelectedSite] = useState<string>('')
|
||||||
|
const [stats, setStats] = useState<BannerConsentStats | null>(null)
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const [totalRecords, setTotalRecords] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
// Load sites on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fb('admin/sites').then(data => {
|
||||||
|
const list = Array.isArray(data) ? data : []
|
||||||
|
setSites(list)
|
||||||
|
if (list.length > 0) {
|
||||||
|
setSelectedSite(list[0].site_id)
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load consents + stats when site or page changes
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!selectedSite) return
|
||||||
|
setLoading(true)
|
||||||
|
const offset = (currentPage - 1) * PAGE_SIZE
|
||||||
|
const [consentsData, statsData] = await Promise.all([
|
||||||
|
fb(`admin/consents?site_id=${selectedSite}&limit=${PAGE_SIZE}&offset=${offset}`),
|
||||||
|
fb(`admin/stats/${selectedSite}`),
|
||||||
|
])
|
||||||
|
if (consentsData) {
|
||||||
|
setRecords(consentsData.consents || [])
|
||||||
|
setTotalRecords(consentsData.total || 0)
|
||||||
|
}
|
||||||
|
setStats(statsData)
|
||||||
|
setLoading(false)
|
||||||
|
}, [selectedSite, currentPage])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [loadData])
|
||||||
|
|
||||||
|
const changeSite = (siteId: string) => {
|
||||||
|
setSelectedSite(siteId)
|
||||||
|
setCurrentPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
records,
|
||||||
|
sites,
|
||||||
|
selectedSite,
|
||||||
|
changeSite,
|
||||||
|
stats,
|
||||||
|
currentPage,
|
||||||
|
setCurrentPage,
|
||||||
|
totalRecords,
|
||||||
|
loading,
|
||||||
|
reload: loadData,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,3 +100,50 @@ export function formatDate(date: Date | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PAGE_SIZE = 50
|
export const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
// Banner (Device-based) Consent
|
||||||
|
export interface BannerConsentRecord {
|
||||||
|
id: string
|
||||||
|
site_id: string
|
||||||
|
device_fingerprint: string
|
||||||
|
categories: string[]
|
||||||
|
vendors: string[]
|
||||||
|
vendor_consents: Record<string, boolean>
|
||||||
|
ip_hash: string | null
|
||||||
|
user_agent: string | null
|
||||||
|
linked_email: string | null
|
||||||
|
consent_string: string | null
|
||||||
|
// Vendor-agnostische Felder (Migration 107)
|
||||||
|
consent_method: string | null
|
||||||
|
banner_version: number | null
|
||||||
|
banner_config_hash: string | null
|
||||||
|
geo_country: string | null
|
||||||
|
geo_region: string | null
|
||||||
|
consent_scope: string | null
|
||||||
|
page_url: string | null
|
||||||
|
referrer: string | null
|
||||||
|
device_type: string | null
|
||||||
|
browser: string | null
|
||||||
|
os: string | null
|
||||||
|
screen_resolution: string | null
|
||||||
|
session_id: string | null
|
||||||
|
// Script/Cookie-Tracking (Migration 108)
|
||||||
|
scripts_blocked: { src: string; category: string }[]
|
||||||
|
scripts_released: { src: string; category: string }[]
|
||||||
|
cookies_set: { name: string; domain: string; expiry_days: number; category: string }[]
|
||||||
|
expires_at: string | null
|
||||||
|
created_at: string | null
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BannerConsentStats {
|
||||||
|
total_consents: number
|
||||||
|
category_acceptance: Record<string, { count: number; rate: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BannerSite {
|
||||||
|
site_id: string
|
||||||
|
site_name: string
|
||||||
|
site_url: string
|
||||||
|
tcf_enabled?: boolean
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||||
import { History } from 'lucide-react'
|
import { History, Globe, User } from 'lucide-react'
|
||||||
|
|
||||||
import { ConsentRecord } from './_types'
|
import { ConsentRecord } from './_types'
|
||||||
import { useConsents } from './_hooks/useConsents'
|
import { useConsents } from './_hooks/useConsents'
|
||||||
@@ -12,8 +12,13 @@ import { SearchAndFilter } from './_components/SearchAndFilter'
|
|||||||
import { RecordsTable } from './_components/RecordsTable'
|
import { RecordsTable } from './_components/RecordsTable'
|
||||||
import { Pagination } from './_components/Pagination'
|
import { Pagination } from './_components/Pagination'
|
||||||
import { ConsentDetailModal } from './_components/ConsentDetailModal'
|
import { ConsentDetailModal } from './_components/ConsentDetailModal'
|
||||||
|
import BannerConsentsTab from './_components/BannerConsentsTab'
|
||||||
|
|
||||||
|
type ConsentTab = 'visitors' | 'users'
|
||||||
|
|
||||||
export default function EinwilligungenPage() {
|
export default function EinwilligungenPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState<ConsentTab>('visitors')
|
||||||
|
|
||||||
const {
|
const {
|
||||||
records,
|
records,
|
||||||
currentPage,
|
currentPage,
|
||||||
@@ -63,51 +68,84 @@ export default function EinwilligungenPage() {
|
|||||||
{/* Navigation Tabs */}
|
{/* Navigation Tabs */}
|
||||||
<EinwilligungenNavTabs />
|
<EinwilligungenNavTabs />
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Consent Type Tabs: Website-Besucher / Login-Nutzer */}
|
||||||
<StatsGrid
|
<div className="flex gap-1 p-1 bg-gray-100 rounded-xl w-fit">
|
||||||
total={globalStats.total}
|
<button
|
||||||
active={globalStats.active}
|
onClick={() => setActiveTab('visitors')}
|
||||||
revoked={globalStats.revoked}
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
versionUpdates={versionUpdates}
|
activeTab === 'visitors'
|
||||||
/>
|
? 'bg-white text-purple-700 shadow-sm'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
{/* Info Banner */}
|
}`}
|
||||||
<div className="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-xl border border-purple-200 p-4 flex items-start gap-3">
|
>
|
||||||
<History className="w-5 h-5 text-purple-600 mt-0.5" />
|
<Globe className="w-4 h-4" />
|
||||||
<div>
|
Website-Besucher
|
||||||
<div className="font-medium text-purple-900">Consent-Historie aktiviert</div>
|
</button>
|
||||||
<div className="text-sm text-purple-700">
|
<button
|
||||||
Alle Änderungen an Einwilligungen werden protokolliert, inkl. Zustimmungen zu neuen Versionen von AGB, DSI und anderen Dokumenten.
|
onClick={() => setActiveTab('users')}
|
||||||
Klicken Sie auf "Details" um die vollständige Historie eines Nutzers einzusehen.
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
</div>
|
activeTab === 'users'
|
||||||
</div>
|
? 'bg-white text-purple-700 shadow-sm'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
Login-Nutzer
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search and Filter */}
|
{/* Tab Content */}
|
||||||
<SearchAndFilter
|
{activeTab === 'visitors' ? (
|
||||||
searchQuery={searchQuery}
|
<BannerConsentsTab />
|
||||||
onSearchChange={setSearchQuery}
|
) : (
|
||||||
filter={filter}
|
<>
|
||||||
onFilterChange={setFilter}
|
{/* Stats */}
|
||||||
/>
|
<StatsGrid
|
||||||
|
total={globalStats.total}
|
||||||
|
active={globalStats.active}
|
||||||
|
revoked={globalStats.revoked}
|
||||||
|
versionUpdates={versionUpdates}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Records Table */}
|
{/* Info Banner */}
|
||||||
<RecordsTable records={filteredRecords} onShowDetails={setSelectedRecord} />
|
<div className="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-xl border border-purple-200 p-4 flex items-start gap-3">
|
||||||
|
<History className="w-5 h-5 text-purple-600 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-purple-900">Consent-Historie aktiviert</div>
|
||||||
|
<div className="text-sm text-purple-700">
|
||||||
|
Alle Änderungen an Einwilligungen werden protokolliert, inkl. Zustimmungen zu neuen Versionen von AGB, DSI und anderen Dokumenten.
|
||||||
|
Klicken Sie auf "Details" um die vollständige Historie eines Nutzers einzusehen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Search and Filter */}
|
||||||
<Pagination
|
<SearchAndFilter
|
||||||
currentPage={currentPage}
|
searchQuery={searchQuery}
|
||||||
totalRecords={totalRecords}
|
onSearchChange={setSearchQuery}
|
||||||
onPageChange={setCurrentPage}
|
filter={filter}
|
||||||
/>
|
onFilterChange={setFilter}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Detail Modal */}
|
{/* Records Table */}
|
||||||
{selectedRecord && (
|
<RecordsTable records={filteredRecords} onShowDetails={setSelectedRecord} />
|
||||||
<ConsentDetailModal
|
|
||||||
record={selectedRecord}
|
{/* Pagination */}
|
||||||
onClose={() => setSelectedRecord(null)}
|
<Pagination
|
||||||
onRevoke={handleRevoke}
|
currentPage={currentPage}
|
||||||
/>
|
totalRecords={totalRecords}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Detail Modal */}
|
||||||
|
{selectedRecord && (
|
||||||
|
<ConsentDetailModal
|
||||||
|
record={selectedRecord}
|
||||||
|
onClose={() => setSelectedRecord(null)}
|
||||||
|
onRevoke={handleRevoke}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
|
||||||
|
interface GapReport {
|
||||||
|
dsms_cid?: string
|
||||||
|
profile_name: string
|
||||||
|
regulations: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
risk_level: string
|
||||||
|
confidence: number
|
||||||
|
reasoning: string
|
||||||
|
requirements?: string[]
|
||||||
|
}>
|
||||||
|
summary: {
|
||||||
|
total_applicable_regulations: number
|
||||||
|
total_gaps: number
|
||||||
|
gaps_by_status: Record<string, number>
|
||||||
|
gaps_by_severity: Record<string, number>
|
||||||
|
overall_compliance_percent: number
|
||||||
|
estimated_effort_weeks: number
|
||||||
|
}
|
||||||
|
gaps: Array<{
|
||||||
|
mc_id: string
|
||||||
|
mc_name: string
|
||||||
|
regulation: string
|
||||||
|
status: string
|
||||||
|
title: string
|
||||||
|
severity: string
|
||||||
|
priority: { score: number; rank: number }
|
||||||
|
recommendation: string
|
||||||
|
control_count: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
report: GapReport
|
||||||
|
onBack: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
fulfilled: 'bg-green-100 text-green-800',
|
||||||
|
partial: 'bg-yellow-100 text-yellow-800',
|
||||||
|
missing: 'bg-red-100 text-red-800',
|
||||||
|
unclear: 'bg-gray-100 text-gray-800',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
fulfilled: 'Erfuellt',
|
||||||
|
partial: 'Teilweise',
|
||||||
|
missing: 'Offen',
|
||||||
|
unclear: 'Unklar',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVERITY_COLORS: Record<string, string> = {
|
||||||
|
CRITICAL: 'bg-red-600 text-white',
|
||||||
|
HIGH: 'bg-orange-500 text-white',
|
||||||
|
MEDIUM: 'bg-yellow-400 text-gray-900',
|
||||||
|
LOW: 'bg-blue-100 text-blue-800',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GapDashboard({ report, onBack }: Props) {
|
||||||
|
const [filterSeverity, setFilterSeverity] = useState<string>('all')
|
||||||
|
const [filterStatus, setFilterStatus] = useState<string>('all')
|
||||||
|
const [expandedGap, setExpandedGap] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const filteredGaps = report.gaps.filter(g => {
|
||||||
|
if (filterSeverity !== 'all' && g.severity !== filterSeverity) return false
|
||||||
|
if (filterStatus !== 'all' && g.status !== filterStatus) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const s = report.summary
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Back button */}
|
||||||
|
<button onClick={onBack} className="mb-6 text-blue-600 hover:text-blue-800 text-sm">
|
||||||
|
← Neue Analyse
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* DSMS Archive Badge */}
|
||||||
|
{report.dsms_cid && (
|
||||||
|
<div className="mb-4 flex items-center gap-2 px-4 py-2.5 bg-emerald-50 border border-emerald-200 rounded-lg">
|
||||||
|
<svg className="w-4 h-4 text-emerald-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm text-emerald-800 font-medium">Revisionssicher archiviert</span>
|
||||||
|
<code className="text-xs text-emerald-600 bg-emerald-100 px-2 py-0.5 rounded font-mono">
|
||||||
|
{report.dsms_cid.length > 20 ? report.dsms_cid.slice(0, 8) + '...' + report.dsms_cid.slice(-6) : report.dsms_cid}
|
||||||
|
</code>
|
||||||
|
<span className="text-[10px] text-emerald-500">DSMS/IPFS</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
|
<SummaryCard
|
||||||
|
label="Regulierungen"
|
||||||
|
value={s.total_applicable_regulations}
|
||||||
|
color="blue"
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label="Offene Gaps"
|
||||||
|
value={s.gaps_by_status?.missing || 0}
|
||||||
|
color="red"
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label="Compliance"
|
||||||
|
value={`${s.overall_compliance_percent}%`}
|
||||||
|
color={s.overall_compliance_percent >= 80 ? 'green' : 'orange'}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label="Gesch. Aufwand"
|
||||||
|
value={`${s.estimated_effort_weeks} Wo.`}
|
||||||
|
color="purple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Applicable Regulations */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Anwendbare Regulierungen
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{report.regulations.map(reg => (
|
||||||
|
<div
|
||||||
|
key={reg.id}
|
||||||
|
className="border border-gray-200 rounded-lg p-4 hover:shadow-sm transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="font-medium text-gray-900 text-sm">
|
||||||
|
{reg.name}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||||
|
reg.risk_level === 'high' ? 'bg-red-100 text-red-700' :
|
||||||
|
reg.risk_level === 'medium' ? 'bg-yellow-100 text-yellow-700' :
|
||||||
|
'bg-green-100 text-green-700'
|
||||||
|
}`}>
|
||||||
|
{reg.risk_level}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">{reg.reasoning}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex gap-4 mb-4">
|
||||||
|
<select
|
||||||
|
value={filterSeverity}
|
||||||
|
onChange={e => setFilterSeverity(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">Alle Prioritaeten</option>
|
||||||
|
<option value="CRITICAL">Kritisch</option>
|
||||||
|
<option value="HIGH">Hoch</option>
|
||||||
|
<option value="MEDIUM">Mittel</option>
|
||||||
|
<option value="LOW">Niedrig</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={e => setFilterStatus(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">Alle Status</option>
|
||||||
|
<option value="missing">Offen</option>
|
||||||
|
<option value="partial">Teilweise</option>
|
||||||
|
<option value="fulfilled">Erfuellt</option>
|
||||||
|
</select>
|
||||||
|
<span className="text-sm text-gray-500 self-center">
|
||||||
|
{filteredGaps.length} von {report.gaps.length} Anforderungen
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gap List */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">#</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Anforderung</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Regulierung</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Prioritaet</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Controls</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{filteredGaps.map(gap => (
|
||||||
|
<React.Fragment key={gap.mc_id}>
|
||||||
|
<tr
|
||||||
|
className="hover:bg-gray-50 cursor-pointer"
|
||||||
|
onClick={() => setExpandedGap(expandedGap === gap.mc_id ? null : gap.mc_id)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-500">{gap.priority.rank}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{gap.title}</div>
|
||||||
|
<div className="text-xs text-gray-500">{gap.mc_name}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600">{gap.regulation}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${STATUS_COLORS[gap.status] || ''}`}>
|
||||||
|
{STATUS_LABELS[gap.status] || gap.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-bold ${SEVERITY_COLORS[gap.severity] || ''}`}>
|
||||||
|
{gap.severity}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-500">{gap.control_count}</td>
|
||||||
|
</tr>
|
||||||
|
{expandedGap === gap.mc_id && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-4 bg-blue-50">
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-gray-700 mb-1">Empfehlung:</p>
|
||||||
|
<p className="text-gray-600">{gap.recommendation}</p>
|
||||||
|
<p className="mt-2 text-xs text-gray-400">
|
||||||
|
Priority Score: {gap.priority.score.toFixed(1)} | MC: {gap.mc_id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryCard({ label, value, color }: { label: string; value: string | number; color: string }) {
|
||||||
|
const bg = {
|
||||||
|
blue: 'bg-blue-50 border-blue-200',
|
||||||
|
red: 'bg-red-50 border-red-200',
|
||||||
|
green: 'bg-green-50 border-green-200',
|
||||||
|
orange: 'bg-orange-50 border-orange-200',
|
||||||
|
purple: 'bg-purple-50 border-purple-200',
|
||||||
|
}[color] || 'bg-gray-50 border-gray-200'
|
||||||
|
|
||||||
|
const text = {
|
||||||
|
blue: 'text-blue-700',
|
||||||
|
red: 'text-red-700',
|
||||||
|
green: 'text-green-700',
|
||||||
|
orange: 'text-orange-700',
|
||||||
|
purple: 'text-purple-700',
|
||||||
|
}[color] || 'text-gray-700'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border p-4 ${bg}`}>
|
||||||
|
<p className="text-sm text-gray-600">{label}</p>
|
||||||
|
<p className={`text-2xl font-bold mt-1 ${text}`}>{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const NORMS = [
|
||||||
|
{ value: 'ISO12100', label: 'ISO 12100 (Maschinensicherheit)' },
|
||||||
|
{ value: 'ENISO13849', label: 'EN ISO 13849 (Sicherheitsfunktionen)' },
|
||||||
|
{ value: 'IEC61508', label: 'IEC 61508 (Funktionale Sicherheit)' },
|
||||||
|
{ value: 'IEC62443', label: 'IEC 62443 (Industrielle Cybersecurity)' },
|
||||||
|
{ value: 'ISO27001', label: 'ISO 27001 (Informationssicherheit)' },
|
||||||
|
{ value: 'ISO27002', label: 'ISO 27002 (Security Controls)' },
|
||||||
|
{ value: 'EN61326', label: 'EN 61326 (EMV)' },
|
||||||
|
{ value: 'EN62368', label: 'EN 62368 (Audio/Video/IT-Sicherheit)' },
|
||||||
|
{ value: 'IEC60204', label: 'IEC 60204 (Elektrische Ausruestung)' },
|
||||||
|
{ value: 'ISO13485', label: 'ISO 13485 (Medizinprodukte QM)' },
|
||||||
|
{ value: 'ISO14971', label: 'ISO 14971 (Risikomanagement Medizin)' },
|
||||||
|
{ value: 'IEC62304', label: 'IEC 62304 (Medizin-Software Lifecycle)' },
|
||||||
|
{ value: 'ISO9001', label: 'ISO 9001 (Qualitaetsmanagement)' },
|
||||||
|
{ value: 'ISO22301', label: 'ISO 22301 (Business Continuity)' },
|
||||||
|
{ value: 'PCIDSS', label: 'PCI DSS (Zahlungssicherheit)' },
|
||||||
|
{ value: 'EN50581', label: 'EN 50581 (RoHS/REACH)' },
|
||||||
|
{ value: 'ASPICE', label: 'ASPICE (Automotive Software)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface IstData {
|
||||||
|
applied_norms: string[]
|
||||||
|
has_risk_assessment: boolean
|
||||||
|
has_technical_file: boolean
|
||||||
|
has_operating_manual: boolean
|
||||||
|
has_sbom: boolean
|
||||||
|
has_vuln_management: boolean
|
||||||
|
has_update_mechanism: boolean
|
||||||
|
has_incident_response: boolean
|
||||||
|
has_supply_chain_mgmt: boolean
|
||||||
|
ce_marking_since: string
|
||||||
|
product_age: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: IstData
|
||||||
|
onChange: (data: IstData) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IstAssessment({ data, onChange }: Props) {
|
||||||
|
const update = (field: string, value: unknown) => {
|
||||||
|
onChange({ ...data, [field]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleNorm = (norm: string) => {
|
||||||
|
const norms = data.applied_norms.includes(norm)
|
||||||
|
? data.applied_norms.filter(n => n !== norm)
|
||||||
|
: [...data.applied_norms, norm]
|
||||||
|
update('applied_norms', norms)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
|
<p className="text-blue-800 text-sm">
|
||||||
|
Geben Sie an was Sie bereits haben. Je mehr wir wissen, desto
|
||||||
|
praeziser ist die Gap-Analyse. Controls die bereits erfuellt sind
|
||||||
|
werden automatisch als "erledigt" markiert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CE-Kennzeichnung */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-800 mb-3">CE-Kennzeichnung</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">CE seit (Jahr)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={data.ce_marking_since}
|
||||||
|
onChange={e => update('ce_marking_since', e.target.value)}
|
||||||
|
placeholder="z.B. 2016"
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Produktalter</label>
|
||||||
|
<select
|
||||||
|
value={data.product_age}
|
||||||
|
onChange={e => update('product_age', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="">Bitte waehlen</option>
|
||||||
|
<option value="new">Neues Produkt (noch nicht am Markt)</option>
|
||||||
|
<option value="1_year">1 Jahr</option>
|
||||||
|
<option value="3_years">2-3 Jahre</option>
|
||||||
|
<option value="5_years">4-5 Jahre</option>
|
||||||
|
<option value="10_years">6-10 Jahre</option>
|
||||||
|
<option value="10_plus">Ueber 10 Jahre</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Angewandte Normen */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-800 mb-3">Angewandte Normen</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{NORMS.map(n => (
|
||||||
|
<button
|
||||||
|
key={n.value}
|
||||||
|
onClick={() => toggleNorm(n.value)}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-xs border transition-colors ${
|
||||||
|
data.applied_norms.includes(n.value)
|
||||||
|
? 'bg-green-100 border-green-400 text-green-800'
|
||||||
|
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{n.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bestehende Dokumentation */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-800 mb-3">Bestehende Dokumentation</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{[
|
||||||
|
{ field: 'has_risk_assessment', label: 'Risikobeurteilung vorhanden' },
|
||||||
|
{ field: 'has_technical_file', label: 'Technische Dokumentation vorhanden' },
|
||||||
|
{ field: 'has_operating_manual', label: 'Betriebsanleitung vorhanden' },
|
||||||
|
{ field: 'has_sbom', label: 'SBOM (Software Bill of Materials)' },
|
||||||
|
].map(item => (
|
||||||
|
<label key={item.field} className="flex items-center gap-3 cursor-pointer p-2 rounded-lg hover:bg-gray-50">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(data as Record<string, unknown>)[item.field] as boolean}
|
||||||
|
onChange={e => update(item.field, e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 text-green-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">{item.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bestehende Prozesse */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-800 mb-3">Bestehende Prozesse</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{[
|
||||||
|
{ field: 'has_vuln_management', label: 'Schwachstellenmanagement' },
|
||||||
|
{ field: 'has_update_mechanism', label: 'Software-Update-Mechanismus' },
|
||||||
|
{ field: 'has_incident_response', label: 'Incident Response Prozess' },
|
||||||
|
{ field: 'has_supply_chain_mgmt', label: 'Lieferketten-Management' },
|
||||||
|
].map(item => (
|
||||||
|
<label key={item.field} className="flex items-center gap-3 cursor-pointer p-2 rounded-lg hover:bg-gray-50">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(data as Record<string, unknown>)[item.field] as boolean}
|
||||||
|
onChange={e => update(item.field, e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 text-green-600"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">{item.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { IstAssessment } from './IstAssessment'
|
||||||
|
|
||||||
|
const PRODUCT_TYPES = [
|
||||||
|
{ value: 'iot', label: 'IoT / Connected Device' },
|
||||||
|
{ value: 'software', label: 'Software / Desktop App' },
|
||||||
|
{ value: 'saas', label: 'SaaS / Cloud-Plattform' },
|
||||||
|
{ value: 'hardware', label: 'Hardware / Elektronik' },
|
||||||
|
{ value: 'machinery', label: 'Maschine / Anlage' },
|
||||||
|
{ value: 'medical_device', label: 'Medizinprodukt' },
|
||||||
|
{ value: 'exchange', label: 'Krypto-Exchange / Fintech' },
|
||||||
|
{ value: 'other', label: 'Sonstiges' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TECHNOLOGIES = [
|
||||||
|
{ value: 'ai', label: 'Kuenstliche Intelligenz / ML' },
|
||||||
|
{ value: 'blockchain', label: 'Blockchain / Smart Contracts' },
|
||||||
|
{ value: 'cloud', label: 'Cloud-Infrastruktur' },
|
||||||
|
{ value: 'api', label: 'REST/GraphQL API' },
|
||||||
|
{ value: 'database', label: 'Datenbank' },
|
||||||
|
{ value: 'encryption', label: 'Verschluesselung' },
|
||||||
|
{ value: 'ota_updates', label: 'OTA Software-Updates' },
|
||||||
|
{ value: 'sensor', label: 'Sensoren' },
|
||||||
|
{ value: 'actuator', label: 'Aktoren / Motoren' },
|
||||||
|
{ value: 'network', label: 'Netzwerk-Anbindung' },
|
||||||
|
{ value: 'camera', label: 'Kamera / Bilderkennung' },
|
||||||
|
{ value: 'payment', label: 'Zahlungsabwicklung' },
|
||||||
|
{ value: 'fiat_gateway', label: 'Fiat On/Off-Ramp' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DATA_TYPES = [
|
||||||
|
{ value: 'personal_data', label: 'Personenbezogene Daten' },
|
||||||
|
{ value: 'health_data', label: 'Gesundheitsdaten' },
|
||||||
|
{ value: 'financial_data', label: 'Finanzdaten' },
|
||||||
|
{ value: 'telemetry', label: 'Telemetrie / Nutzungsdaten' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CERTIFICATIONS = [
|
||||||
|
{ value: 'ISO27001', label: 'ISO 27001' },
|
||||||
|
{ value: 'CE', label: 'CE-Kennzeichnung' },
|
||||||
|
{ value: 'SOC2', label: 'SOC 2' },
|
||||||
|
{ value: 'ISO13485', label: 'ISO 13485 (Medizin)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onAnalyze: (profile: Record<string, unknown>) => void
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductWizard({ onAnalyze, loading }: Props) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [productType, setProductType] = useState('')
|
||||||
|
const [technologies, setTechnologies] = useState<string[]>([])
|
||||||
|
const [dataProcessing, setDataProcessing] = useState<string[]>([])
|
||||||
|
const [certifications, setCertifications] = useState<string[]>([])
|
||||||
|
const [connectedToInternet, setConnectedToInternet] = useState(false)
|
||||||
|
const [hasSoftwareUpdates, setHasSoftwareUpdates] = useState(false)
|
||||||
|
const [usesAI, setUsesAI] = useState(false)
|
||||||
|
const [processesPersonalData, setProcessesPersonalData] = useState(false)
|
||||||
|
const [isCriticalInfra, setIsCriticalInfra] = useState(false)
|
||||||
|
const [step, setStep] = useState(1)
|
||||||
|
const [istData, setIstData] = useState({
|
||||||
|
applied_norms: [] as string[],
|
||||||
|
has_risk_assessment: false,
|
||||||
|
has_technical_file: false,
|
||||||
|
has_operating_manual: false,
|
||||||
|
has_sbom: false,
|
||||||
|
has_vuln_management: false,
|
||||||
|
has_update_mechanism: false,
|
||||||
|
has_incident_response: false,
|
||||||
|
has_supply_chain_mgmt: false,
|
||||||
|
ce_marking_since: '',
|
||||||
|
product_age: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleArrayValue = (
|
||||||
|
arr: string[],
|
||||||
|
setter: (v: string[]) => void,
|
||||||
|
value: string
|
||||||
|
) => {
|
||||||
|
setter(arr.includes(value) ? arr.filter(v => v !== value) : [...arr, value])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
onAnalyze({
|
||||||
|
name: name || 'Unbenanntes Produkt',
|
||||||
|
description,
|
||||||
|
product_type: productType,
|
||||||
|
technologies,
|
||||||
|
data_processing: dataProcessing,
|
||||||
|
markets: ['EU'],
|
||||||
|
connected_to_internet: connectedToInternet,
|
||||||
|
has_software_updates: hasSoftwareUpdates,
|
||||||
|
uses_ai: usesAI,
|
||||||
|
processes_personal_data: processesPersonalData,
|
||||||
|
is_critical_infra_supplier: isCriticalInfra,
|
||||||
|
existing_certifications: certifications,
|
||||||
|
...istData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
||||||
|
{/* Step Indicator */}
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setStep(1)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium ${
|
||||||
|
step === 1 ? 'bg-blue-100 text-blue-700' : 'text-gray-500 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-6 h-6 rounded-full bg-blue-600 text-white text-xs flex items-center justify-center">1</span>
|
||||||
|
Produkt beschreiben
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-300">→</span>
|
||||||
|
<button
|
||||||
|
onClick={() => productType ? setStep(2) : null}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium ${
|
||||||
|
step === 2 ? 'bg-blue-100 text-blue-700' : 'text-gray-500 hover:bg-gray-50'
|
||||||
|
} ${!productType ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
<span className={`w-6 h-6 rounded-full text-xs flex items-center justify-center ${
|
||||||
|
step === 2 ? 'bg-blue-600 text-white' : 'bg-gray-300 text-white'
|
||||||
|
}`}>2</span>
|
||||||
|
IST-Zustand
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<>
|
||||||
|
<IstAssessment data={istData} onChange={setIstData} />
|
||||||
|
<div className="flex gap-4 mt-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setStep(1)}
|
||||||
|
className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Zurueck
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:bg-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Analyse laeuft...' : 'Gap-Analyse starten'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 1 && (<>
|
||||||
|
{/* Produktname */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Produktname
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="z.B. SmartFactory Gateway Pro"
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Beschreibung */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Produktbeschreibung
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Beschreiben Sie Ihr Produkt in 2-3 Saetzen..."
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Produkttyp */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Produkttyp
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{PRODUCT_TYPES.map(pt => (
|
||||||
|
<button
|
||||||
|
key={pt.value}
|
||||||
|
onClick={() => setProductType(pt.value)}
|
||||||
|
className={`px-4 py-3 rounded-lg border text-sm font-medium transition-colors ${
|
||||||
|
productType === pt.value
|
||||||
|
? 'bg-blue-50 border-blue-500 text-blue-700'
|
||||||
|
: 'border-gray-200 text-gray-700 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Technologien */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Verwendete Technologien
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{TECHNOLOGIES.map(t => (
|
||||||
|
<button
|
||||||
|
key={t.value}
|
||||||
|
onClick={() => toggleArrayValue(technologies, setTechnologies, t.value)}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-sm border transition-colors ${
|
||||||
|
technologies.includes(t.value)
|
||||||
|
? 'bg-blue-100 border-blue-400 text-blue-800'
|
||||||
|
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Datenverarbeitung */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Verarbeitete Daten
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{DATA_TYPES.map(d => (
|
||||||
|
<button
|
||||||
|
key={d.value}
|
||||||
|
onClick={() => toggleArrayValue(dataProcessing, setDataProcessing, d.value)}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-sm border transition-colors ${
|
||||||
|
dataProcessing.includes(d.value)
|
||||||
|
? 'bg-green-100 border-green-400 text-green-800'
|
||||||
|
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{d.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Checkboxen */}
|
||||||
|
<div className="mb-6 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{[
|
||||||
|
{ label: 'Mit dem Internet verbunden', value: connectedToInternet, setter: setConnectedToInternet },
|
||||||
|
{ label: 'Hat Software-Updates (OTA)', value: hasSoftwareUpdates, setter: setHasSoftwareUpdates },
|
||||||
|
{ label: 'Verwendet KI / Machine Learning', value: usesAI, setter: setUsesAI },
|
||||||
|
{ label: 'Verarbeitet personenbezogene Daten', value: processesPersonalData, setter: setProcessesPersonalData },
|
||||||
|
{ label: 'Zulieferer fuer kritische Infrastruktur', value: isCriticalInfra, setter: setIsCriticalInfra },
|
||||||
|
].map(cb => (
|
||||||
|
<label key={cb.label} className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={cb.value}
|
||||||
|
onChange={e => cb.setter(e.target.checked)}
|
||||||
|
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">{cb.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bestehende Zertifizierungen */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Bestehende Zertifizierungen (optional)
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CERTIFICATIONS.map(cert => (
|
||||||
|
<button
|
||||||
|
key={cert.value}
|
||||||
|
onClick={() => toggleArrayValue(certifications, setCertifications, cert.value)}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-sm border transition-colors ${
|
||||||
|
certifications.includes(cert.value)
|
||||||
|
? 'bg-purple-100 border-purple-400 text-purple-800'
|
||||||
|
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cert.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Next Step */}
|
||||||
|
<button
|
||||||
|
onClick={() => setStep(2)}
|
||||||
|
disabled={!productType}
|
||||||
|
className="w-full py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
Weiter: IST-Zustand erfassen →
|
||||||
|
</button>
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { ProductWizard } from './_components/ProductWizard'
|
||||||
|
import { GapDashboard } from './_components/GapDashboard'
|
||||||
|
|
||||||
|
interface GapProject {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
product_type: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GapReport {
|
||||||
|
profile_id: string
|
||||||
|
profile_name: string
|
||||||
|
regulations: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
applicable: boolean
|
||||||
|
confidence: number
|
||||||
|
reasoning: string
|
||||||
|
risk_level: string
|
||||||
|
deadline?: string
|
||||||
|
requirements?: string[]
|
||||||
|
}>
|
||||||
|
summary: {
|
||||||
|
total_applicable_regulations: number
|
||||||
|
total_gaps: number
|
||||||
|
gaps_by_status: Record<string, number>
|
||||||
|
gaps_by_severity: Record<string, number>
|
||||||
|
gaps_by_regulation: Record<string, number>
|
||||||
|
overall_compliance_percent: number
|
||||||
|
estimated_effort_weeks: number
|
||||||
|
}
|
||||||
|
gaps: Array<{
|
||||||
|
mc_id: string
|
||||||
|
mc_name: string
|
||||||
|
regulation: string
|
||||||
|
status: string
|
||||||
|
title: string
|
||||||
|
severity: string
|
||||||
|
priority: { score: number; rank: number }
|
||||||
|
recommendation: string
|
||||||
|
control_count: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
type View = 'projects' | 'wizard' | 'dashboard'
|
||||||
|
|
||||||
|
const PRODUCT_TYPE_LABELS: Record<string, string> = {
|
||||||
|
iot: 'IoT', software: 'Software', saas: 'SaaS', hardware: 'Hardware',
|
||||||
|
machinery: 'Maschine', medical_device: 'Medizin', exchange: 'Fintech', other: 'Sonstiges',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GapAnalysisPage() {
|
||||||
|
const [view, setView] = useState<View>('projects')
|
||||||
|
const [projects, setProjects] = useState<GapProject[]>([])
|
||||||
|
const [report, setReport] = useState<GapReport | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const loadProjects = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sdk/v1/gap/projects', {
|
||||||
|
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000001' },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setProjects(data.projects || [])
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { loadProjects() }, [loadProjects])
|
||||||
|
|
||||||
|
const handleCreateAndAnalyze = async (profile: Record<string, unknown>) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
// Save project
|
||||||
|
const createRes = await fetch('/api/sdk/v1/gap/projects', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Tenant-ID': '00000000-0000-0000-0000-000000000001',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(profile),
|
||||||
|
})
|
||||||
|
if (!createRes.ok) throw new Error('Projekt konnte nicht gespeichert werden')
|
||||||
|
const created = await createRes.json()
|
||||||
|
const projectId = created.project?.id
|
||||||
|
|
||||||
|
// Run analysis
|
||||||
|
const analyzeRes = await fetch(`/api/sdk/v1/gap/projects/${projectId}/analyze`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000001' },
|
||||||
|
})
|
||||||
|
if (!analyzeRes.ok) throw new Error(await analyzeRes.text())
|
||||||
|
const data = await analyzeRes.json()
|
||||||
|
setReport(data)
|
||||||
|
setView('dashboard')
|
||||||
|
loadProjects()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Analyse fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenProject = async (projectId: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/gap/projects/${projectId}/analyze`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000001' },
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(await res.text())
|
||||||
|
const data = await res.json()
|
||||||
|
setReport(data)
|
||||||
|
setView('dashboard')
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Analyse fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 py-8">
|
||||||
|
<div className="max-w-6xl mx-auto px-4">
|
||||||
|
<div className="mb-8 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">
|
||||||
|
Regulatory Gap-Analyse
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mt-2">
|
||||||
|
Produkt beschreiben, Regulierungen erkennen, Prioritaeten setzen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{view !== 'projects' && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setView('projects'); setReport(null) }}
|
||||||
|
className="px-4 py-2 text-sm text-blue-600 hover:text-blue-800 border border-blue-200 rounded-lg hover:bg-blue-50"
|
||||||
|
>
|
||||||
|
Alle Projekte
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<p className="text-red-700">{error}</p>
|
||||||
|
<button onClick={() => setError('')} className="text-sm text-red-500 mt-1 underline">
|
||||||
|
Schliessen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'projects' && (
|
||||||
|
<div>
|
||||||
|
{/* New project button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setView('wizard')}
|
||||||
|
className="mb-6 w-full py-4 border-2 border-dashed border-blue-300 rounded-xl text-blue-600 hover:bg-blue-50 hover:border-blue-400 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
+ Neues Produkt analysieren
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Project list */}
|
||||||
|
{projects.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800">Gespeicherte Projekte</h2>
|
||||||
|
{projects.map(p => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => handleOpenProject(p.id)}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full text-left bg-white rounded-xl shadow-sm border border-gray-200 p-5 hover:shadow-md hover:border-blue-300 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">{p.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">{p.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="px-3 py-1 bg-gray-100 text-gray-600 rounded-full text-xs font-medium">
|
||||||
|
{PRODUCT_TYPE_LABELS[p.product_type] || p.product_type}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{new Date(p.created_at).toLocaleDateString('de-DE')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{projects.length === 0 && (
|
||||||
|
<p className="text-center text-gray-500 mt-8">
|
||||||
|
Noch keine Projekte. Starten Sie Ihre erste Gap-Analyse.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'wizard' && (
|
||||||
|
<ProductWizard onAnalyze={handleCreateAndAnalyze} loading={loading} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'dashboard' && report && (
|
||||||
|
<GapDashboard report={report} onBack={() => { setView('projects'); setReport(null) }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface DeltaResult {
|
||||||
|
added_patterns?: Array<{ pattern_name: string; hazard_cats: string[] }>
|
||||||
|
removed_patterns?: Array<{ pattern_name: string; hazard_cats: string[] }>
|
||||||
|
added_hazards?: Array<{ name: string; category: string }>
|
||||||
|
removed_hazards?: Array<{ name: string; category: string }>
|
||||||
|
added_measures?: Array<{ id: string; name: string }>
|
||||||
|
removed_measures?: Array<{ id: string; name: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeltaPreviewModalProps {
|
||||||
|
projectId: string
|
||||||
|
currentInput: {
|
||||||
|
component_library_ids: string[]
|
||||||
|
energy_source_ids: string[]
|
||||||
|
operational_states?: string[]
|
||||||
|
human_roles?: string[]
|
||||||
|
}
|
||||||
|
proposedInput: {
|
||||||
|
component_library_ids: string[]
|
||||||
|
energy_source_ids: string[]
|
||||||
|
operational_states?: string[]
|
||||||
|
human_roles?: string[]
|
||||||
|
}
|
||||||
|
onClose: () => void
|
||||||
|
onApply: () => void
|
||||||
|
changeDescription: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeltaPreviewModal({
|
||||||
|
projectId,
|
||||||
|
currentInput,
|
||||||
|
proposedInput,
|
||||||
|
onClose,
|
||||||
|
onApply,
|
||||||
|
changeDescription,
|
||||||
|
}: DeltaPreviewModalProps) {
|
||||||
|
const [result, setResult] = useState<DeltaResult | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// Auto-run delta analysis on mount
|
||||||
|
useState(() => {
|
||||||
|
runDelta()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function runDelta() {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/delta-analysis`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ current: currentInput, proposed: proposedInput }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
setError('Delta-Analyse fehlgeschlagen')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setResult(await res.json())
|
||||||
|
} catch {
|
||||||
|
setError('Verbindung fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addedP = result?.added_patterns?.length || 0
|
||||||
|
const removedP = result?.removed_patterns?.length || 0
|
||||||
|
const addedH = result?.added_hazards?.length || 0
|
||||||
|
const removedH = result?.removed_hazards?.length || 0
|
||||||
|
const addedM = result?.added_measures?.length || 0
|
||||||
|
const removedM = result?.removed_measures?.length || 0
|
||||||
|
const hasChanges = addedP + removedP + addedH + removedH + addedM + removedM > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Delta-Vorschau</h2>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">{changeDescription}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600" />
|
||||||
|
<span className="ml-3 text-sm text-gray-500">Berechne Auswirkungen...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 text-red-700 rounded-lg p-3 text-sm">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && !loading && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Summary Grid */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<DeltaStat label="Patterns" added={addedP} removed={removedP} />
|
||||||
|
<DeltaStat label="Gefaehrdungen" added={addedH} removed={removedH} />
|
||||||
|
<DeltaStat label="Massnahmen" added={addedM} removed={removedM} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasChanges && (
|
||||||
|
<p className="text-sm text-gray-400 italic text-center py-2">
|
||||||
|
Keine Auswirkungen erkannt — die Aenderung beeinflusst keine Patterns.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Added Hazards */}
|
||||||
|
{addedH > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-green-700 mb-1">+ Neue Gefaehrdungen</h3>
|
||||||
|
<ul className="space-y-0.5 max-h-32 overflow-y-auto">
|
||||||
|
{result!.added_hazards!.slice(0, 15).map((h, i) => (
|
||||||
|
<li key={i} className="text-xs text-gray-600 flex items-center gap-1">
|
||||||
|
<span className="text-green-500 flex-shrink-0">+</span>
|
||||||
|
<span className="truncate">{h.name || h.category}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{addedH > 15 && <li className="text-xs text-gray-400">... und {addedH - 15} weitere</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Removed Hazards */}
|
||||||
|
{removedH > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold text-red-700 mb-1">- Entfallene Gefaehrdungen</h3>
|
||||||
|
<ul className="space-y-0.5 max-h-32 overflow-y-auto">
|
||||||
|
{result!.removed_hazards!.slice(0, 10).map((h, i) => (
|
||||||
|
<li key={i} className="text-xs text-gray-600 flex items-center gap-1">
|
||||||
|
<span className="text-red-500 flex-shrink-0">-</span>
|
||||||
|
<span className="truncate">{h.name || h.category}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onApply}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-5 py-2 text-sm font-medium bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Aenderung uebernehmen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeltaStat({ label, added, removed }: { label: string; added: number; removed: number }) {
|
||||||
|
return (
|
||||||
|
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<div className="text-xs text-gray-500 mb-1">{label}</div>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
{added > 0 && <span className="text-sm font-bold text-green-600">+{added}</span>}
|
||||||
|
{removed > 0 && <span className="text-sm font-bold text-red-600">-{removed}</span>}
|
||||||
|
{added === 0 && removed === 0 && <span className="text-sm text-gray-400">0</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -95,13 +95,13 @@ export default function IACEFlowFAB() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end">
|
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end pointer-events-none">
|
||||||
{/* Expanded Panel */}
|
{/* Expanded Panel */}
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className={`mb-3 w-[300px] max-h-[70vh] overflow-y-auto bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 transition-all duration-200 origin-bottom-right ${
|
className={`mb-3 w-[300px] max-h-[70vh] overflow-y-auto bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 transition-all duration-200 origin-bottom-right ${
|
||||||
isOpen
|
isOpen
|
||||||
? 'opacity-100 scale-100 translate-y-0'
|
? 'opacity-100 scale-100 translate-y-0 pointer-events-auto'
|
||||||
: 'opacity-0 scale-95 translate-y-2 pointer-events-none'
|
: 'opacity-0 scale-95 translate-y-2 pointer-events-none'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -223,7 +223,7 @@ export default function IACEFlowFAB() {
|
|||||||
<button
|
<button
|
||||||
ref={fabRef}
|
ref={fabRef}
|
||||||
onClick={() => setIsOpen((o) => !o)}
|
onClick={() => setIsOpen((o) => !o)}
|
||||||
className="w-14 h-14 rounded-full bg-gradient-to-br from-purple-600 to-indigo-600 text-white shadow-lg hover:shadow-xl hover:scale-105 active:scale-95 transition-all flex items-center justify-center"
|
className="pointer-events-auto w-14 h-14 rounded-full bg-gradient-to-br from-purple-600 to-indigo-600 text-white shadow-lg hover:shadow-xl hover:scale-105 active:scale-95 transition-all flex items-center justify-center"
|
||||||
title="CE-Prozessschritte"
|
title="CE-Prozessschritte"
|
||||||
>
|
>
|
||||||
{/* Steps/flow icon */}
|
{/* Steps/flow icon */}
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
interface VariantProject {
|
||||||
|
id: string
|
||||||
|
machine_name: string
|
||||||
|
description?: string
|
||||||
|
status: string
|
||||||
|
hazard_count?: number
|
||||||
|
parent_project_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VariantGapResponse {
|
||||||
|
base_project: { id: string; name: string; hazard_count: number; measure_count: number }
|
||||||
|
variant: { id: string; name: string; hazard_count: number; measure_count: number }
|
||||||
|
gap: { additional_hazards: number; additional_measures: number; categories_affected: string[] }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseProjectSummary {
|
||||||
|
hazard_count: number
|
||||||
|
component_count: number
|
||||||
|
mitigation_count: number
|
||||||
|
norms_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string
|
||||||
|
parentProjectId?: string | null
|
||||||
|
parentProjectName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function VariantBanner({ projectId, parentProjectId, parentProjectName }: { projectId: string; parentProjectId: string; parentProjectName?: string }) {
|
||||||
|
const [baseSummary, setBaseSummary] = useState<BaseProjectSummary | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadBase() {
|
||||||
|
try {
|
||||||
|
const [projRes, riskRes] = await Promise.all([
|
||||||
|
fetch(`/api/sdk/v1/iace/projects/${parentProjectId}`),
|
||||||
|
fetch(`/api/sdk/v1/iace/projects/${parentProjectId}/risk-summary`),
|
||||||
|
])
|
||||||
|
const proj = projRes.ok ? await projRes.json() : null
|
||||||
|
const risk = riskRes.ok ? await riskRes.json() : null
|
||||||
|
const rs = risk?.risk_summary || risk || {}
|
||||||
|
setBaseSummary({
|
||||||
|
hazard_count: rs.total_hazards || rs.total || 0,
|
||||||
|
component_count: proj?.components?.length || 0,
|
||||||
|
mitigation_count: rs.total_mitigations || 0,
|
||||||
|
norms_count: 0,
|
||||||
|
})
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
loadBase()
|
||||||
|
}, [parentProjectId])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-purple-200 dark:border-purple-700 p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-purple-50 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-white">Variante</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Diese Seite zeigt nur die <strong>varianten-spezifischen</strong> Gefaehrdungen und Massnahmen.
|
||||||
|
Die Basis-Risikobeurteilung liegt im Eltern-Projekt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/sdk/iace/${parentProjectId}`}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-purple-700 bg-purple-50 rounded-lg hover:bg-purple-100 dark:text-purple-300 dark:bg-purple-900/30 dark:hover:bg-purple-900/50 transition-colors"
|
||||||
|
>
|
||||||
|
{parentProjectName || 'Basis-Projekt'}
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||||
|
</svg>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{baseSummary && (
|
||||||
|
<div className="bg-purple-50/50 dark:bg-purple-900/10 rounded-lg p-3 border border-purple-100 dark:border-purple-800">
|
||||||
|
<p className="text-xs font-medium text-purple-700 dark:text-purple-300 mb-2">Basis-Projekt Zusammenfassung</p>
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold text-gray-900 dark:text-white">{baseSummary.hazard_count}</div>
|
||||||
|
<div className="text-xs text-gray-500">Gefaehrdungen</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold text-gray-900 dark:text-white">{baseSummary.mitigation_count}</div>
|
||||||
|
<div className="text-xs text-gray-500">Massnahmen</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold text-gray-900 dark:text-white">{baseSummary.component_count}</div>
|
||||||
|
<div className="text-xs text-gray-500">Komponenten</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VariantPanel({ projectId, parentProjectId, parentProjectName }: Props) {
|
||||||
|
const [variants, setVariants] = useState<VariantProject[]>([])
|
||||||
|
const [gapMap, setGapMap] = useState<Record<string, VariantGapResponse>>({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
|
||||||
|
const fetchVariants = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/variants`)
|
||||||
|
if (!res.ok) {
|
||||||
|
setVariants([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const json = await res.json()
|
||||||
|
const list: VariantProject[] = json.variants || json.projects || []
|
||||||
|
setVariants(list)
|
||||||
|
|
||||||
|
// Fetch gap analysis for this project
|
||||||
|
const gapRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/variant-gap`)
|
||||||
|
if (gapRes.ok) {
|
||||||
|
const gapJson = await gapRes.json()
|
||||||
|
const gaps: Record<string, VariantGapResponse> = {}
|
||||||
|
// Could be a single gap or array — handle both
|
||||||
|
if (Array.isArray(gapJson)) {
|
||||||
|
for (const g of gapJson) {
|
||||||
|
gaps[g.variant?.id] = g
|
||||||
|
}
|
||||||
|
} else if (gapJson.variant) {
|
||||||
|
gaps[gapJson.variant.id] = gapJson
|
||||||
|
}
|
||||||
|
setGapMap(gaps)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setVariants([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchVariants()
|
||||||
|
}, [fetchVariants])
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!name.trim()) return
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/variants`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
machine_name: name.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setName('')
|
||||||
|
setDescription('')
|
||||||
|
setShowCreate(false)
|
||||||
|
fetchVariants()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silently handle
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this project IS a variant, show link to base project + base stats
|
||||||
|
if (parentProjectId) {
|
||||||
|
return <VariantBanner projectId={projectId} parentProjectId={parentProjectId} parentProjectName={parentProjectName} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return null
|
||||||
|
if (variants.length === 0 && !showCreate) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-gray-50 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-white">Keine Varianten</p>
|
||||||
|
<p className="text-xs text-gray-500">Erstellen Sie Varianten fuer verschiedene Betriebsarten</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="px-3 py-1.5 text-sm font-medium text-purple-700 bg-purple-50 rounded-lg hover:bg-purple-100 dark:text-purple-300 dark:bg-purple-900/30 dark:hover:bg-purple-900/50 transition-colors"
|
||||||
|
>
|
||||||
|
+ Neue Variante
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{renderCreateDialog()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCreateDialog() {
|
||||||
|
if (!showCreate) return null
|
||||||
|
return (
|
||||||
|
<div className="mt-4 p-4 border border-purple-200 dark:border-purple-700 rounded-lg bg-purple-50/50 dark:bg-purple-900/10 space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Neue Variante erstellen</h3>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Variantenname (z.B. Kollaborierender Betrieb)"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder="Beschreibung (optional)"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowCreate(false); setName(''); setDescription('') }}
|
||||||
|
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={creating || !name.trim()}
|
||||||
|
className="px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{creating ? 'Erstelle...' : 'Erstellen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-purple-50 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Varianten ({variants.length})
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-gray-500">Betriebsart-spezifische Projektversionen</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="px-3 py-1.5 text-sm font-medium text-purple-700 bg-purple-50 rounded-lg hover:bg-purple-100 dark:text-purple-300 dark:bg-purple-900/30 dark:hover:bg-purple-900/50 transition-colors"
|
||||||
|
>
|
||||||
|
+ Neue Variante
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{variants.map((v) => {
|
||||||
|
const gap = gapMap[v.id]
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={v.id}
|
||||||
|
href={`/sdk/iace/${v.id}`}
|
||||||
|
className="block p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md hover:border-purple-300 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white truncate group-hover:text-purple-700 dark:group-hover:text-purple-400">
|
||||||
|
{v.machine_name}
|
||||||
|
</p>
|
||||||
|
{v.description && (
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{v.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<svg className="w-4 h-4 text-gray-400 group-hover:text-purple-600 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{gap && gap.gap.additional_hazards > 0 && (
|
||||||
|
<span className="inline-flex items-center mt-2 px-2 py-0.5 text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/50 dark:text-orange-300 rounded-full">
|
||||||
|
+{gap.gap.additional_hazards} Gefaehrdungen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{renderCreateDialog()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import type { CategoryScore } from '../_hooks/useBenchmark'
|
||||||
|
|
||||||
|
interface Props { breakdown: CategoryScore[] }
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
|
'mechanische gefaehrdungen': 'Mechanisch',
|
||||||
|
'elektrische gefaehrdungen': 'Elektrisch',
|
||||||
|
'thermische gefaehrdungen': 'Thermisch',
|
||||||
|
'laerm': 'Laerm',
|
||||||
|
'vibration': 'Vibration',
|
||||||
|
'strahlung': 'Strahlung',
|
||||||
|
'materialien und substanzen': 'Materialien/Substanzen',
|
||||||
|
'ergonomische gefaehrdungen': 'Ergonomie',
|
||||||
|
'einsatzumgebung': 'Einsatzumgebung',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoryBreakdown({ breakdown }: Props) {
|
||||||
|
if (!breakdown || breakdown.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Coverage nach Gefaehrdungsgruppe</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{breakdown.map((cat) => {
|
||||||
|
const label = CATEGORY_LABELS[cat.category] || cat.category
|
||||||
|
const pct = Math.round(cat.coverage * 100)
|
||||||
|
const barColor = pct >= 80 ? 'bg-green-500' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500'
|
||||||
|
return (
|
||||||
|
<div key={cat.category}>
|
||||||
|
<div className="flex justify-between text-xs text-gray-600 dark:text-gray-400 mb-0.5">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span>{cat.match_count}/{cat.gt_count} ({pct}%)</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full ${barColor} rounded-full transition-all`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react'
|
||||||
|
import type { GroundTruthEntry } from '../_hooks/useBenchmark'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onImport: (gt: { entries: GroundTruthEntry[]; source_file?: string; description?: string }) => Promise<void>
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GTImportForm({ onImport, loading }: Props) {
|
||||||
|
const [jsonText, setJsonText] = useState('')
|
||||||
|
const [parseError, setParseError] = useState<string | null>(null)
|
||||||
|
const [preview, setPreview] = useState<{ count: number; groups: Record<string, number> } | null>(null)
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
function tryParse(text: string) {
|
||||||
|
setJsonText(text)
|
||||||
|
setParseError(null)
|
||||||
|
setPreview(null)
|
||||||
|
if (!text.trim()) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text)
|
||||||
|
const entries: GroundTruthEntry[] = parsed.entries || parsed
|
||||||
|
if (!Array.isArray(entries) || entries.length === 0) {
|
||||||
|
setParseError('JSON muss ein Array "entries" enthalten')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Validate first entry has required fields
|
||||||
|
const first = entries[0]
|
||||||
|
if (!first.hazard_type && !first.hazard_group) {
|
||||||
|
setParseError('Eintraege muessen hazard_type oder hazard_group enthalten')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Build preview
|
||||||
|
const groups: Record<string, number> = {}
|
||||||
|
for (const e of entries) {
|
||||||
|
const g = e.hazard_group || 'Unbekannt'
|
||||||
|
groups[g] = (groups[g] || 0) + 1
|
||||||
|
}
|
||||||
|
setPreview({ count: entries.length, groups })
|
||||||
|
} catch (err) {
|
||||||
|
setParseError('Ungueltiges JSON: ' + (err instanceof Error ? err.message : String(err)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
if (!jsonText.trim()) return
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonText)
|
||||||
|
const gt = parsed.entries ? parsed : { entries: parsed }
|
||||||
|
await onImport(gt)
|
||||||
|
setJsonText('')
|
||||||
|
setPreview(null)
|
||||||
|
} catch (err) {
|
||||||
|
setParseError(err instanceof Error ? err.message : 'Import fehlgeschlagen')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
const text = ev.target?.result as string
|
||||||
|
tryParse(text)
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Ground Truth importieren</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
|
JSON-Datei mit der professionellen Risikobeurteilung einfuegen oder hochladen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<button
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
className="px-3 py-1.5 text-xs bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
JSON-Datei waehlen
|
||||||
|
</button>
|
||||||
|
<input ref={fileRef} type="file" accept=".json" onChange={handleFileUpload} className="hidden" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={jsonText}
|
||||||
|
onChange={(e) => tryParse(e.target.value)}
|
||||||
|
placeholder='{"entries": [...], "source_file": "...", "description": "..."}'
|
||||||
|
rows={6}
|
||||||
|
className="w-full text-xs font-mono border border-gray-300 dark:border-gray-600 rounded-md p-2 bg-gray-50 dark:bg-gray-900 text-gray-800 dark:text-gray-200 resize-y"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{parseError && (
|
||||||
|
<div className="mt-2 px-3 py-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded text-xs text-red-600">
|
||||||
|
{parseError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<div className="mt-2 px-3 py-2 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded text-xs text-green-700 dark:text-green-400">
|
||||||
|
<strong>{preview.count} Eintraege</strong> erkannt:
|
||||||
|
{Object.entries(preview.groups).map(([g, c]) => (
|
||||||
|
<span key={g} className="ml-2">{g}: {c}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={loading || !preview}
|
||||||
|
className="mt-3 w-full px-4 py-2 text-sm font-medium bg-purple-600 hover:bg-purple-700 disabled:bg-gray-300 text-white rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Importiere...' : 'Ground Truth importieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+280
@@ -0,0 +1,280 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import type { HazardMatchPair, GroundTruthEntry, HazardSummary } from '../_hooks/useBenchmark'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
matched: HazardMatchPair[]
|
||||||
|
missing: GroundTruthEntry[]
|
||||||
|
extra: HazardSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabType = 'matched' | 'missing' | 'extra'
|
||||||
|
|
||||||
|
export function HazardComparisonTable({ matched, missing, extra }: Props) {
|
||||||
|
const [tab, setTab] = useState<TabType>('matched')
|
||||||
|
|
||||||
|
// Split matches: >= 50% are real matches, < 50% are weak (shown separately)
|
||||||
|
const realMatched = matched.filter(p => p.match_score >= 0.5)
|
||||||
|
const weakMatched = matched.filter(p => p.match_score < 0.5)
|
||||||
|
|
||||||
|
// Weak matches: GT entries go to "missing", engine entries go to "extra"
|
||||||
|
const allMissing = [...missing, ...weakMatched.map(w => w.gt_entry)]
|
||||||
|
const allExtra = [...extra, ...weakMatched.map(w => w.engine_hazard)]
|
||||||
|
|
||||||
|
const greenCount = realMatched.filter(p => p.match_score >= 0.7).length
|
||||||
|
const yellowCount = realMatched.filter(p => p.match_score >= 0.5 && p.match_score < 0.7).length
|
||||||
|
|
||||||
|
const tabs: { id: TabType; label: string; count: number; color: string }[] = [
|
||||||
|
{ id: 'matched', label: `Zugeordnet (${greenCount} exakt, ${yellowCount} aehnlich)`, count: realMatched.length, color: 'text-green-600' },
|
||||||
|
{ id: 'missing', label: 'Fehlend', count: allMissing.length, color: 'text-red-600' },
|
||||||
|
{ id: 'extra', label: 'Engine Findings', count: allExtra.length, color: 'text-blue-500' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => setTab(t.id)}
|
||||||
|
className={`flex-1 px-4 py-2.5 text-xs font-medium transition-colors ${
|
||||||
|
tab === t.id
|
||||||
|
? 'border-b-2 border-purple-600 text-purple-700 dark:text-purple-400'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label} <span className={t.color}>({t.count})</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
{tab === 'matched' && <MatchedTable pairs={realMatched} />}
|
||||||
|
{tab === 'missing' && <MissingTable entries={allMissing} />}
|
||||||
|
{tab === 'extra' && <ExtraTable entries={allExtra} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MatchedTable({ pairs }: { pairs: HazardMatchPair[] }) {
|
||||||
|
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
|
||||||
|
if (pairs.length === 0) return <EmptyState text="Keine Zuordnungen gefunden" />
|
||||||
|
return (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-500">Nr.</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-500">Ground Truth</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-500">R</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-500">Engine</th>
|
||||||
|
<th className="px-3 py-2 text-center font-medium text-gray-500">Score</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-500">Qualitaet</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{pairs.map((p, i) => {
|
||||||
|
const quality = p.match_score >= 0.7 ? 'green' : p.match_score >= 0.4 ? 'yellow' : 'red'
|
||||||
|
const rowBg = quality === 'green' ? 'bg-green-50/30 dark:bg-green-900/5'
|
||||||
|
: quality === 'yellow' ? 'bg-yellow-50/30 dark:bg-yellow-900/5' : ''
|
||||||
|
const isOpen = expanded[i]
|
||||||
|
return (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
<tr className={`hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer ${rowBg}`}
|
||||||
|
onClick={() => setExpanded(prev => ({ ...prev, [i]: !prev[i] }))}>
|
||||||
|
<td className="px-3 py-2 text-gray-400">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<svg className={`w-3 h-3 text-gray-400 transition-transform ${isOpen ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
{p.gt_entry.nr}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="font-medium text-gray-800 dark:text-gray-200">{p.gt_entry.hazard_type}</div>
|
||||||
|
<div className="text-gray-400 truncate max-w-[250px]">{p.gt_entry.component_zone}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
<RiskBadge risk={p.gt_entry.risk_in.r} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="font-medium text-gray-800 dark:text-gray-200">{p.engine_hazard.name}</div>
|
||||||
|
<div className="text-gray-400">{p.engine_hazard.category}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-center"><ScoreBadge score={p.match_score} /></td>
|
||||||
|
<td className="px-3 py-2"><QualityBadge quality={quality} /></td>
|
||||||
|
</tr>
|
||||||
|
{isOpen && (
|
||||||
|
<tr className="bg-gray-50/70 dark:bg-gray-850">
|
||||||
|
<td colSpan={6} className="px-4 py-3">
|
||||||
|
<DetailComparison gt={p.gt_entry} engine={p.engine_hazard} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LIFECYCLE_LABELS: Record<string, string> = {
|
||||||
|
startup: 'Hochfahren', homing: 'Referenzfahrt', automatic_operation: 'Automatikbetrieb',
|
||||||
|
manual_operation: 'Handbetrieb', teach_mode: 'Einrichtbetrieb', maintenance: 'Wartung',
|
||||||
|
cleaning: 'Reinigung', emergency_stop: 'Not-Halt', recovery_mode: 'Wiederanlauf',
|
||||||
|
normal_operation: 'Automatikbetrieb', setup: 'Einrichten', changeover: 'Umruesten',
|
||||||
|
fault_clearing: 'Fehlersuche/Stoerungsbeseitigung', commissioning: 'Inbetriebnahme',
|
||||||
|
decommissioning: 'Demontage/Ausserbetriebnahme', transport: 'Transport',
|
||||||
|
assembly: 'Montage/Installation', inspection: 'Inspektion/Pruefung',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLifecycles(raw: string): string {
|
||||||
|
if (!raw) return '-'
|
||||||
|
return raw.split(',').map(s => s.trim()).map(s => LIFECYCLE_LABELS[s] || s).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Side-by-side detail comparison of GT entry vs. Engine hazard */
|
||||||
|
function DetailComparison({ gt, engine }: { gt: GroundTruthEntry; engine: HazardSummary }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||||
|
{/* Left: Ground Truth */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="font-semibold text-red-700 dark:text-red-400 uppercase text-[10px]">Ground Truth (Fachmann)</div>
|
||||||
|
<DetailRow label="Gefaehrdung" gt={gt.hazard_type} />
|
||||||
|
<DetailRow label="Ursache" gt={gt.hazard_cause} />
|
||||||
|
<DetailRow label="Gefahrenstelle" gt={gt.component_zone} />
|
||||||
|
<DetailRow label="Lebensphasen" gt={gt.lifecycle_phases?.join(', ') || '-'} />
|
||||||
|
<DetailRow label="Risiko" gt={`F=${gt.risk_in.f} W=${gt.risk_in.w} P=${gt.risk_in.p} S=${gt.risk_in.s} => R=${gt.risk_in.r}`} />
|
||||||
|
{gt.risk_out.r > 0 && (
|
||||||
|
<DetailRow label="Restrisiko" gt={`F=${gt.risk_out.f} W=${gt.risk_out.w} P=${gt.risk_out.p} S=${gt.risk_out.s} => R=${gt.risk_out.r}`} />
|
||||||
|
)}
|
||||||
|
<DetailRow label="Massnahmen" gt={gt.measures?.join('\n') || '-'} multiline />
|
||||||
|
<DetailRow label="Typ" gt={gt.measure_type || '-'} />
|
||||||
|
{gt.norm_references?.length > 0 && (
|
||||||
|
<DetailRow label="Normen" gt={gt.norm_references.join(', ')} />
|
||||||
|
)}
|
||||||
|
<DetailRow label="Hinreichend" gt={gt.sufficient ? 'JA' : 'NEIN'} />
|
||||||
|
{gt.comment && <DetailRow label="Kommentar" gt={gt.comment} />}
|
||||||
|
</div>
|
||||||
|
{/* Right: Engine */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="font-semibold text-purple-700 dark:text-purple-400 uppercase text-[10px]">Engine (automatisch)</div>
|
||||||
|
<DetailRow label="Gefaehrdung" gt={engine.name} />
|
||||||
|
<DetailRow label="Szenario" gt={engine.scenario || engine.description || '-'} />
|
||||||
|
<DetailRow label="Gefahrenstelle" gt={engine.zone || '-'} />
|
||||||
|
{engine.lifecycle_phase && (
|
||||||
|
<DetailRow label="Lebensphasen" gt={formatLifecycles(engine.lifecycle_phase)} />
|
||||||
|
)}
|
||||||
|
<DetailRow label="Moeglicher Schaden" gt={engine.possible_harm || '-'} />
|
||||||
|
<DetailRow label="Trigger" gt={engine.trigger_event || '-'} />
|
||||||
|
{engine.affected_person && (
|
||||||
|
<DetailRow label="Betroffene Personen" gt={engine.affected_person} />
|
||||||
|
)}
|
||||||
|
{engine.mitigations && engine.mitigations.length > 0 ? (
|
||||||
|
<DetailRow label="Massnahmen" gt={engine.mitigations.join('\n')} multiline />
|
||||||
|
) : (
|
||||||
|
<DetailRow label="Massnahmen" gt="(keine zugeordnet)" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, gt, multiline }: { label: string; gt: string; multiline?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-medium text-gray-500 uppercase">{label}</div>
|
||||||
|
{multiline ? (
|
||||||
|
<pre className="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap font-sans mt-0.5">{gt}</pre>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-gray-700 dark:text-gray-300 mt-0.5">{gt}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MissingTable({ entries }: { entries: GroundTruthEntry[] }) {
|
||||||
|
if (entries.length === 0) return <EmptyState text="Keine fehlenden Gefaehrdungen" />
|
||||||
|
return (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-red-50 dark:bg-red-900/20">
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-red-600">Nr.</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-red-600">Gefaehrdung</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-red-600">Ursache</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-red-600">Zone</th>
|
||||||
|
<th className="px-3 py-2 text-center font-medium text-red-600">R</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-red-600">Typ</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{entries.map((e, i) => (
|
||||||
|
<tr key={i} className="hover:bg-red-50/50">
|
||||||
|
<td className="px-3 py-2 text-gray-400">{e.nr}</td>
|
||||||
|
<td className="px-3 py-2 font-medium text-gray-800 dark:text-gray-200">{e.hazard_type}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-600 truncate max-w-[200px]">{e.hazard_cause}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-500">{e.component_zone}</td>
|
||||||
|
<td className="px-3 py-2 text-center"><RiskBadge risk={e.risk_in.r} /></td>
|
||||||
|
<td className="px-3 py-2 text-gray-500">{e.measure_type}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExtraTable({ entries }: { entries: HazardSummary[] }) {
|
||||||
|
if (entries.length === 0) return <EmptyState text="Keine zusaetzlichen Engine-Gefaehrdungen" />
|
||||||
|
return (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-500">Name</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-500">Kategorie</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-500">Zone</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{entries.map((e, i) => (
|
||||||
|
<tr key={i} className="hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||||
|
<td className="px-3 py-2 text-gray-800 dark:text-gray-200">{e.name}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-500">{e.category}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-400">{e.zone || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RiskBadge({ risk }: { risk: number }) {
|
||||||
|
const color = risk >= 30 ? 'bg-red-100 text-red-700' : risk >= 15 ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'
|
||||||
|
return <span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${color}`}>{risk}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScoreBadge({ score }: { score: number }) {
|
||||||
|
const pct = Math.round(score * 100)
|
||||||
|
const color = pct >= 70 ? 'text-green-600' : pct >= 50 ? 'text-yellow-600' : 'text-red-600'
|
||||||
|
return <span className={`font-bold ${color}`}>{pct}%</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
function QualityBadge({ quality }: { quality: 'green' | 'yellow' | 'red' }) {
|
||||||
|
const styles = {
|
||||||
|
green: 'bg-green-100 text-green-700 border-green-200',
|
||||||
|
yellow: 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||||
|
red: 'bg-red-100 text-red-700 border-red-200',
|
||||||
|
}
|
||||||
|
const labels = { green: 'Exakt', yellow: 'Aehnlich', red: 'Schwach' }
|
||||||
|
return (
|
||||||
|
<span className={`inline-block px-1.5 py-0.5 rounded border text-[10px] font-medium ${styles[quality]}`}>
|
||||||
|
{labels[quality]}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ text }: { text: string }) {
|
||||||
|
return <div className="px-4 py-8 text-center text-sm text-gray-400">{text}</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
export interface GTRisk { f: number; w: number; p: number; s: number; r: number }
|
||||||
|
export interface GTPLr { s: string; f: string; p: string; ew?: string; plr: string }
|
||||||
|
|
||||||
|
export interface GroundTruthEntry {
|
||||||
|
nr: string
|
||||||
|
hazard_group: string
|
||||||
|
hazard_group_applicable: boolean
|
||||||
|
hazard_subgroup: string
|
||||||
|
hazard_type: string
|
||||||
|
hazard_cause: string
|
||||||
|
lifecycle_phases: string[]
|
||||||
|
component_zone: string
|
||||||
|
risk_in: GTRisk
|
||||||
|
plr?: GTPLr | null
|
||||||
|
measures: string[]
|
||||||
|
measure_type: string
|
||||||
|
risk_out: GTRisk
|
||||||
|
norm_references: string[]
|
||||||
|
sufficient: boolean
|
||||||
|
comment?: string
|
||||||
|
reduction_steps?: {
|
||||||
|
risk_in: GTRisk; measures: string[]; measure_type: string
|
||||||
|
risk_out: GTRisk; norm_references: string[]; sufficient: boolean
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HazardSummary {
|
||||||
|
id: string; name: string; category: string
|
||||||
|
component?: string; zone?: string; risk_level?: string
|
||||||
|
description?: string; scenario?: string
|
||||||
|
possible_harm?: string; trigger_event?: string
|
||||||
|
affected_person?: string; lifecycle_phase?: string
|
||||||
|
mitigations?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HazardMatchPair {
|
||||||
|
gt_entry: GroundTruthEntry
|
||||||
|
engine_hazard: HazardSummary
|
||||||
|
match_score: number
|
||||||
|
match_reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryScore {
|
||||||
|
category: string; gt_count: number; match_count: number; coverage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BenchmarkResult {
|
||||||
|
coverage_score: number
|
||||||
|
measure_coverage: number
|
||||||
|
total_gt: number
|
||||||
|
total_engine: number
|
||||||
|
matched_pairs: HazardMatchPair[]
|
||||||
|
missing_from_engine: GroundTruthEntry[]
|
||||||
|
extra_in_engine: HazardSummary[]
|
||||||
|
category_breakdown: CategoryScore[]
|
||||||
|
risk_rank_pairs: { gt_rank: number; engine_rank: number; hazard_name: string; gt_risk_score: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseBenchmarkReturn {
|
||||||
|
result: BenchmarkResult | null
|
||||||
|
gtLoaded: boolean
|
||||||
|
gtEntryCount: number
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
importGT: (gt: { entries: GroundTruthEntry[]; source_file?: string; description?: string }) => Promise<void>
|
||||||
|
runBenchmark: (gtProjectId?: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBenchmark(projectId: string): UseBenchmarkReturn {
|
||||||
|
const [result, setResult] = useState<BenchmarkResult | null>(null)
|
||||||
|
const [gtLoaded, setGtLoaded] = useState(false)
|
||||||
|
const [gtEntryCount, setGtEntryCount] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const importGT = useCallback(async (gt: { entries: GroundTruthEntry[]; source_file?: string; description?: string }) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/benchmark/import-gt`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(gt),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(await res.text())
|
||||||
|
const data = await res.json()
|
||||||
|
setGtLoaded(true)
|
||||||
|
setGtEntryCount(data.entry_count || gt.entries.length)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Import failed')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
const runBenchmark = useCallback(async (gtProjectId?: string) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const params = gtProjectId ? `?gt_project_id=${gtProjectId}` : ''
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/benchmark${params}`)
|
||||||
|
if (!res.ok) throw new Error(await res.text())
|
||||||
|
const data: BenchmarkResult = await res.json()
|
||||||
|
setResult(data)
|
||||||
|
setGtLoaded(true)
|
||||||
|
setGtEntryCount(data.total_gt)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Benchmark failed')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
return { result, gtLoaded, gtEntryCount, loading, error, importGT, runBenchmark }
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { useBenchmark } from './_hooks/useBenchmark'
|
||||||
|
import { GTImportForm } from './_components/GTImportForm'
|
||||||
|
import { HazardComparisonTable } from './_components/HazardComparisonTable'
|
||||||
|
import { CategoryBreakdown } from './_components/CategoryBreakdown'
|
||||||
|
|
||||||
|
export default function BenchmarkPage() {
|
||||||
|
const { projectId } = useParams<{ projectId: string }>()
|
||||||
|
const { result, gtLoaded, gtEntryCount, loading, error, importGT, runBenchmark } = useBenchmark(projectId)
|
||||||
|
const [gtProjectId, setGtProjectId] = useState('')
|
||||||
|
|
||||||
|
// Only count matches >= 50% as real coverage
|
||||||
|
const realMatchCount = result ? (result.matched_pairs?.filter(m => m.match_score >= 0.5).length || 0) : 0
|
||||||
|
const coveragePct = result ? Math.round(realMatchCount * 100 / Math.max(result.total_gt, 1)) : 0
|
||||||
|
const measurePct = result ? Math.round(result.measure_coverage * 100) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-[1200px]">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-gray-900 dark:text-white">Ground Truth Benchmark</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Vergleich der Engine-Ergebnisse mit einer professionellen Risikobeurteilung
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="px-4 py-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-600">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* GT Import or Cross-Project Reference */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<GTImportForm onImport={importGT} loading={loading} />
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Benchmark ausfuehren</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
|
GT aus diesem Projekt verwenden, oder eine Projekt-ID mit importierter GT angeben.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={gtProjectId}
|
||||||
|
onChange={(e) => setGtProjectId(e.target.value)}
|
||||||
|
placeholder="GT-Projekt-ID (optional — leer = dieses Projekt)"
|
||||||
|
className="w-full text-xs border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-gray-50 dark:bg-gray-900"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => runBenchmark(gtProjectId || undefined)}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full px-4 py-2 text-sm font-medium bg-purple-600 hover:bg-purple-700 disabled:bg-gray-300 text-white rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Vergleiche...' : 'Benchmark starten'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gtLoaded && !result && (
|
||||||
|
<div className="mt-3 px-3 py-2 bg-blue-50 dark:bg-blue-900/20 rounded text-xs text-blue-600">
|
||||||
|
{gtEntryCount} GT-Eintraege geladen. Klicke "Benchmark starten".
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{result && (
|
||||||
|
<>
|
||||||
|
{/* Score Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<ScoreCard
|
||||||
|
label="Hazard Coverage"
|
||||||
|
value={`${coveragePct}%`}
|
||||||
|
sub={`${realMatchCount} / ${result.total_gt} erkannt (>= 50% Match)`}
|
||||||
|
color={coveragePct >= 80 ? 'green' : coveragePct >= 50 ? 'yellow' : 'red'}
|
||||||
|
/>
|
||||||
|
<ScoreCard
|
||||||
|
label="Massnahmen-Coverage"
|
||||||
|
value={`${measurePct}%`}
|
||||||
|
sub="der zugeordneten Gefaehrdungen"
|
||||||
|
color={measurePct >= 80 ? 'green' : measurePct >= 50 ? 'yellow' : 'red'}
|
||||||
|
/>
|
||||||
|
<ScoreCard
|
||||||
|
label="GT Eintraege"
|
||||||
|
value={String(result.total_gt)}
|
||||||
|
sub="professionelle Beurteilung"
|
||||||
|
color="gray"
|
||||||
|
/>
|
||||||
|
<ScoreCard
|
||||||
|
label="Engine Eintraege"
|
||||||
|
value={String(result.total_engine)}
|
||||||
|
sub={`${result.extra_in_engine?.length || 0} zusaetzlich`}
|
||||||
|
color="gray"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Breakdown */}
|
||||||
|
<CategoryBreakdown breakdown={result.category_breakdown || []} />
|
||||||
|
|
||||||
|
{/* Hazard Comparison Table */}
|
||||||
|
<HazardComparisonTable
|
||||||
|
matched={result.matched_pairs || []}
|
||||||
|
missing={result.missing_from_engine || []}
|
||||||
|
extra={result.extra_in_engine || []}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Business Impact */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Business Impact</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900 dark:text-white">2,5 Tage</div>
|
||||||
|
<div className="text-xs text-gray-500">Manueller Aufwand</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-purple-600">
|
||||||
|
{(coveragePct / 100 * 2.5).toFixed(1)} Tage
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">Eingespart bei {coveragePct}% Coverage</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-green-600">
|
||||||
|
{Math.round(coveragePct / 100 * 2.5 * 8 * 100)} EUR
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">Einsparung (100 EUR/h)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScoreCard({ label, value, sub, color }: {
|
||||||
|
label: string; value: string; sub: string
|
||||||
|
color: 'green' | 'yellow' | 'red' | 'gray'
|
||||||
|
}) {
|
||||||
|
const colors = {
|
||||||
|
green: 'border-green-200 dark:border-green-800',
|
||||||
|
yellow: 'border-yellow-200 dark:border-yellow-800',
|
||||||
|
red: 'border-red-200 dark:border-red-800',
|
||||||
|
gray: 'border-gray-200 dark:border-gray-700',
|
||||||
|
}
|
||||||
|
const textColors = {
|
||||||
|
green: 'text-green-600', yellow: 'text-yellow-600',
|
||||||
|
red: 'text-red-600', gray: 'text-gray-900 dark:text-white',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-white dark:bg-gray-800 rounded-lg border-2 ${colors[color]} p-4 text-center`}>
|
||||||
|
<div className={`text-2xl font-bold ${textColors[color]}`}>{value}</div>
|
||||||
|
<div className="text-xs font-medium text-gray-700 dark:text-gray-300 mt-1">{label}</div>
|
||||||
|
<div className="text-[10px] text-gray-400 mt-0.5">{sub}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ export function ComponentForm({
|
|||||||
version: initialData?.version || '',
|
version: initialData?.version || '',
|
||||||
description: initialData?.description || '',
|
description: initialData?.description || '',
|
||||||
safety_relevant: initialData?.safety_relevant || false,
|
safety_relevant: initialData?.safety_relevant || false,
|
||||||
|
ce_marked: initialData?.ce_marked || false,
|
||||||
parent_id: parentId || initialData?.parent_id || null,
|
parent_id: parentId || initialData?.parent_id || null,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -73,6 +74,19 @@ export function ComponentForm({
|
|||||||
</label>
|
</label>
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-300">Sicherheitsrelevant</span>
|
<span className="text-sm text-gray-700 dark:text-gray-300">Sicherheitsrelevant</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3 pt-6">
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.ce_marked}
|
||||||
|
onChange={(e) => setFormData({ ...formData, ce_marked: e.target.checked })}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-green-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-green-500" />
|
||||||
|
</label>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">Bereits CE-gekennzeichnet</span>
|
||||||
|
<span className="text-[10px] text-gray-400">(Nur Schnittstellen bewerten)</span>
|
||||||
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ export interface Component {
|
|||||||
version: string
|
version: string
|
||||||
description: string
|
description: string
|
||||||
safety_relevant: boolean
|
safety_relevant: boolean
|
||||||
|
ce_marked?: boolean
|
||||||
parent_id: string | null
|
parent_id: string | null
|
||||||
children: Component[]
|
children: Component[]
|
||||||
library_component_id?: string
|
library_component_id?: string
|
||||||
energy_source_ids?: string[]
|
energy_source_ids?: string[]
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LibraryComponent {
|
export interface LibraryComponent {
|
||||||
@@ -41,6 +43,7 @@ export interface ComponentFormData {
|
|||||||
version: string
|
version: string
|
||||||
description: string
|
description: string
|
||||||
safety_relevant: boolean
|
safety_relevant: boolean
|
||||||
|
ce_marked: boolean
|
||||||
parent_id: string | null
|
parent_id: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
export interface FailureMode {
|
||||||
|
id: string
|
||||||
|
component_type: string
|
||||||
|
mode: string
|
||||||
|
name_de: string
|
||||||
|
name_en: string
|
||||||
|
effect: string
|
||||||
|
detection_hint: string
|
||||||
|
default_severity: number
|
||||||
|
default_occurrence: number
|
||||||
|
default_detection: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Component {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
component_type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FMEARow {
|
||||||
|
component: Component
|
||||||
|
failureMode: FailureMode
|
||||||
|
severity: number
|
||||||
|
occurrence: number
|
||||||
|
detection: number
|
||||||
|
rpz: number
|
||||||
|
ap: 'H' | 'M' | 'L'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AIAG-VDA Action Priority (2019 Handbook) */
|
||||||
|
export function calculateAP(s: number, o: number, d: number): 'H' | 'M' | 'L' {
|
||||||
|
if (s >= 9) return (o >= 4 || d >= 7) ? 'H' : (o >= 2 || d >= 5) ? 'M' : 'L'
|
||||||
|
if (s >= 7) return (o >= 5 || d >= 8) ? 'H' : (o >= 3 || d >= 5) ? 'M' : 'L'
|
||||||
|
if (s >= 5) return (o >= 7 || d >= 9) ? 'H' : (o >= 4 || d >= 7) ? 'M' : 'L'
|
||||||
|
return (o >= 8 && d >= 9) ? 'H' : (o >= 6 || d >= 8) ? 'M' : 'L'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFMEA(projectId: string) {
|
||||||
|
const [rows, setRows] = useState<FMEARow[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [projectId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
try {
|
||||||
|
// Load project components
|
||||||
|
const compRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components`)
|
||||||
|
if (!compRes.ok) return
|
||||||
|
const compJson = await compRes.json()
|
||||||
|
const components: Component[] = (compJson.components || compJson || []).map(
|
||||||
|
(c: Record<string, unknown>) => ({
|
||||||
|
id: c.id as string,
|
||||||
|
name: c.name as string,
|
||||||
|
component_type: c.component_type as string || 'mechanical',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Load ALL failure modes, then match by component type + name keywords
|
||||||
|
const allRes = await fetch('/api/sdk/v1/iace/failure-modes')
|
||||||
|
let allFMs: FailureMode[] = []
|
||||||
|
if (allRes.ok) {
|
||||||
|
const json = await allRes.json()
|
||||||
|
allFMs = json.failure_modes || []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive the best FM component_type from component name keywords
|
||||||
|
const nameToFMTypes: Record<string, string[]> = {
|
||||||
|
sensor: ['sensor'], scanner: ['sensor'], kamera: ['sensor'],
|
||||||
|
motor: ['actuator', 'electrical'], antrieb: ['actuator'],
|
||||||
|
steuerung: ['controller'], sps: ['controller'], plc: ['controller'],
|
||||||
|
software: ['software'], firmware: ['software'],
|
||||||
|
ventil: ['actuator', 'mechanical'], greifer: ['actuator', 'mechanical'],
|
||||||
|
roboter: ['actuator', 'mechanical'], hydraulik: ['actuator'],
|
||||||
|
netzwerk: ['network'], ethernet: ['network'],
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFMTypesForComp(comp: Component): string[] {
|
||||||
|
const types = [comp.component_type]
|
||||||
|
const nameLower = comp.name.toLowerCase()
|
||||||
|
for (const [kw, fmTypes] of Object.entries(nameToFMTypes)) {
|
||||||
|
if (nameLower.includes(kw)) types.push(...fmTypes)
|
||||||
|
}
|
||||||
|
return [...new Set(types)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build FMEA rows: each component × its matching failure modes
|
||||||
|
const fmeaRows: FMEARow[] = []
|
||||||
|
for (const comp of components) {
|
||||||
|
const compTypes = getFMTypesForComp(comp)
|
||||||
|
const compFMs = allFMs.filter((fm) => compTypes.includes(fm.component_type))
|
||||||
|
// Use matched FMs, or fallback to mechanical FMs
|
||||||
|
const relevantFMs = compFMs.length > 0 ? compFMs : allFMs.filter((fm) => fm.component_type === 'mechanical').slice(0, 3)
|
||||||
|
|
||||||
|
for (const fm of relevantFMs) {
|
||||||
|
const s = fm.default_severity || 5
|
||||||
|
const o = fm.default_occurrence || 5
|
||||||
|
const d = fm.default_detection || 5
|
||||||
|
fmeaRows.push({
|
||||||
|
component: comp,
|
||||||
|
failureMode: fm,
|
||||||
|
severity: s,
|
||||||
|
occurrence: o,
|
||||||
|
detection: d,
|
||||||
|
rpz: s * o * d,
|
||||||
|
ap: calculateAP(s, o, d),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by RPZ descending (highest risk first)
|
||||||
|
fmeaRows.sort((a, b) => b.rpz - a.rpz)
|
||||||
|
setRows(fmeaRows)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load FMEA data:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: rows.length,
|
||||||
|
critical: rows.filter((r) => r.rpz > 200).length,
|
||||||
|
actionRequired: rows.filter((r) => r.rpz > 100 && r.rpz <= 200).length,
|
||||||
|
acceptable: rows.filter((r) => r.rpz <= 100).length,
|
||||||
|
}
|
||||||
|
|
||||||
|
const [suggesting, setSuggesting] = useState(false)
|
||||||
|
const [suggestions, setSuggestions] = useState<FailureMode[]>([])
|
||||||
|
const [suggestSource, setSuggestSource] = useState<string>('')
|
||||||
|
|
||||||
|
async function suggestFMs(componentId: string) {
|
||||||
|
setSuggesting(true)
|
||||||
|
setSuggestions([])
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/components/${componentId}/suggest-fms`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json()
|
||||||
|
setSuggestions(json.suggestions || [])
|
||||||
|
setSuggestSource(json.source || 'unknown')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('FM suggest failed:', err)
|
||||||
|
} finally {
|
||||||
|
setSuggesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unique components for the suggest button
|
||||||
|
const components = [...new Map(rows.map((r) => [r.component.id, r.component])).values()]
|
||||||
|
|
||||||
|
return { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions }
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { useFMEA, type FMEARow } from './_hooks/useFMEA'
|
||||||
|
|
||||||
|
const COMP_TYPE_LABELS: Record<string, string> = {
|
||||||
|
mechanical: 'Mechanisch', electrical: 'Elektrisch', sensor: 'Sensor',
|
||||||
|
actuator: 'Aktor', software: 'Software', firmware: 'Firmware',
|
||||||
|
ai_model: 'KI-Modell', hmi: 'HMI', network: 'Netzwerk',
|
||||||
|
hydraulic: 'Hydraulik', pneumatic: 'Pneumatik', safety: 'Sicherheit',
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpzColor(rpz: number): string {
|
||||||
|
if (rpz > 200) return 'bg-red-100 text-red-800 border-red-200'
|
||||||
|
if (rpz > 100) return 'bg-orange-100 text-orange-800 border-orange-200'
|
||||||
|
if (rpz > 50) return 'bg-yellow-100 text-yellow-800 border-yellow-200'
|
||||||
|
return 'bg-green-100 text-green-800 border-green-200'
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpzLabel(rpz: number): string {
|
||||||
|
if (rpz > 200) return 'Kritisch'
|
||||||
|
if (rpz > 100) return 'Handlungsbedarf'
|
||||||
|
if (rpz > 50) return 'Beobachten'
|
||||||
|
return 'Akzeptabel'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FMEAPage() {
|
||||||
|
const { projectId } = useParams<{ projectId: string }>()
|
||||||
|
const { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions } = useFMEA(projectId)
|
||||||
|
const [suggestComp, setSuggestComp] = useState<string | null>(null)
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">FMEA-Worksheet</h1>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
Fehlermoeglich­keits- und Einflussanalyse — RPZ = Severity x Occurrence x Detection
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<FMEAInfoBox />
|
||||||
|
|
||||||
|
{/* KI-Vorschlag + Export */}
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={suggestComp || ''}
|
||||||
|
onChange={(e) => setSuggestComp(e.target.value || null)}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-lg text-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="">Komponente waehlen...</option>
|
||||||
|
{components.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => suggestComp && suggestFMs(suggestComp)}
|
||||||
|
disabled={!suggestComp || suggesting}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 text-sm font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{suggesting ? (
|
||||||
|
<span className="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
KI-Vorschlag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<a
|
||||||
|
href={`/api/sdk/v1/iace/projects/${projectId}/fmea/export`}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm font-medium transition-colors"
|
||||||
|
download
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
VDA Excel exportieren
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suggest Results */}
|
||||||
|
{suggestions.length > 0 && (
|
||||||
|
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-xl p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-purple-800 dark:text-purple-300">
|
||||||
|
KI-Vorschlaege ({suggestions.length}) — {suggestSource === 'llm' ? 'LLM-generiert' : 'Bibliothek'}
|
||||||
|
</h3>
|
||||||
|
<button onClick={() => setSuggestions([])} className="text-xs text-purple-600 hover:text-purple-800">Schliessen</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{suggestions.map((fm, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg p-3 border border-purple-100 dark:border-purple-800">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">{fm.name_de}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">{fm.effect}</div>
|
||||||
|
<div className="flex gap-3 mt-1 text-xs text-gray-400">
|
||||||
|
<span>S={fm.default_severity}</span>
|
||||||
|
<span>O={fm.default_occurrence}</span>
|
||||||
|
<span>D={fm.default_detection}</span>
|
||||||
|
<span className="font-bold">RPZ={fm.default_severity * fm.default_occurrence * fm.default_detection}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
<StatCard label="Gesamt" value={stats.total} color="gray" />
|
||||||
|
<StatCard label="Kritisch (RPZ > 200)" value={stats.critical} color="red" />
|
||||||
|
<StatCard label="Handlungsbedarf (RPZ > 100)" value={stats.actionRequired} color="orange" />
|
||||||
|
<StatCard label="Akzeptabel (RPZ ≤ 100)" value={stats.acceptable} color="green" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RPZ Threshold Info */}
|
||||||
|
<div className="p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-xs text-amber-800 dark:text-amber-300">
|
||||||
|
<strong>RPZ-Schwellen:</strong> Kritisch > 200 | Handlungsbedarf > 100 | Beobachten > 50 | Akzeptabel ≤ 50.
|
||||||
|
Massnahmen sind erforderlich ab RPZ > 100.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FMEA Table */}
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
Keine Failure Modes gefunden. Bitte zuerst Komponenten erfassen.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Komponente</th>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Typ</th>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Fehlerart</th>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Auswirkung</th>
|
||||||
|
<th className="px-3 py-2.5 text-center text-xs font-medium text-gray-500 uppercase w-12">S</th>
|
||||||
|
<th className="px-3 py-2.5 text-center text-xs font-medium text-gray-500 uppercase w-12">O</th>
|
||||||
|
<th className="px-3 py-2.5 text-center text-xs font-medium text-gray-500 uppercase w-12">D</th>
|
||||||
|
<th className="px-3 py-2.5 text-center text-xs font-medium text-gray-500 uppercase w-16">RPZ</th>
|
||||||
|
<th className="px-3 py-2.5 text-center text-xs font-medium text-gray-500 uppercase w-12">AP</th>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Bewertung</th>
|
||||||
|
<th className="px-3 py-2.5 text-left text-xs font-medium text-gray-500 uppercase">Erkennung</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{rows.map((row, idx) => (
|
||||||
|
<FMEATableRow key={`${row.component.id}-${row.failureMode.id}-${idx}`} row={row} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FMEATableRow({ row }: { row: FMEARow }) {
|
||||||
|
const color = rpzColor(row.rpz)
|
||||||
|
return (
|
||||||
|
<tr className={`hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors ${row.rpz > 100 ? 'bg-red-50/30 dark:bg-red-900/10' : ''}`}>
|
||||||
|
<td className="px-3 py-2.5 text-sm font-medium text-gray-900 dark:text-white">{row.component.name}</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||||
|
{COMP_TYPE_LABELS[row.component.component_type] || row.component.component_type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<div className="text-sm text-gray-900 dark:text-white">{row.failureMode.name_de}</div>
|
||||||
|
<div className="text-[10px] text-gray-400">{row.failureMode.id}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-xs text-gray-600 dark:text-gray-400 max-w-[200px] truncate" title={row.failureMode.effect}>
|
||||||
|
{row.failureMode.effect}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-sm text-center font-medium text-gray-900 dark:text-white">{row.severity}</td>
|
||||||
|
<td className="px-3 py-2.5 text-sm text-center font-medium text-gray-900 dark:text-white">{row.occurrence}</td>
|
||||||
|
<td className="px-3 py-2.5 text-sm text-center font-medium text-gray-900 dark:text-white">{row.detection}</td>
|
||||||
|
<td className="px-3 py-2.5 text-center">
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-sm font-bold border ${color}`}>
|
||||||
|
{row.rpz}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-center">
|
||||||
|
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-bold ${
|
||||||
|
row.ap === 'H' ? 'bg-red-600 text-white' :
|
||||||
|
row.ap === 'M' ? 'bg-yellow-500 text-white' :
|
||||||
|
'bg-green-500 text-white'
|
||||||
|
}`}>
|
||||||
|
{row.ap}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${color}`}>{rpzLabel(row.rpz)}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-xs text-gray-500 dark:text-gray-400 max-w-[150px] truncate" title={row.failureMode.detection_hint}>
|
||||||
|
{row.failureMode.detection_hint || '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FMEAInfoBox() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
return (
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl overflow-hidden">
|
||||||
|
<button onClick={() => setOpen(!open)} className="w-full flex items-center justify-between px-4 py-3 text-left">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="w-4 h-4 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm font-medium text-blue-800 dark:text-blue-300">Was ist FMEA? — Anleitung & Beispiel</span>
|
||||||
|
</div>
|
||||||
|
<svg className={`w-4 h-4 text-blue-600 transition-transform ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="px-4 pb-4 text-xs text-blue-800 dark:text-blue-300 space-y-3">
|
||||||
|
<p><strong>FMEA</strong> (Fehlermoeglich- und Einflussanalyse) ist eine systematische Methode zur vorbeugenden Qualitaetssicherung nach AIAG-VDA (2019).</p>
|
||||||
|
<div>
|
||||||
|
<strong>Bewertungsskalen (je 1-10):</strong>
|
||||||
|
<ul className="mt-1 ml-4 space-y-0.5 list-disc">
|
||||||
|
<li><strong>S (Severity)</strong> — Schwere der Auswirkung: 1 = kaum merkbar, 10 = katastrophal (Lebensgefahr)</li>
|
||||||
|
<li><strong>O (Occurrence)</strong> — Auftretenswahrscheinlichkeit: 1 = praktisch ausgeschlossen, 10 = sehr haeufig</li>
|
||||||
|
<li><strong>D (Detection)</strong> — Entdeckbarkeit: 1 = sofort erkennbar, 10 = nicht erkennbar</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Kennzahlen:</strong>
|
||||||
|
<ul className="mt-1 ml-4 space-y-0.5 list-disc">
|
||||||
|
<li><strong>RPZ</strong> = S x O x D (1-1000). Ab RPZ > 100: Massnahme erforderlich.</li>
|
||||||
|
<li><strong>AP (Action Priority)</strong> — AIAG-VDA Standard: <span className="inline-block px-1.5 py-0.5 bg-red-600 text-white rounded text-[10px] font-bold">H</span> = sofort handeln, <span className="inline-block px-1.5 py-0.5 bg-yellow-500 text-white rounded text-[10px] font-bold">M</span> = planen, <span className="inline-block px-1.5 py-0.5 bg-green-500 text-white rounded text-[10px] font-bold">L</span> = beobachten</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Beispiel:</strong> SPS-Steuerung → Kommunikationsausfall (S=8, O=3, D=5) → RPZ=120, AP=M → Massnahme: Redundante Kommunikation implementieren.
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Workflow:</strong> 1. Komponente waehlen → 2. Fehlerart identifizieren → 3. S/O/D bewerten → 4. AP pruefen → 5. Bei H/M: Massnahme definieren → 6. Nach Massnahme: neu bewerten
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
gray: 'bg-gray-50 text-gray-700 border-gray-200',
|
||||||
|
red: 'bg-red-50 text-red-700 border-red-200',
|
||||||
|
orange: 'bg-orange-50 text-orange-700 border-orange-200',
|
||||||
|
green: 'bg-green-50 text-green-700 border-green-200',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border p-4 ${colors[color] || colors.gray}`}>
|
||||||
|
<div className="text-2xl font-bold">{value}</div>
|
||||||
|
<div className="text-xs mt-1">{label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+286
@@ -0,0 +1,286 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react'
|
||||||
|
import { Hazard } from './types'
|
||||||
|
import { RiskAssessmentTable } from './RiskAssessmentTable'
|
||||||
|
|
||||||
|
interface BlockData {
|
||||||
|
parent_hazard: { hazard: { id: string } }
|
||||||
|
children: { hazard: { id: string } }[]
|
||||||
|
children_covered_by_parent: boolean
|
||||||
|
block_key: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BlockInfo {
|
||||||
|
isParent: boolean
|
||||||
|
isChild: boolean
|
||||||
|
isCovered: boolean
|
||||||
|
blockKey: string
|
||||||
|
parentId: string
|
||||||
|
childCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string
|
||||||
|
hazards: Hazard[]
|
||||||
|
onReassess?: () => void
|
||||||
|
decisions?: Record<string, boolean | null>
|
||||||
|
onDecision?: (hazardId: string, acceptable: boolean | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps RiskAssessmentTable with block-awareness:
|
||||||
|
* - Injects block metadata into hazards so the table can show grouping
|
||||||
|
* - Provides controls to ungroup/promote children
|
||||||
|
*/
|
||||||
|
export function BlockAwareRiskTable({ projectId, hazards, onReassess, decisions, onDecision }: Props) {
|
||||||
|
const [blocks, setBlocks] = useState<BlockData[]>([])
|
||||||
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||||
|
const [ungrouped, setUngrouped] = useState<Record<string, boolean>>({})
|
||||||
|
const [pendingAction, setPendingAction] = useState<{ childId: string; childName: string } | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazard-blocks`)
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(d => { if (d?.blocks) setBlocks(d.blocks) })
|
||||||
|
.catch(() => {})
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
// Build lookup: hazardId → block info
|
||||||
|
const blockMap = useMemo(() => {
|
||||||
|
const map: Record<string, BlockInfo> = {}
|
||||||
|
for (const b of blocks) {
|
||||||
|
if (b.children.length === 0) continue
|
||||||
|
const pid = b.parent_hazard.hazard.id
|
||||||
|
map[pid] = {
|
||||||
|
isParent: true, isChild: false, isCovered: false,
|
||||||
|
blockKey: b.block_key, parentId: pid, childCount: b.children.length,
|
||||||
|
}
|
||||||
|
for (const c of b.children) {
|
||||||
|
if (ungrouped[c.hazard.id]) continue
|
||||||
|
map[c.hazard.id] = {
|
||||||
|
isParent: false, isChild: true,
|
||||||
|
isCovered: b.children_covered_by_parent,
|
||||||
|
blockKey: b.block_key, parentId: pid, childCount: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [blocks, ungrouped])
|
||||||
|
|
||||||
|
// Sort hazards: parents first, then their children, then standalone
|
||||||
|
const sortedHazards = useMemo(() => {
|
||||||
|
const parents: Hazard[] = []
|
||||||
|
const childrenByParent: Record<string, Hazard[]> = {}
|
||||||
|
const standalone: Hazard[] = []
|
||||||
|
|
||||||
|
for (const h of hazards) {
|
||||||
|
const info = blockMap[h.id]
|
||||||
|
if (!info) {
|
||||||
|
standalone.push(h)
|
||||||
|
} else if (info.isParent) {
|
||||||
|
parents.push(h)
|
||||||
|
childrenByParent[h.id] = []
|
||||||
|
} else if (info.isChild) {
|
||||||
|
const arr = childrenByParent[info.parentId]
|
||||||
|
if (arr) arr.push(h)
|
||||||
|
else standalone.push(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort parents by risk desc
|
||||||
|
parents.sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0))
|
||||||
|
standalone.sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0))
|
||||||
|
|
||||||
|
// Interleave: parent → children → parent → children → ... → standalone
|
||||||
|
const result: Hazard[] = []
|
||||||
|
for (const p of parents) {
|
||||||
|
result.push(p)
|
||||||
|
const isCollapsed = collapsed[p.id]
|
||||||
|
if (!isCollapsed && childrenByParent[p.id]) {
|
||||||
|
result.push(...childrenByParent[p.id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(...standalone)
|
||||||
|
return result
|
||||||
|
}, [hazards, blockMap, collapsed])
|
||||||
|
|
||||||
|
const toggleCollapse = (parentId: string) => {
|
||||||
|
setCollapsed(prev => ({ ...prev, [parentId]: !prev[parentId] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUngroup = (childId: string) => {
|
||||||
|
setUngrouped(prev => ({ ...prev, [childId]: true }))
|
||||||
|
setPendingAction(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRegroup = (childId: string) => {
|
||||||
|
setUngrouped(prev => {
|
||||||
|
const next = { ...prev }
|
||||||
|
delete next[childId]
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count blocks with children
|
||||||
|
const blockCount = blocks.filter(b => b.children.length > 0).length
|
||||||
|
const coveredCount = Object.values(blockMap).filter(b => b.isChild && b.isCovered).length
|
||||||
|
const ungroupedCount = Object.keys(ungrouped).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Confirmation dialog */}
|
||||||
|
{pendingAction && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 p-5 max-w-md w-full mx-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">Gefaehrdung aus Block entfernen?</h3>
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
<strong>{pendingAction.childName}</strong>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
|
Der Punkt wird als eigenstaendige Gefaehrdung gefuehrt und muss separat bewertet werden.
|
||||||
|
Sie koennen ihn jederzeit ueber "Zurueck in Block" wieder zuordnen.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => handleUngroup(pendingAction.childId)}
|
||||||
|
className="flex-1 px-3 py-2 text-xs font-medium bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||||
|
Als eigenen Punkt fuehren
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setPendingAction(null)}
|
||||||
|
className="flex-1 px-3 py-2 text-xs font-medium bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 transition-colors">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Block info bar */}
|
||||||
|
{blockCount > 0 && (
|
||||||
|
<div className="flex items-center gap-4 px-4 py-2 bg-purple-50 dark:bg-purple-900/20 rounded-lg text-xs">
|
||||||
|
<span className="font-medium text-purple-700 dark:text-purple-300">
|
||||||
|
{blockCount} Bloecke erkannt
|
||||||
|
</span>
|
||||||
|
{coveredCount > 0 && (
|
||||||
|
<span className="text-green-600">
|
||||||
|
{coveredCount} Kinder durch Mutter abgedeckt
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{ungroupedCount > 0 && (
|
||||||
|
<button onClick={() => setUngrouped({})}
|
||||||
|
className="text-orange-600 hover:text-orange-700 underline">
|
||||||
|
{ungroupedCount} entgruppiert — alle zuruecksetzen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Enhanced table with block decorations */}
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs whitespace-nowrap">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-100 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th className="w-8 px-1 py-1.5"></th>
|
||||||
|
<th colSpan={2} className="px-3 py-1.5 text-left font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600">Gefaehrdung</th>
|
||||||
|
<th colSpan={4} className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600">Risiko (S x F x P)</th>
|
||||||
|
<th className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{sortedHazards.map(h => {
|
||||||
|
const info = blockMap[h.id]
|
||||||
|
const isParent = info?.isParent
|
||||||
|
const isChild = info?.isChild
|
||||||
|
const isCovered = info?.isCovered
|
||||||
|
const childCount = info?.childCount || 0
|
||||||
|
const isCollapsedParent = isParent && collapsed[h.id]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={h.id} className={`transition-colors ${
|
||||||
|
isChild ? 'bg-gray-50/50 dark:bg-gray-850' :
|
||||||
|
isParent ? 'bg-white dark:bg-gray-800' : ''
|
||||||
|
} ${isCovered ? 'opacity-60' : ''} hover:bg-gray-50 dark:hover:bg-gray-750`}>
|
||||||
|
{/* Block indicator */}
|
||||||
|
<td className="px-1 py-2 text-center">
|
||||||
|
{isParent && (
|
||||||
|
<button onClick={() => toggleCollapse(h.id)}
|
||||||
|
className="w-5 h-5 flex items-center justify-center rounded hover:bg-purple-100 text-purple-600 transition-colors"
|
||||||
|
title={`${childCount} Kinder ${isCollapsedParent ? 'anzeigen' : 'verbergen'}`}>
|
||||||
|
<svg className={`w-3 h-3 transition-transform ${isCollapsedParent ? '' : 'rotate-90'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isChild && (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<button onClick={() => setPendingAction({ childId: h.id, childName: h.name })}
|
||||||
|
className="w-5 h-5 flex items-center justify-center rounded hover:bg-orange-100 text-gray-300 hover:text-orange-500 transition-colors"
|
||||||
|
title="Aus Block entfernen (mit Bestaetigung)">
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Show regroup button for ungrouped items */}
|
||||||
|
{!isParent && !isChild && ungrouped[h.id] && (
|
||||||
|
<button onClick={() => handleRegroup(h.id)}
|
||||||
|
className="w-5 h-5 flex items-center justify-center rounded hover:bg-green-100 text-orange-400 hover:text-green-600 transition-colors"
|
||||||
|
title="Zurueck in Block">
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{/* Name */}
|
||||||
|
<td className={`px-3 py-2 ${isChild ? 'pl-8' : ''}`}>
|
||||||
|
<div className={`font-medium ${isParent ? 'text-purple-800 dark:text-purple-300' : 'text-gray-900 dark:text-white'}`}>
|
||||||
|
{h.name}
|
||||||
|
{isParent && <span className="ml-1 text-[10px] text-purple-500">({childCount})</span>}
|
||||||
|
</div>
|
||||||
|
{h.hazardous_zone && <div className="text-[10px] text-gray-400 truncate max-w-[200px]">{h.hazardous_zone}</div>}
|
||||||
|
</td>
|
||||||
|
{/* Category */}
|
||||||
|
<td className="px-3 py-2 border-r border-gray-200 dark:border-gray-600 text-gray-500">
|
||||||
|
{h.category?.replace(/_/g, ' ')}
|
||||||
|
</td>
|
||||||
|
{/* Risk */}
|
||||||
|
<td className="px-2 py-2 text-center">{h.severity || '-'}</td>
|
||||||
|
<td className="px-2 py-2 text-center">{h.exposure || '-'}</td>
|
||||||
|
<td className="px-2 py-2 text-center">{h.probability || '-'}</td>
|
||||||
|
<td className="px-2 py-2 text-center font-bold border-r border-gray-200 dark:border-gray-600">
|
||||||
|
{h.r_inherent || '-'}
|
||||||
|
</td>
|
||||||
|
{/* Status */}
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
{isCovered ? (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-green-100 text-green-700 text-[10px] font-medium">
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
Abgedeckt
|
||||||
|
</span>
|
||||||
|
) : h.r_inherent ? (
|
||||||
|
<span className={`inline-block px-1.5 py-0.5 rounded-full text-[10px] font-medium ${
|
||||||
|
(h.r_inherent || 0) <= 20 ? 'bg-green-100 text-green-700' :
|
||||||
|
(h.r_inherent || 0) <= 60 ? 'bg-yellow-100 text-yellow-700' :
|
||||||
|
'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{(h.r_inherent || 0) <= 20 ? 'Niedrig' : (h.r_inherent || 0) <= 60 ? 'Mittel' : 'Hoch'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">Offen</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { CATEGORY_LABELS } from './types'
|
||||||
|
import { RiskBadge } from './RiskBadge'
|
||||||
|
|
||||||
|
interface BlockHazard {
|
||||||
|
hazard: {
|
||||||
|
id: string; name: string; description: string; category: string
|
||||||
|
hazardous_zone: string; scenario?: string; possible_harm?: string
|
||||||
|
}
|
||||||
|
assessment?: { severity: number; exposure: number; probability: number; inherent_risk: number; risk_level: string } | null
|
||||||
|
mitigation_ids: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HazardBlock {
|
||||||
|
parent_hazard: BlockHazard
|
||||||
|
children: BlockHazard[]
|
||||||
|
block_key: string
|
||||||
|
shared_measure_count: number
|
||||||
|
children_covered_by_parent: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BlockSummary {
|
||||||
|
total_blocks: number
|
||||||
|
parent_only_blocks: number
|
||||||
|
blocks_with_children: number
|
||||||
|
total_hazards: number
|
||||||
|
covered_children: number
|
||||||
|
uncovered_children: number
|
||||||
|
assessments_needed: number
|
||||||
|
assessments_saved: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HazardBlockView() {
|
||||||
|
const { projectId } = useParams<{ projectId: string }>()
|
||||||
|
const [blocks, setBlocks] = useState<HazardBlock[]>([])
|
||||||
|
const [summary, setSummary] = useState<BlockSummary | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId) return
|
||||||
|
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazard-blocks`)
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(data => {
|
||||||
|
if (data) {
|
||||||
|
setBlocks(data.blocks || [])
|
||||||
|
setSummary(data.summary || null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [projectId])
|
||||||
|
|
||||||
|
const toggle = (key: string) => setExpanded(prev => ({ ...prev, [key]: !prev[key] }))
|
||||||
|
|
||||||
|
if (loading) return <div className="text-sm text-gray-400 py-8 text-center">Lade Bloecke...</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Summary Cards */}
|
||||||
|
{summary && (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<SummaryCard label="Bloecke" value={summary.total_blocks} sub={`${summary.total_hazards} Gefaehrdungen`} />
|
||||||
|
<SummaryCard label="Mit Kindern" value={summary.blocks_with_children} sub={`${summary.covered_children} abgedeckt`} color="green" />
|
||||||
|
<SummaryCard label="Bewertungen noetig" value={summary.assessments_needed} sub={`von ${summary.total_hazards}`} color="purple" />
|
||||||
|
<SummaryCard label="Eingespart" value={summary.assessments_saved} sub="durch Gruppierung" color="green" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Block List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{blocks.map((block) => {
|
||||||
|
const isOpen = expanded[block.block_key]
|
||||||
|
const parent = block.parent_hazard
|
||||||
|
const childCount = block.children.length
|
||||||
|
const covered = block.children_covered_by_parent
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={block.block_key} className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
{/* Parent Row */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors ${childCount > 0 ? '' : 'opacity-90'}`}
|
||||||
|
onClick={() => childCount > 0 && toggle(block.block_key)}
|
||||||
|
>
|
||||||
|
{/* Expand Arrow */}
|
||||||
|
{childCount > 0 ? (
|
||||||
|
<svg className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<div className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Name + Category */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">{parent.hazard.name}</span>
|
||||||
|
<span className="text-xs text-gray-400">{CATEGORY_LABELS[parent.hazard.category] || parent.hazard.category}</span>
|
||||||
|
</div>
|
||||||
|
{parent.hazard.hazardous_zone && (
|
||||||
|
<div className="text-xs text-gray-500 truncate">{parent.hazard.hazardous_zone}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Risk */}
|
||||||
|
{parent.assessment ? (
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="text-gray-500">R={parent.assessment.inherent_risk}</span>
|
||||||
|
<RiskBadge level={parent.assessment.risk_level} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">Nicht bewertet</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Child count badge */}
|
||||||
|
{childCount > 0 && (
|
||||||
|
<div className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
covered
|
||||||
|
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||||
|
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'
|
||||||
|
}`}>
|
||||||
|
+{childCount}
|
||||||
|
{covered && (
|
||||||
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Measures count */}
|
||||||
|
<span className="text-xs text-gray-400">{block.shared_measure_count} M.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Children (expanded) */}
|
||||||
|
{isOpen && childCount > 0 && (
|
||||||
|
<div className="border-t border-gray-100 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-850">
|
||||||
|
{covered && (
|
||||||
|
<div className="px-4 py-2 text-xs text-green-600 dark:text-green-400 bg-green-50/50 dark:bg-green-900/10 border-b border-green-100 dark:border-green-900/30">
|
||||||
|
Alle Untergefaehrdungen durch Massnahmen der Muttergefaehrdung abgedeckt — keine separate Bewertung noetig.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{block.children.map((child) => (
|
||||||
|
<div key={child.hazard.id} className="flex items-center gap-3 px-4 py-2 pl-12 border-b border-gray-100 dark:border-gray-700 last:border-b-0">
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-gray-300 dark:bg-gray-600 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-xs text-gray-700 dark:text-gray-300">{child.hazard.name}</span>
|
||||||
|
{child.hazard.hazardous_zone && (
|
||||||
|
<span className="text-xs text-gray-400 ml-2">[{child.hazard.hazardous_zone}]</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{child.assessment ? (
|
||||||
|
<span className="text-xs text-gray-500">R={child.assessment.inherent_risk}</span>
|
||||||
|
) : covered ? (
|
||||||
|
<span className="text-xs text-green-500">Abgedeckt</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-yellow-500">Offen</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryCard({ label, value, sub, color }: { label: string; value: number; sub: string; color?: string }) {
|
||||||
|
const textColor = color === 'green' ? 'text-green-600' : color === 'purple' ? 'text-purple-600' : 'text-gray-900 dark:text-white'
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3 text-center">
|
||||||
|
<div className={`text-xl font-bold ${textColor}`}>{value}</div>
|
||||||
|
<div className="text-xs font-medium text-gray-600 dark:text-gray-400">{label}</div>
|
||||||
|
<div className="text-[10px] text-gray-400">{sub}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,12 @@
|
|||||||
import { Hazard, LifecyclePhase, CATEGORY_LABELS, STATUS_LABELS } from './types'
|
import { Hazard, LifecyclePhase, CATEGORY_LABELS, STATUS_LABELS } from './types'
|
||||||
import { RiskBadge, ReviewStatusBadge } from './RiskBadge'
|
import { RiskBadge, ReviewStatusBadge } from './RiskBadge'
|
||||||
|
|
||||||
|
const OP_STATE_LABELS: Record<string, string> = {
|
||||||
|
startup: 'Hochfahren', homing: 'Referenzfahrt', automatic_operation: 'Automatik',
|
||||||
|
manual_operation: 'Handbetrieb', teach_mode: 'Einrichten', maintenance: 'Wartung',
|
||||||
|
cleaning: 'Reinigung', emergency_stop: 'Not-Halt', recovery_mode: 'Wiederanlauf',
|
||||||
|
}
|
||||||
|
|
||||||
export function HazardTable({ hazards, lifecyclePhases, onDelete }: {
|
export function HazardTable({ hazards, lifecyclePhases, onDelete }: {
|
||||||
hazards: Hazard[]
|
hazards: Hazard[]
|
||||||
lifecyclePhases: LifecyclePhase[]
|
lifecyclePhases: LifecyclePhase[]
|
||||||
@@ -47,6 +53,15 @@ export function HazardTable({ hazards, lifecyclePhases, onDelete }: {
|
|||||||
{lifecyclePhases.find(p => p.id === hazard.lifecycle_phase)?.label_de || hazard.lifecycle_phase}
|
{lifecyclePhases.find(p => p.id === hazard.lifecycle_phase)?.label_de || hazard.lifecycle_phase}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{hazard.operational_states && hazard.operational_states.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{hazard.operational_states.map((s) => (
|
||||||
|
<span key={s} className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800">
|
||||||
|
{OP_STATE_LABELS[s] || s}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{CATEGORY_LABELS[hazard.category] || hazard.category}</td>
|
<td className="px-4 py-3 text-sm text-gray-600">{CATEGORY_LABELS[hazard.category] || hazard.category}</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.severity}</td>
|
<td className="px-4 py-3 text-sm text-gray-900 dark:text-white text-center font-medium">{hazard.severity}</td>
|
||||||
|
|||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
interface RegulatoryHint {
|
||||||
|
regulation_id: string
|
||||||
|
regulation_short: string
|
||||||
|
category: string
|
||||||
|
text: string
|
||||||
|
pages?: number[]
|
||||||
|
source_url?: string
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string
|
||||||
|
hazardId: string
|
||||||
|
hazardName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryBadge(cat: string): string {
|
||||||
|
if (cat === 'trbs') return 'bg-orange-100 text-orange-800'
|
||||||
|
if (cat === 'trgs') return 'bg-red-100 text-red-800'
|
||||||
|
if (cat === 'asr') return 'bg-teal-100 text-teal-800'
|
||||||
|
if (cat === 'osha' || cat.startsWith('ce_')) return 'bg-blue-100 text-blue-800'
|
||||||
|
return 'bg-gray-100 text-gray-700'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegulatoryHintsPanel({ projectId, hazardId, hazardName }: Props) {
|
||||||
|
const [hints, setHints] = useState<RegulatoryHint[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const loadHints = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/${hazardId}/regulatory-hints`)
|
||||||
|
if (!res.ok) {
|
||||||
|
setError('Hinweise konnten nicht geladen werden')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
setHints(data.hints || [])
|
||||||
|
} catch {
|
||||||
|
setError('Verbindung zum RAG-Service fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
setLoaded(true)
|
||||||
|
}
|
||||||
|
}, [projectId, hazardId])
|
||||||
|
|
||||||
|
if (!loaded) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={loadHints}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-purple-700 bg-purple-50 rounded-lg hover:bg-purple-100 dark:text-purple-300 dark:bg-purple-900/30 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<span className="animate-spin inline-block w-3 h-3 border border-purple-400 border-t-transparent rounded-full" />
|
||||||
|
Lade Hinweise...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||||
|
</svg>
|
||||||
|
TRBS/OSHA Hinweise laden
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <p className="text-xs text-red-500">{error}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hints.length === 0) {
|
||||||
|
return <p className="text-xs text-gray-400">Keine regulatorischen Hinweise gefunden</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
Regulatorische Hinweise ({hints.length})
|
||||||
|
</p>
|
||||||
|
{hints.map((hint, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="p-2.5 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/50 text-xs space-y-1"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className={`inline-flex px-1.5 py-0.5 rounded font-medium ${categoryBadge(hint.category)}`}>
|
||||||
|
{hint.regulation_short || hint.regulation_id}
|
||||||
|
</span>
|
||||||
|
{hint.pages && hint.pages.length > 0 && (
|
||||||
|
<span className="text-gray-400">S. {hint.pages.join(', ')}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-400 ml-auto">{(hint.score * 100).toFixed(0)}% Relevanz</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-700 dark:text-gray-300 leading-relaxed">{hint.text}</p>
|
||||||
|
{hint.source_url && (
|
||||||
|
<a
|
||||||
|
href={hint.source_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-purple-600 hover:text-purple-800 dark:text-purple-400"
|
||||||
|
>
|
||||||
|
Quelle
|
||||||
|
<svg className="w-3 h-3 inline-block ml-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</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