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.
|
||||
consent-sdk/src/mobile/flutter/consent_sdk.dart
|
||||
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
|
||||
# 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:
|
||||
# REGISTRY_USERNAME / REGISTRY_PASSWORD — registry.meghsakha.com credentials
|
||||
@@ -8,24 +14,68 @@
|
||||
name: Build + Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'admin-compliance/**'
|
||||
- 'backend-compliance/**'
|
||||
- 'ai-compliance-sdk/**'
|
||||
- 'developer-portal/**'
|
||||
- 'compliance-tts-service/**'
|
||||
- 'document-crawler/**'
|
||||
- 'dsms-gateway/**'
|
||||
- 'dsms-node/**'
|
||||
|
||||
jobs:
|
||||
# ── per-service builds run in parallel ────────────────────────────────────
|
||||
# ── 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:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.admin == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -49,6 +99,8 @@ jobs:
|
||||
build-backend-compliance:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.backend == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -72,6 +124,8 @@ jobs:
|
||||
build-ai-sdk:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.sdk == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -95,6 +149,8 @@ jobs:
|
||||
build-developer-portal:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.portal == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -118,6 +174,8 @@ jobs:
|
||||
build-tts:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.tts == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -141,6 +199,8 @@ jobs:
|
||||
build-document-crawler:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.crawler == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -164,6 +224,8 @@ jobs:
|
||||
build-dsms-gateway:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.dsms_gateway == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -187,6 +249,8 @@ jobs:
|
||||
build-dsms-node:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.dsms_node == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
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:${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:
|
||||
runs-on: docker
|
||||
@@ -221,6 +330,11 @@ jobs:
|
||||
- build-document-crawler
|
||||
- build-dsms-gateway
|
||||
- build-dsms-node
|
||||
if: |
|
||||
always() &&
|
||||
contains(needs.*.result, 'success') &&
|
||||
!contains(needs.*.result, 'failure') &&
|
||||
!contains(needs.*.result, 'cancelled')
|
||||
steps:
|
||||
- name: Checkout (for SHA)
|
||||
run: |
|
||||
|
||||
@@ -19,6 +19,49 @@ on:
|
||||
|
||||
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-name:
|
||||
runs-on: docker
|
||||
@@ -55,10 +98,12 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── LOC budget (always) ──────────────────────────────────────────────────
|
||||
# ── LOC budget (only if files changed) ───────────────────────────────────
|
||||
loc-budget:
|
||||
runs-on: docker
|
||||
container: alpine:3.20
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.any == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -86,10 +131,11 @@ jobs:
|
||||
--redact \
|
||||
|| { 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:
|
||||
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
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -107,10 +153,11 @@ jobs:
|
||||
cd ai-compliance-sdk
|
||||
go build ./...
|
||||
|
||||
# ── Python lint + import check (PR only) ────────────────────────────────
|
||||
# ── Python lint + import check (PR only, gated on python service changes) ─
|
||||
python-lint:
|
||||
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
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -137,10 +184,11 @@ jobs:
|
||||
python -c "import compliance; print('Import OK')" \
|
||||
|| { echo "::error::compliance package fails to import — missing import or syntax error."; exit 1; }
|
||||
|
||||
# ── Node.js lint + type-check (PR only) ─────────────────────────────────
|
||||
# ── Node.js lint + type-check (PR only, gated on Next.js service changes) ─
|
||||
nodejs-lint:
|
||||
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
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -158,10 +206,12 @@ jobs:
|
||||
done
|
||||
exit $fail
|
||||
|
||||
# ── Node.js build — next build (PR + push to main) ───────────────────────
|
||||
# ── Node.js build — next build (gated on Next.js service changes) ───────
|
||||
nodejs-build:
|
||||
runs-on: docker
|
||||
container: node:20-alpine
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.any_node == 'true'
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
@@ -244,10 +294,12 @@ jobs:
|
||||
- name: Vulnerability scan (fail on high+)
|
||||
run: grype sbom:sbom-out/sbom.cdx.json --fail-on high -q
|
||||
|
||||
# ── Tests (PR + push to main) ─────────────────────────────────────────────
|
||||
# ── Tests (gated per service) ────────────────────────────────────────────
|
||||
test-go:
|
||||
runs-on: docker
|
||||
container: golang:1.24-alpine
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.sdk == 'true'
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
steps:
|
||||
@@ -265,6 +317,8 @@ jobs:
|
||||
test-python-backend:
|
||||
runs-on: docker
|
||||
container: python:3.12-slim
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.backend == 'true'
|
||||
env:
|
||||
CI: "true"
|
||||
steps:
|
||||
@@ -284,6 +338,8 @@ jobs:
|
||||
test-python-document-crawler:
|
||||
runs-on: docker
|
||||
container: python:3.12-slim
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.crawler == 'true'
|
||||
env:
|
||||
CI: "true"
|
||||
steps:
|
||||
@@ -303,6 +359,8 @@ jobs:
|
||||
test-python-dsms-gateway:
|
||||
runs-on: docker
|
||||
container: python:3.12-slim
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.dsms_gateway == 'true'
|
||||
env:
|
||||
CI: "true"
|
||||
steps:
|
||||
|
||||
@@ -40,6 +40,11 @@ offiziellen Quellen und gibst praxisnahe Hinweise.
|
||||
- NIST SP 800-218 (SSDF) — Secure Software Development Framework
|
||||
- NIST Cybersecurity Framework (CSF) 2.0 — Govern, Identify, Protect, Detect, Respond, Recover
|
||||
- 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
|
||||
- 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.
|
||||
|
||||
## 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 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 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 generatedBlocks: ProseBlockOutput[] = []
|
||||
|
||||
@@ -88,7 +88,7 @@ export async function handleV1Draft(body: Record<string, unknown>): Promise<Next
|
||||
}
|
||||
|
||||
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
|
||||
if (ragContext) {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
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 { ValidationContext, ValidationResult, ValidationFinding } from '@/lib/sdk/drafting-engine/types'
|
||||
import { buildCrossCheckPrompt } from '@/lib/sdk/drafting-engine/prompts/validate-cross-check'
|
||||
@@ -94,7 +94,7 @@ function deterministicCheck(
|
||||
const findings: ValidationFinding[] = []
|
||||
const level = validationContext.scopeLevel
|
||||
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?
|
||||
if (req && !req.required && levelNumeric < 3) {
|
||||
@@ -109,8 +109,8 @@ function deterministicCheck(
|
||||
}
|
||||
|
||||
// Check 2: VVT vorhanden wenn erforderlich?
|
||||
const vvtReq = DOCUMENT_SCOPE_MATRIX.vvt[level]
|
||||
if (vvtReq.required && validationContext.crossReferences.vvtCategories.length === 0) {
|
||||
const vvtReq = DOCUMENT_SCOPE_MATRIX_CORE.vvt?.[level]
|
||||
if (vvtReq?.required && validationContext.crossReferences.vvtCategories.length === 0) {
|
||||
findings.push({
|
||||
id: 'DET-VVT-MISSING',
|
||||
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) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { url } = body
|
||||
const { url, categories = [] } = body
|
||||
|
||||
if (!url) {
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url }),
|
||||
body: JSON.stringify({ url, categories }),
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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) {
|
||||
|
||||
@@ -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(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const tenantId = getTenantId(request)
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/einwilligungen/consents/${params.id}/history`,
|
||||
`${BACKEND_URL}/api/compliance/einwilligungen/consents/${id}/history`,
|
||||
{
|
||||
method: 'GET',
|
||||
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[] } }) {
|
||||
return proxy(request, params, 'GET')
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
|
||||
return proxy(request, await params, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: { path?: string[] } }) {
|
||||
return proxy(request, params, 'POST')
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
|
||||
return proxy(request, await params, 'POST')
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: { path?: string[] } }) {
|
||||
return proxy(request, params, 'DELETE')
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
|
||||
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/...
|
||||
*/
|
||||
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 subPath = path ? path.join('/') : ''
|
||||
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_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() {
|
||||
const [url, setUrl] = useState('')
|
||||
const [url, setUrl] = useState(() =>
|
||||
typeof window !== 'undefined' ? localStorage.getItem('banner-check-url') || '' : ''
|
||||
)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [progress, setProgress] = useState('')
|
||||
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) => {
|
||||
e.preventDefault()
|
||||
@@ -49,15 +86,64 @@ export function BannerCheckTab() {
|
||||
setResult(null)
|
||||
setProgress('Cookie-Banner wird analysiert...')
|
||||
|
||||
const selectedCategories = categories.includes('all') ? [] : categories
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/agent/banner-check', {
|
||||
method: 'POST',
|
||||
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}`)
|
||||
const data = await res.json()
|
||||
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) {
|
||||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||
} 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 hasStructured = structuredChecks.length > 0
|
||||
const compPct = result?.completeness_pct ?? 0
|
||||
const corrPct = result?.correctness_pct ?? 0
|
||||
|
||||
// Build ChecklistView-compatible result for structured checks
|
||||
const checklistResults = hasStructured ? [{
|
||||
label: `Cookie-Banner: ${result?.banner_provider || 'Unbekannt'}`,
|
||||
url: url,
|
||||
@@ -94,22 +194,59 @@ export function BannerCheckTab() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleScan} 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">
|
||||
{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'}
|
||||
<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 (381 Cookie-MCs)' : 'KI-Agent aus'}
|
||||
</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>
|
||||
|
||||
{progress && (
|
||||
@@ -128,68 +265,97 @@ export function BannerCheckTab() {
|
||||
|
||||
{result && (
|
||||
<div className="space-y-4">
|
||||
{/* 3-Phase Summary Card */}
|
||||
{result.phases && (
|
||||
<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="flex items-center gap-3">
|
||||
<span className="text-2xl">
|
||||
{result.banner_detected ? '\u{1F6E1}\u{FE0F}' : '\u26A0\u{FE0F}'}
|
||||
</span>
|
||||
<span className="text-2xl">{result.banner_detected ? '🛡️' : '⚠️'}</span>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
{result.banner_detected
|
||||
? `Banner erkannt: ${result.banner_provider || 'Unbekannter Anbieter'}`
|
||||
: 'Kein Cookie-Banner erkannt'}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
3-Phasen-Analyse: Cookies und Scripts vor/nach Interaktion
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5">3-Phasen-Analyse: Cookies und Scripts vor/nach Interaktion</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-3 grid grid-cols-3 gap-4">
|
||||
<PhaseBox
|
||||
label="Vor Consent"
|
||||
icon="\uD83D\uDD12"
|
||||
<PhaseBox label="Vor Consent" icon="🔒"
|
||||
cookies={result.phases.before_consent.cookies?.length ?? 0}
|
||||
scripts={result.phases.before_consent.scripts?.length ?? 0}
|
||||
violations={result.phases.before_consent.violations?.length ?? 0}
|
||||
/>
|
||||
<PhaseBox
|
||||
label="Nach Ablehnen"
|
||||
icon="\uD83D\uDEAB"
|
||||
violations={result.phases.before_consent.violations?.length ?? 0} />
|
||||
<PhaseBox label="Nach Ablehnen" icon="🚫"
|
||||
cookies={result.phases.after_reject.cookies?.length ?? 0}
|
||||
scripts={result.phases.after_reject.scripts?.length ?? 0}
|
||||
violations={result.phases.after_reject.violations?.length ?? 0}
|
||||
/>
|
||||
<PhaseBox
|
||||
label="Nach Akzeptieren"
|
||||
icon="\u2705"
|
||||
violations={result.phases.after_reject.violations?.length ?? 0} />
|
||||
<PhaseBox label="Nach Akzeptieren" icon="✅"
|
||||
cookies={result.phases.after_accept.cookies?.length ?? 0}
|
||||
scripts={result.phases.after_accept.scripts?.length ?? 0}
|
||||
violations={0}
|
||||
/>
|
||||
violations={0} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Structured L1/L2 Checklist */}
|
||||
{hasStructured && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||
<ChecklistView results={checklistResults} />
|
||||
</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 && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||
<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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -201,14 +367,8 @@ function PhaseBox({ label, icon, cookies, scripts, violations }: {
|
||||
<div className="text-center">
|
||||
<div className="text-lg">{icon}</div>
|
||||
<div className="text-xs font-medium text-gray-700">{label}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{cookies} Cookies, {scripts} Scripts
|
||||
</div>
|
||||
{violations > 0 && (
|
||||
<div className="text-xs text-red-600 font-medium">
|
||||
{violations} Verstoesse
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 mt-1">{cookies} Cookies, {scripts} Scripts</div>
|
||||
{violations > 0 && <div className="text-xs text-red-600 font-medium">{violations} Verstoesse</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,13 @@ interface DocResult {
|
||||
checks: CheckItem[]
|
||||
findings_count: number
|
||||
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> = {
|
||||
@@ -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) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
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 (
|
||||
<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" />
|
||||
@@ -84,14 +98,23 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
|
||||
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 (
|
||||
<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">
|
||||
Dokumenten-Pruefung ({results.length} Dokumente, {totalOk} vollstaendig)
|
||||
Dokumenten-Pruefung ({results.length} Dokumente)
|
||||
</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 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 grouped = groupChecks(r.checks)
|
||||
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 l1Passed = l1Checks.filter(c => c.passed).length
|
||||
const l1Passed = l1Scoreable.filter(c => c.passed).length
|
||||
const l2Passed = l2Active.filter(c => c.passed).length
|
||||
|
||||
return (
|
||||
@@ -123,10 +147,17 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
{typeLabel}
|
||||
</span>
|
||||
<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">
|
||||
{l1Checks.length > 0
|
||||
? `${l1Passed}/${l1Checks.length} Pflichtangaben`
|
||||
? `${l1Passed}/${l1Scoreable.length} Pflichtangaben`
|
||||
+ (l2Active.length > 0 ? `, ${l2Passed}/${l2Active.length} Detailpruefungen` : '')
|
||||
: r.url}
|
||||
</div>
|
||||
@@ -137,8 +168,9 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
<span className="text-xs text-red-600 font-medium">Fehler</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-16 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div className="flex items-center gap-2" title={`Pflichtangaben: ${l1Passed}/${l1Scoreable.length}`}>
|
||||
<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>
|
||||
<span className={`text-xs font-medium w-10 text-right ${
|
||||
@@ -146,8 +178,9 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
}`}>{pct}%</span>
|
||||
</div>
|
||||
{l2Active.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-16 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div className="flex items-center gap-2" title={`Detailpruefung: ${l2Passed}/${l2Active.length}`}>
|
||||
<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>
|
||||
<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>
|
||||
) : (
|
||||
<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}>
|
||||
{/* L1 check */}
|
||||
<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={`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.children.length > 0 && <L2Summary>{g.children}</L2Summary>}
|
||||
</div>
|
||||
@@ -180,7 +218,7 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
</div>
|
||||
)}
|
||||
{!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}
|
||||
</div>
|
||||
)}
|
||||
@@ -190,13 +228,16 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
{/* L2 children — always visible */}
|
||||
{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">
|
||||
{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">
|
||||
<CheckIcon passed={ch.passed} skipped={ch.skipped} />
|
||||
<CheckIcon passed={ch.passed} skipped={ch.skipped} isInfo={chInfo} />
|
||||
<div className="flex-1">
|
||||
<div className={`text-xs ${
|
||||
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.skipped && ' (uebersprungen)'}
|
||||
@@ -207,17 +248,19 @@ export function ChecklistView({ results }: { results: DocResult[] }) {
|
||||
</div>
|
||||
)}
|
||||
{!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}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{r.word_count > 0 && (
|
||||
<div className="text-xs text-gray-400 mt-2 pt-2 border-t border-gray-200">
|
||||
{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.`,
|
||||
},
|
||||
{
|
||||
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() {
|
||||
|
||||
@@ -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()] }
|
||||
})
|
||||
const [checkCookieBanner, setCheckCookieBanner] = useState(false)
|
||||
const [useAgent, setUseAgent] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [progress, setProgress] = useState('')
|
||||
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 }
|
||||
})
|
||||
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 []
|
||||
try { return JSON.parse(localStorage.getItem('doc-check-history') || '[]') } catch { return [] }
|
||||
})
|
||||
@@ -92,6 +93,7 @@ export function DocCheckTab() {
|
||||
url: e.url.trim(),
|
||||
})),
|
||||
check_cookie_banner: checkCookieBanner,
|
||||
use_agent: useAgent,
|
||||
}),
|
||||
})
|
||||
if (!startRes.ok) throw new Error(`Pruefung konnte nicht gestartet werden: ${startRes.status}`)
|
||||
@@ -110,7 +112,9 @@ export function DocCheckTab() {
|
||||
setResults(pollData.result)
|
||||
setProgress('')
|
||||
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)
|
||||
setHistory(updated)
|
||||
localStorage.setItem('doc-check-history', JSON.stringify(updated))
|
||||
@@ -190,6 +194,19 @@ export function DocCheckTab() {
|
||||
/>
|
||||
Cookie-Banner pruefen
|
||||
</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>
|
||||
|
||||
{/* Submit */}
|
||||
@@ -270,7 +287,20 @@ export function DocCheckTab() {
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Letzte Pruefungen</h4>
|
||||
<div className="space-y-1">
|
||||
{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">
|
||||
{new Date(h.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
@@ -280,7 +310,7 @@ export function DocCheckTab() {
|
||||
{h.findings} Findings
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</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'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { TextReference } from './TextReference'
|
||||
|
||||
interface ServiceInfo {
|
||||
name: string
|
||||
@@ -14,22 +15,27 @@ interface ServiceInfo {
|
||||
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 {
|
||||
code: string
|
||||
severity: string
|
||||
text: string
|
||||
correction: string
|
||||
doc_title: string
|
||||
}
|
||||
|
||||
interface DiscoveredDocument {
|
||||
title: string
|
||||
url: string
|
||||
doc_type: string
|
||||
language: string
|
||||
word_count: number
|
||||
completeness_pct: number
|
||||
findings_count: number
|
||||
text_reference: TextRef | null
|
||||
}
|
||||
|
||||
interface ScanData {
|
||||
@@ -249,7 +255,12 @@ export function ScanResult({ data }: { data: ScanData }) {
|
||||
</span>
|
||||
<p className="text-sm text-gray-800 flex-1">{f.text}</p>
|
||||
</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">
|
||||
<button onClick={() => setExpandedCorrection(isExp ? null : corrKey)}
|
||||
className="text-xs text-purple-600 hover:text-purple-800 font-medium">
|
||||
@@ -272,14 +283,35 @@ export function ScanResult({ data }: { data: ScanData }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Status */}
|
||||
{data.email_status && (
|
||||
<div className="text-xs text-gray-500 flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${data.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
||||
E-Mail: {data.email_status === 'sent' ? 'Gesendet' : data.email_status}
|
||||
</div>
|
||||
)}
|
||||
{/* PDF Export Button */}
|
||||
<div className="pt-4 border-t flex gap-3">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/agent/scans/pdf', {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
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 { DocCheckTab } from './_components/DocCheckTab'
|
||||
import { ComplianceCheckTab } from './_components/ComplianceCheckTab'
|
||||
import { BannerCheckTab } from './_components/BannerCheckTab'
|
||||
import { ComplianceFAQ } from './_components/ComplianceFAQ'
|
||||
|
||||
type AnalysisMode = 'pre_launch' | 'post_launch'
|
||||
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: '🌐' },
|
||||
]
|
||||
type AnalysisTab = 'scan' | 'compliance-check' | 'banner-check'
|
||||
|
||||
const TABS: { id: AnalysisTab; label: string; desc: string }[] = [
|
||||
{ id: 'quick', label: 'Schnellanalyse', desc: 'Einzelne Seite klassifizieren + bewerten' },
|
||||
{ id: 'scan', label: 'Website-Scan', desc: 'Mehrere Seiten scannen + Dienstleister abgleichen' },
|
||||
{ id: 'doc-check', label: 'Dokumenten-Pruefung', desc: 'Einzelne Dokumente gezielt pruefen' },
|
||||
{ id: 'scan', label: 'Website-Scan', desc: 'Rechtliche Dokumente finden + Dienstleister erkennen' },
|
||||
{ id: 'compliance-check', label: 'Compliance-Check', desc: 'Alle rechtlichen Dokumente zusammen pruefen' },
|
||||
{ id: 'banner-check', label: 'Banner-Check', desc: 'Cookie-Banner auf DSGVO-Konformitaet testen' },
|
||||
]
|
||||
|
||||
export default function AgentPage() {
|
||||
// Restore state from localStorage on mount
|
||||
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) || 'quick')
|
||||
const [tab, setTab] = useState<AnalysisTab>(() => (typeof window !== 'undefined' ? localStorage.getItem('agent-scan-tab') as AnalysisTab : null) || 'compliance-check')
|
||||
const [scanLoading, setScanLoading] = useState(false)
|
||||
const [scanError, setScanError] = useState<string | null>(null)
|
||||
const [scanData, setScanData] = useState<any>(() => {
|
||||
@@ -38,19 +25,15 @@ export default function AgentPage() {
|
||||
})
|
||||
const [scanProgress, setScanProgress] = useState<string>('')
|
||||
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 []
|
||||
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-mode', mode) }, [mode])
|
||||
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(() => {
|
||||
if (!activeScanId || scanData?.services) return
|
||||
let cancelled = false
|
||||
@@ -65,31 +48,17 @@ export default function AgentPage() {
|
||||
const data = await res.json()
|
||||
if (data.progress) setScanProgress(data.progress)
|
||||
if (data.status === 'completed' && data.result) {
|
||||
setScanData(data.result)
|
||||
setScanProgress('')
|
||||
setScanLoading(false)
|
||||
setScanData(data.result); setScanProgress(''); setScanLoading(false)
|
||||
localStorage.setItem('agent-scan-result', JSON.stringify(data.result))
|
||||
localStorage.removeItem('agent-scan-id')
|
||||
setActiveScanId('')
|
||||
_addToHistory(data.result)
|
||||
return
|
||||
localStorage.removeItem('agent-scan-id'); setActiveScanId('')
|
||||
_addToHistory(data.result); return
|
||||
}
|
||||
if (data.status === 'failed') {
|
||||
setScanError(data.error || 'Scan fehlgeschlagen')
|
||||
setScanProgress('')
|
||||
setScanLoading(false)
|
||||
localStorage.removeItem('agent-scan-id')
|
||||
setActiveScanId('')
|
||||
return
|
||||
if (data.status === 'failed' || data.status === 'not_found') {
|
||||
if (data.status === 'failed') setScanError(data.error || 'Scan fehlgeschlagen')
|
||||
setScanProgress(''); setScanLoading(false)
|
||||
localStorage.removeItem('agent-scan-id'); setActiveScanId(''); return
|
||||
}
|
||||
if (data.status === 'not_found') {
|
||||
setScanProgress('')
|
||||
setScanLoading(false)
|
||||
localStorage.removeItem('agent-scan-id')
|
||||
setActiveScanId('')
|
||||
return
|
||||
}
|
||||
} catch { /* retry */ }
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
poll()
|
||||
@@ -97,222 +66,127 @@ export default function AgentPage() {
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const _addToHistory = (result: any) => {
|
||||
const entry = {
|
||||
url: url || result.url || '',
|
||||
date: new Date().toISOString(),
|
||||
findings: result.findings?.length || 0,
|
||||
docs: result.discovered_documents?.length || 0,
|
||||
}
|
||||
const updated = [entry, ...scanHistory].slice(0, 50)
|
||||
setScanHistory(updated)
|
||||
localStorage.setItem('agent-scan-history', JSON.stringify(updated))
|
||||
const resultKey = `scan-result-${Date.now()}`
|
||||
try { localStorage.setItem(resultKey, JSON.stringify(result)) } catch {}
|
||||
const entry = { url: url || result.url || '', date: new Date().toISOString(), findings: result.findings?.length || 0, docs: result.discovered_documents?.length || 0, resultKey }
|
||||
const updated = [entry, ...scanHistory].slice(0, 30)
|
||||
setScanHistory(updated); localStorage.setItem('agent-scan-history', JSON.stringify(updated))
|
||||
}
|
||||
|
||||
const _loadFromHistory = (entry: { url: string }) => {
|
||||
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) => {
|
||||
const handleScan = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!url.trim()) return
|
||||
|
||||
if (tab === 'quick') {
|
||||
analyze(url.trim(), mode)
|
||||
} else {
|
||||
setScanLoading(true)
|
||||
setScanError(null)
|
||||
setScanData(null)
|
||||
setScanProgress('Scan wird gestartet...')
|
||||
try {
|
||||
// Step 1: Start async scan
|
||||
const startRes = await fetch('/api/sdk/v1/agent/scan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: url.trim(), mode }),
|
||||
})
|
||||
if (!startRes.ok) throw new Error(`Scan konnte nicht gestartet werden: ${startRes.status}`)
|
||||
const { scan_id } = await startRes.json()
|
||||
if (!scan_id) throw new Error('Keine Scan-ID erhalten')
|
||||
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++
|
||||
setScanLoading(true); setScanError(null); setScanData(null); setScanProgress('Scan wird gestartet...')
|
||||
try {
|
||||
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' }) })
|
||||
if (!startRes.ok) throw new Error(`Scan konnte nicht gestartet werden: ${startRes.status}`)
|
||||
const { scan_id } = await startRes.json()
|
||||
if (!scan_id) throw new Error('Keine Scan-ID erhalten')
|
||||
setActiveScanId(scan_id); localStorage.setItem('agent-scan-id', scan_id)
|
||||
let attempts = 0
|
||||
while (attempts < 120) {
|
||||
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 (attempts >= maxAttempts) throw new Error('Scan-Timeout (10 Minuten)')
|
||||
} catch (e) {
|
||||
setScanError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||
setScanProgress('')
|
||||
} finally {
|
||||
setScanLoading(false)
|
||||
if (pollData.status === 'failed') throw new Error(pollData.error || 'Scan fehlgeschlagen')
|
||||
attempts++
|
||||
}
|
||||
}
|
||||
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 currentError = tab === 'quick' ? error : scanError
|
||||
const navigateToCheck = (targetTab: AnalysisTab, checkUrl: string) => {
|
||||
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 (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{/* Mode Selection */}
|
||||
<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">
|
||||
<div className="flex border-b border-gray-200 overflow-x-auto">
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} onClick={() => setTab(t.id)}
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
tab === t.id
|
||||
? 'border-purple-500 text-purple-700'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
tab === t.id ? 'border-purple-500 text-purple-700' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Doc Check Tab — own component */}
|
||||
{tab === 'doc-check' && <DocCheckTab />}
|
||||
|
||||
{/* Banner Check Tab — own component */}
|
||||
{tab === 'banner-check' && <BannerCheckTab />}
|
||||
|
||||
{/* URL Input (quick + scan only) */}
|
||||
{(tab === 'quick' || tab === 'scan') && <form onSubmit={handleSubmit} className="flex gap-3">
|
||||
<input type="url" value={url} onChange={e => setUrl(e.target.value)}
|
||||
placeholder={tab === 'scan' ? 'https://www.example.com/' : 'https://example.com/datenschutz'}
|
||||
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={isLoading} required />
|
||||
<button type="submit" disabled={isLoading || !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">
|
||||
{isLoading ? (
|
||||
<><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>{tab === 'scan' ? 'Scanne...' : 'Analysiere...'}</>
|
||||
) : tab === 'scan' ? 'Website scannen' : 'Analysieren'}
|
||||
</button>
|
||||
</form>}
|
||||
|
||||
{/* Scan Progress */}
|
||||
{scanProgress && tab === 'scan' && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{currentError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{currentError}</div>
|
||||
)}
|
||||
|
||||
{/* Quick Analysis Result */}
|
||||
{tab === 'quick' && result && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm space-y-6">
|
||||
<AnalysisResult result={result} />
|
||||
{result.follow_up_questions.length > 0 && (
|
||||
<div className="border-t pt-4">
|
||||
<FollowUpQuestions questions={result.follow_up_questions} answers={result.follow_up_answers} onAnswer={answerFollowUp} />
|
||||
{tab === 'scan' && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-indigo-900">Website-Scan (Discovery)</h3>
|
||||
<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>
|
||||
<form onSubmit={handleScan} 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={scanLoading} required />
|
||||
<button type="submit" disabled={scanLoading || !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">
|
||||
{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>
|
||||
</form>
|
||||
{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>}
|
||||
{scanError && <div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{scanError}</div>}
|
||||
{scanData && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-4 shadow-sm">
|
||||
<h4 className="text-sm font-semibold text-gray-800 mb-3">Jetzt pruefen</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<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">
|
||||
<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>
|
||||
</button>
|
||||
<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="text-sm font-medium text-gray-900">Impressum pruefen</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">§5 TMG Pflichtangaben</div>
|
||||
</button>
|
||||
{discoveredDocs.map((doc: any, i: number) => (
|
||||
<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">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">{doc.title || doc.url}</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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{scanData?.services && <div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm"><ScanResult data={scanData} /></div>}
|
||||
{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={() => { setUrl(h.url); if (h.resultKey) { try { const s = localStorage.getItem(h.resultKey); if (s) { setScanData(JSON.parse(s)); return } } catch {} } }}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scan Result — only render when we have a complete response with services */}
|
||||
{tab === 'scan' && scanData && scanData.services && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||
<ScanResult data={scanData} />
|
||||
</div>
|
||||
)}
|
||||
{tab === 'compliance-check' && <ComplianceCheckTab />}
|
||||
{tab === 'banner-check' && <BannerCheckTab />}
|
||||
|
||||
{/* 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 />
|
||||
</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>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<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 [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).
|
||||
// The SDK context loads state from server/localStorage asynchronously, so
|
||||
// sdkState.complianceScope may arrive AFTER this page has already mounted.
|
||||
@@ -159,6 +167,10 @@ export default function ComplianceScopePage() {
|
||||
// Set applicable regulations from response
|
||||
const regs: ApplicableRegulation[] = data.overview?.applicable_regulations || data.applicable_regulations || []
|
||||
setApplicableRegulations(regs)
|
||||
// Auto-enable all applicable regulations as modules
|
||||
if (enabledModules.length === 0) {
|
||||
setEnabledModules(regs.map(r => r.id))
|
||||
}
|
||||
|
||||
// Derive supervisory authorities
|
||||
const regIds = regs.map(r => r.id)
|
||||
@@ -375,6 +387,8 @@ export default function ComplianceScopePage() {
|
||||
supervisoryAuthorities={supervisoryAuthorities}
|
||||
regulationAssessmentLoading={regulationAssessmentLoading}
|
||||
onGoToObligations={() => { window.location.href = '/sdk/obligations' }}
|
||||
enabledModules={enabledModules}
|
||||
onToggleModule={handleToggleModule}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -141,16 +141,24 @@ export default function ConsentManagementPage() {
|
||||
)}
|
||||
|
||||
{activeTab === 'emails' && (
|
||||
<EmailsTab
|
||||
apiEmailTemplates={apiEmailTemplates}
|
||||
templatesLoading={templatesLoading}
|
||||
savingTemplateId={savingTemplateId}
|
||||
savedTemplates={savedTemplates}
|
||||
setShowCreateTemplateModal={setShowCreateTemplateModal}
|
||||
saveApiEmailTemplate={saveApiEmailTemplate}
|
||||
setPreviewTemplate={setPreviewTemplate}
|
||||
setEditingTemplate={setEditingTemplate}
|
||||
/>
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-xl p-8 text-center">
|
||||
<div className="w-14 h-14 mx-auto mb-4 bg-purple-100 rounded-xl flex items-center justify-center">
|
||||
<svg className="w-7 h-7 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">E-Mail-Templates wurden zentralisiert</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
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' && (
|
||||
|
||||
@@ -212,14 +212,14 @@ export function ControlDetail({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{ctrl.requirements.length > 0 && (
|
||||
{Array.isArray(ctrl.requirements) && ctrl.requirements.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
|
||||
<ol className="list-decimal list-inside space-y-1">{ctrl.requirements.map((r, i) => <li key={i} className="text-sm text-gray-700">{r}</li>)}</ol>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{ctrl.test_procedure.length > 0 && (
|
||||
{Array.isArray(ctrl.test_procedure) && ctrl.test_procedure.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
|
||||
<ol className="list-decimal list-inside space-y-1">{ctrl.test_procedure.map((s, i) => <li key={i} className="text-sm text-gray-700">{s}</li>)}</ol>
|
||||
|
||||
@@ -18,7 +18,8 @@ export interface ControlsMeta {
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export function useControlLibraryState() {
|
||||
export function useControlLibraryState(backendUrlOverride?: string) {
|
||||
const backendUrl = backendUrlOverride || BACKEND_URL
|
||||
const [frameworks, setFrameworks] = useState<Framework[]>([])
|
||||
const [controls, setControls] = useState<CanonicalControl[]>([])
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
@@ -100,7 +101,7 @@ export function useControlLibraryState() {
|
||||
|
||||
const loadFrameworks = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BACKEND_URL}?endpoint=frameworks`)
|
||||
const res = await fetch(`${backendUrl}?endpoint=frameworks`)
|
||||
if (res.ok) setFrameworks(await res.json())
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
@@ -111,7 +112,7 @@ export function useControlLibraryState() {
|
||||
metaAbortRef.current = controller
|
||||
try {
|
||||
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())
|
||||
} catch (e) {
|
||||
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 countQs = buildParams()
|
||||
const [ctrlRes, countRes] = await Promise.all([
|
||||
fetch(`${BACKEND_URL}?endpoint=controls&${qs}`, { signal: controller.signal }),
|
||||
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`, { signal: controller.signal }),
|
||||
fetch(`${backendUrl}?endpoint=controls&${qs}`, { signal: controller.signal }),
|
||||
fetch(`${backendUrl}?endpoint=controls-count&${countQs}`, { signal: controller.signal }),
|
||||
])
|
||||
if (!controller.signal.aborted) {
|
||||
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
||||
@@ -147,7 +148,7 @@ export function useControlLibraryState() {
|
||||
|
||||
const loadReviewCount = useCallback(async () => {
|
||||
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) }
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
@@ -165,14 +166,14 @@ export function useControlLibraryState() {
|
||||
|
||||
const loadProcessedStats = async () => {
|
||||
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 || []) }
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const enterReviewMode = async () => {
|
||||
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) {
|
||||
const items: CanonicalControl[] = await res.json()
|
||||
if (items.length > 0) {
|
||||
|
||||
@@ -62,6 +62,14 @@ export default function ControlLibraryPage() {
|
||||
initial={{
|
||||
...EMPTY_CONTROL,
|
||||
...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,
|
||||
implementation_effort: state.selectedControl.implementation_effort,
|
||||
open_anchors: state.selectedControl.open_anchors.length > 0
|
||||
@@ -69,7 +77,9 @@ export default function ControlLibraryPage() {
|
||||
: [{ framework: '', ref: '', url: '' }],
|
||||
requirements: state.selectedControl.requirements.length > 0 ? state.selectedControl.requirements : [''],
|
||||
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}
|
||||
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',
|
||||
}
|
||||
|
||||
export interface BannerSite {
|
||||
id: string
|
||||
site_id: string
|
||||
site_name: string
|
||||
site_url: string
|
||||
is_active: boolean
|
||||
tcf_enabled?: boolean
|
||||
}
|
||||
|
||||
export function useCookieBanner() {
|
||||
const [categories, setCategories] = useState<CookieCategory[]>([])
|
||||
const [config, setConfig] = useState<BannerConfig>(defaultConfig)
|
||||
const [bannerTexts, setBannerTexts] = useState<BannerTexts>(defaultBannerTexts)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
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(() => {
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
@@ -125,7 +151,20 @@ export function useCookieBanner() {
|
||||
}
|
||||
}
|
||||
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) => {
|
||||
setCategories(prev =>
|
||||
@@ -180,5 +219,6 @@ export function useCookieBanner() {
|
||||
categories, config, bannerTexts, isSaving, exportToast,
|
||||
setConfig, setBannerTexts,
|
||||
handleCategoryToggle, handleExportCode, handleSaveConfig,
|
||||
sites, activeSiteId, setActiveSiteId, createSite,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import { useCookieBanner } from './_hooks/useCookieBanner'
|
||||
import { BannerPreview } from './_components/BannerPreview'
|
||||
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() {
|
||||
const { state } = useSDK()
|
||||
const [activeTab, setActiveTab] = useState<BannerTab>('config')
|
||||
const {
|
||||
categories, config, bannerTexts, isSaving, exportToast,
|
||||
setConfig, setBannerTexts,
|
||||
handleCategoryToggle, handleExportCode, handleSaveConfig,
|
||||
sites, activeSiteId, setActiveSiteId, createSite,
|
||||
} = useCookieBanner()
|
||||
|
||||
const totalCookies = categories.reduce((sum, cat) => sum + cat.cookies.length, 0)
|
||||
@@ -57,6 +67,58 @@ export default function CookieBannerPage() {
|
||||
</div>
|
||||
</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 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
@@ -207,6 +269,7 @@ export default function CookieBannerPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { LegalTemplateResult } from '@/lib/sdk/types'
|
||||
import { RuleEngineResult } from '../ruleEngine'
|
||||
import ReviewAssignmentPanel from './ReviewAssignmentPanel'
|
||||
|
||||
interface GeneratorPreviewTabProps {
|
||||
template: LegalTemplateResult
|
||||
@@ -10,8 +12,76 @@ interface GeneratorPreviewTabProps {
|
||||
missing: string[]
|
||||
onCopy: () => 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({
|
||||
template,
|
||||
ruleResult,
|
||||
@@ -19,13 +89,20 @@ export default function GeneratorPreviewTab({
|
||||
missing,
|
||||
onCopy,
|
||||
onExportMarkdown,
|
||||
onSaveToWorkflow,
|
||||
saveStatus,
|
||||
}: GeneratorPreviewTabProps) {
|
||||
const [viewMode, setViewMode] = useState<'preview' | 'markdown'>('preview')
|
||||
|
||||
const htmlContent = markdownToHtml(renderedContent)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Violations */}
|
||||
{ruleResult && ruleResult.violations.length > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||
<p className="text-sm font-semibold text-red-700 mb-2">
|
||||
🔴 {ruleResult.violations.length} Fehler
|
||||
{ruleResult.violations.length} Fehler
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{ruleResult.violations.map((v) => (
|
||||
@@ -36,6 +113,8 @@ export default function GeneratorPreviewTab({
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{ruleResult && ruleResult.warnings.filter((w) => w.id !== 'WARN_LEGAL_REVIEW').length > 0 && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4">
|
||||
<ul className="space-y-1">
|
||||
@@ -43,69 +122,156 @@ export default function GeneratorPreviewTab({
|
||||
.filter((w) => w.id !== 'WARN_LEGAL_REVIEW')
|
||||
.map((w) => (
|
||||
<li key={w.id} className="text-xs text-yellow-700">
|
||||
🟡 <span className="font-mono font-medium">[{w.id}]</span> {w.message}
|
||||
<span className="font-mono font-medium">[{w.id}]</span> {w.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legal notice */}
|
||||
{ruleResult && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3">
|
||||
<p className="text-xs text-blue-700">
|
||||
ℹ️ Rechtlicher Hinweis: Diese Vorlage ist MIT-lizenziert. Vor Produktionseinsatz
|
||||
wird eine rechtliche Überprüfung dringend empfohlen.
|
||||
Rechtlicher Hinweis: Diese Vorlage ist MIT-lizenziert. Vor Produktionseinsatz
|
||||
wird eine rechtliche Ueberpruefung dringend empfohlen.
|
||||
</p>
|
||||
</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">
|
||||
<span className="text-sm text-gray-600">
|
||||
{missing.length > 0 && (
|
||||
<span className="text-orange-600">
|
||||
⚠ {missing.length} Platzhalter noch nicht ausgefüllt
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-1 bg-gray-100 rounded-lg p-0.5">
|
||||
<button
|
||||
onClick={onCopy}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors"
|
||||
onClick={() => setViewMode('preview')}
|
||||
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">
|
||||
<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
|
||||
Vorschau
|
||||
</button>
|
||||
<button
|
||||
onClick={onExportMarkdown}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors"
|
||||
onClick={() => setViewMode('markdown')}
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Markdown
|
||||
</button>
|
||||
</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
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
onClick={() => {
|
||||
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
|
||||
</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 className="bg-gray-50 rounded-xl border border-gray-200 p-6 max-h-[600px] overflow-y-auto">
|
||||
<pre className="text-sm text-gray-800 whitespace-pre-wrap leading-relaxed font-sans">
|
||||
{renderedContent}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{viewMode === 'markdown' ? (
|
||||
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6 max-h-[800px] overflow-y-auto">
|
||||
<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 && (
|
||||
<div className="text-xs text-orange-600 bg-orange-50 p-3 rounded-lg border border-orange-200">
|
||||
<strong>Attribution erforderlich:</strong> {template.attributionText}
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function GeneratorSection({
|
||||
const [activeTab, setActiveTab] = useState<'placeholders' | 'preview'>('placeholders')
|
||||
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 uncovered = useMemo(() => getUncoveredPlaceholders(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 [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 blob = new Blob([renderedContent], { type: 'text/markdown' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
@@ -160,6 +199,33 @@ export default function GeneratorSection({
|
||||
</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">
|
||||
<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" />
|
||||
@@ -223,6 +289,8 @@ export default function GeneratorSection({
|
||||
missing={missing}
|
||||
onCopy={handleCopy}
|
||||
onExportMarkdown={handleExportMarkdown}
|
||||
onSaveToWorkflow={handleSaveToWorkflow}
|
||||
saveStatus={saveStatus}
|
||||
/>
|
||||
)}
|
||||
</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 }[] = [
|
||||
{ key: 'all', label: 'Alle', types: null },
|
||||
{ key: 'privacy_policy', label: 'Datenschutz', types: ['privacy_policy'] },
|
||||
{ key: 'terms', label: 'AGB', types: ['terms_of_service', 'agb', 'clause'] },
|
||||
{ key: 'impressum', label: 'Impressum', types: ['impressum'] },
|
||||
{ key: 'dpa', label: 'AVV/DPA', types: ['dpa'] },
|
||||
{ key: 'nda', label: 'NDA', types: ['nda'] },
|
||||
{ key: 'sla', label: 'SLA', types: ['sla'] },
|
||||
{ key: 'acceptable_use', label: 'AUP', types: ['acceptable_use'] },
|
||||
{ key: 'widerruf', label: 'Widerruf', types: ['widerruf'] },
|
||||
{ key: 'cookie', label: 'Cookie', types: ['cookie_policy', 'cookie_banner'] },
|
||||
{ key: 'cloud', label: 'Cloud', types: ['cloud_service_agreement'] },
|
||||
{ key: 'misc', label: 'Weitere', types: ['community_guidelines', 'copyright_policy', 'data_usage_clause'] },
|
||||
{ key: 'dsfa', label: 'DSFA', types: ['dsfa'] },
|
||||
{ key: 'dsr', label: 'DSR-Prozesse', types: [
|
||||
|
||||
// ── Nach Nutzungskontext sortiert ──────────────────────────────────────
|
||||
|
||||
// Jede Website / App braucht:
|
||||
{ key: 'website', label: 'Website / App', types: ['privacy_policy', 'impressum', 'cookie_policy', 'cookie_banner', 'social_media_dsi'] },
|
||||
|
||||
// Online-Shop / E-Commerce:
|
||||
{ key: 'shop', label: 'Online-Shop', types: ['agb', 'widerruf', 'privacy_policy', 'impressum', 'cookie_policy', 'cookie_banner'] },
|
||||
|
||||
// SaaS / Cloud-Dienst:
|
||||
{ key: 'saas', label: 'SaaS / Cloud', types: ['agb', 'dpa', 'sla', 'cloud_service_agreement', 'privacy_policy', 'terms_of_use'] },
|
||||
|
||||
// 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_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',
|
||||
HOSTING: 'Hosting-Provider',
|
||||
FEATURES: 'Dokument-Features & Textbausteine',
|
||||
TOM: 'TOM-Dokumentation',
|
||||
DPA: 'AVV / Auftragsverarbeitung',
|
||||
}
|
||||
|
||||
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: 'HAS_DISPUTE_RESOLUTION', label: 'Streitbeilegungshinweis', type: 'boolean' },
|
||||
{ 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,
|
||||
ProviderCtx,
|
||||
ComputedFlags,
|
||||
TOMCtx,
|
||||
DPACtx,
|
||||
} from './contextBridge'
|
||||
|
||||
// =============================================================================
|
||||
@@ -44,6 +46,8 @@ export function contextToPlaceholders(ctx: TemplateContext): Record<string, stri
|
||||
const con = ctx.CONSENT
|
||||
const h = ctx.HOSTING
|
||||
const f = ctx.FEATURES
|
||||
const tom = ctx.TOM
|
||||
const dpa = ctx.DPA
|
||||
|
||||
const address = providerAddress(p)
|
||||
|
||||
@@ -180,6 +184,86 @@ export function contextToPlaceholders(ctx: TemplateContext): Record<string, stri
|
||||
'{{LIMITATION_CAP_TEXT}}': str(f.LIMITATION_CAP_TEXT),
|
||||
'{{CONSUMER_WITHDRAWAL_TEXT}}': str(f.CONSUMER_WITHDRAWAL_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}}'],
|
||||
CONSENT: ['{{WEBSITE_NAME}}', '{{ANALYTICS_TOOLS}}', '{{MARKETING_PARTNERS}}', '{{ANALYTICS_TOOLS_LIST}}', '{{MARKETING_PARTNERS_LIST}}'],
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
PROVIDER: ProviderCtx
|
||||
CUSTOMER: CustomerCtx
|
||||
@@ -180,6 +258,8 @@ export interface TemplateContext {
|
||||
CONSENT: ConsentCtx
|
||||
HOSTING: HostingCtx
|
||||
FEATURES: FeaturesCtx
|
||||
TOM: TOMCtx
|
||||
DPA: DPACtx
|
||||
}
|
||||
|
||||
export interface ComputedFlags {
|
||||
@@ -263,6 +343,37 @@ export const EMPTY_CONTEXT: TemplateContext = {
|
||||
LIMITATION_CAP_TEXT: '', HAS_WITHDRAWAL: false, CONSUMER_WITHDRAWAL_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 { TemplateContext, EMPTY_CONTEXT } from './contextBridge'
|
||||
import { CATEGORIES } from './_constants'
|
||||
import { getGeneratorDefaults, getProfileLabel } from './scopeDefaults'
|
||||
import TemplateLibrary from './_components/TemplateLibrary'
|
||||
import GeneratorSection from './_components/GeneratorSection'
|
||||
import RecommendedDocuments from './_components/RecommendedDocuments'
|
||||
|
||||
function DocumentGeneratorPageInner() {
|
||||
const { state } = useSDK()
|
||||
@@ -86,6 +88,119 @@ function DocumentGeneratorPageInner() {
|
||||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (selectedDataPointsData && selectedDataPointsData.length > 0) {
|
||||
@@ -177,6 +292,12 @@ function DocumentGeneratorPageInner() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommended documents based on scope profile */}
|
||||
<RecommendedDocuments
|
||||
allTemplates={allTemplates}
|
||||
onUseTemplate={handleUseTemplate}
|
||||
/>
|
||||
|
||||
<TemplateLibrary
|
||||
allTemplates={allTemplates}
|
||||
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 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 [saving, setSaving] = useState(false)
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [processingActivity, setProcessingActivity] = useState('')
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
|
||||
const [riskLevel, setRiskLevel] = useState<'low' | 'medium' | 'high' | 'critical'>('low')
|
||||
const [selectedMeasures, setSelectedMeasures] = useState<string[]>([])
|
||||
const [title, setTitle] = useState(prefill?.title || '')
|
||||
const [description, setDescription] = useState(prefill?.description || '')
|
||||
const [processingActivity, setProcessingActivity] = useState(prefill?.processingActivity || '')
|
||||
const [selectedCategories, setSelectedCategories] = useState<string[]>(prefill?.dataCategories || [])
|
||||
const riskMap2: Record<string, 'low' | 'medium' | 'high' | 'critical'> = { niedrig: 'low', mittel: 'medium', hoch: 'high', kritisch: 'critical' }
|
||||
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'> = {
|
||||
Niedrig: 'low', Mittel: 'medium', Hoch: 'high', Kritisch: 'critical',
|
||||
@@ -28,7 +50,12 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
||||
riskLevel,
|
||||
measures: selectedMeasures,
|
||||
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()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
@@ -48,7 +75,7 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
||||
|
||||
{/* Progress Steps */}
|
||||
<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}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
s < step ? 'bg-green-500 text-white' :
|
||||
@@ -60,7 +87,7 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
||||
</svg>
|
||||
) : s}
|
||||
</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>
|
||||
))}
|
||||
</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"
|
||||
/>
|
||||
</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>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungstaetigkeit</label>
|
||||
<input
|
||||
@@ -167,6 +208,43 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
||||
</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>
|
||||
|
||||
{/* Navigation */}
|
||||
@@ -179,11 +257,11 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
|
||||
{step === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => step < 4 ? setStep(step + 1) : handleSubmit()}
|
||||
onClick={() => step < 5 ? setStep(step + 1) : handleSubmit()}
|
||||
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"
|
||||
>
|
||||
{step === 4 ? (saving ? 'Wird erstellt...' : 'DSFA erstellen') : 'Weiter'}
|
||||
{step === 5 ? (saving ? 'Wird erstellt...' : 'DSFA erstellen') : 'Weiter'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import { DocumentUploadSection, type UploadedDocument } from '@/components/sdk'
|
||||
import { DSFACard, type DSFA } from './_components/DSFACard'
|
||||
import { GeneratorWizard } from './_components/GeneratorWizard'
|
||||
import { prefillDSFAFromScope, isDSFARequired } from '@/lib/sdk/dsfa/prefill-from-scope'
|
||||
|
||||
export default function DSFAPage() {
|
||||
const router = useRouter()
|
||||
@@ -17,6 +18,17 @@ export default function DSFAPage() {
|
||||
const [showGenerator, setShowGenerator] = useState(false)
|
||||
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 () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
@@ -120,10 +132,42 @@ export default function DSFAPage() {
|
||||
)}
|
||||
</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 && (
|
||||
<GeneratorWizard
|
||||
onClose={() => setShowGenerator(false)}
|
||||
onSubmit={handleCreateDSFA}
|
||||
prefill={prefill}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ export function ActionButtons({
|
||||
onExtendDeadline,
|
||||
onComplete,
|
||||
onReject,
|
||||
onAssign
|
||||
onAssign,
|
||||
onRejectArt11,
|
||||
}: {
|
||||
request: DSRRequest
|
||||
onVerifyIdentity: () => void
|
||||
@@ -17,15 +18,31 @@ export function ActionButtons({
|
||||
onComplete: () => void
|
||||
onReject: () => void
|
||||
onAssign: () => void
|
||||
onRejectArt11?: () => void
|
||||
}) {
|
||||
const isTerminal = request.status === 'completed' || request.status === 'rejected' || request.status === 'cancelled'
|
||||
|
||||
if (isTerminal) {
|
||||
return (
|
||||
<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
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -33,12 +50,23 @@ export function ActionButtons({
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{!request.identityVerification.verified && (
|
||||
<button
|
||||
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>
|
||||
<>
|
||||
<button
|
||||
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>
|
||||
{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
|
||||
|
||||
@@ -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
|
||||
|
||||
// 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 { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import { History } from 'lucide-react'
|
||||
import { History, Globe, User } from 'lucide-react'
|
||||
|
||||
import { ConsentRecord } from './_types'
|
||||
import { useConsents } from './_hooks/useConsents'
|
||||
@@ -12,8 +12,13 @@ import { SearchAndFilter } from './_components/SearchAndFilter'
|
||||
import { RecordsTable } from './_components/RecordsTable'
|
||||
import { Pagination } from './_components/Pagination'
|
||||
import { ConsentDetailModal } from './_components/ConsentDetailModal'
|
||||
import BannerConsentsTab from './_components/BannerConsentsTab'
|
||||
|
||||
type ConsentTab = 'visitors' | 'users'
|
||||
|
||||
export default function EinwilligungenPage() {
|
||||
const [activeTab, setActiveTab] = useState<ConsentTab>('visitors')
|
||||
|
||||
const {
|
||||
records,
|
||||
currentPage,
|
||||
@@ -63,51 +68,84 @@ export default function EinwilligungenPage() {
|
||||
{/* Navigation Tabs */}
|
||||
<EinwilligungenNavTabs />
|
||||
|
||||
{/* Stats */}
|
||||
<StatsGrid
|
||||
total={globalStats.total}
|
||||
active={globalStats.active}
|
||||
revoked={globalStats.revoked}
|
||||
versionUpdates={versionUpdates}
|
||||
/>
|
||||
|
||||
{/* 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" />
|
||||
<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>
|
||||
{/* Consent Type Tabs: Website-Besucher / Login-Nutzer */}
|
||||
<div className="flex gap-1 p-1 bg-gray-100 rounded-xl w-fit">
|
||||
<button
|
||||
onClick={() => setActiveTab('visitors')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeTab === 'visitors'
|
||||
? 'bg-white text-purple-700 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
Website-Besucher
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('users')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
activeTab === 'users'
|
||||
? 'bg-white text-purple-700 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
Login-Nutzer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<SearchAndFilter
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
/>
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'visitors' ? (
|
||||
<BannerConsentsTab />
|
||||
) : (
|
||||
<>
|
||||
{/* Stats */}
|
||||
<StatsGrid
|
||||
total={globalStats.total}
|
||||
active={globalStats.active}
|
||||
revoked={globalStats.revoked}
|
||||
versionUpdates={versionUpdates}
|
||||
/>
|
||||
|
||||
{/* Records Table */}
|
||||
<RecordsTable records={filteredRecords} onShowDetails={setSelectedRecord} />
|
||||
{/* 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" />
|
||||
<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 */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalRecords={totalRecords}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
{/* Search and Filter */}
|
||||
<SearchAndFilter
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
/>
|
||||
|
||||
{/* Detail Modal */}
|
||||
{selectedRecord && (
|
||||
<ConsentDetailModal
|
||||
record={selectedRecord}
|
||||
onClose={() => setSelectedRecord(null)}
|
||||
onRevoke={handleRevoke}
|
||||
/>
|
||||
{/* Records Table */}
|
||||
<RecordsTable records={filteredRecords} onShowDetails={setSelectedRecord} />
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalRecords={totalRecords}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
|
||||
{/* Detail Modal */}
|
||||
{selectedRecord && (
|
||||
<ConsentDetailModal
|
||||
record={selectedRecord}
|
||||
onClose={() => setSelectedRecord(null)}
|
||||
onRevoke={handleRevoke}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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 (
|
||||
<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 */}
|
||||
<div
|
||||
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 ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
@@ -223,7 +223,7 @@ export default function IACEFlowFAB() {
|
||||
<button
|
||||
ref={fabRef}
|
||||
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"
|
||||
>
|
||||
{/* 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 || '',
|
||||
description: initialData?.description || '',
|
||||
safety_relevant: initialData?.safety_relevant || false,
|
||||
ce_marked: initialData?.ce_marked || false,
|
||||
parent_id: parentId || initialData?.parent_id || null,
|
||||
})
|
||||
|
||||
@@ -73,6 +74,19 @@ export function ComponentForm({
|
||||
</label>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Sicherheitsrelevant</span>
|
||||
</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">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
||||
<textarea
|
||||
|
||||
@@ -5,10 +5,12 @@ export interface Component {
|
||||
version: string
|
||||
description: string
|
||||
safety_relevant: boolean
|
||||
ce_marked?: boolean
|
||||
parent_id: string | null
|
||||
children: Component[]
|
||||
library_component_id?: string
|
||||
energy_source_ids?: string[]
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface LibraryComponent {
|
||||
@@ -41,6 +43,7 @@ export interface ComponentFormData {
|
||||
version: string
|
||||
description: string
|
||||
safety_relevant: boolean
|
||||
ce_marked: boolean
|
||||
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 { 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 }: {
|
||||
hazards: Hazard[]
|
||||
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}
|
||||
</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 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>
|
||||
|
||||
+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